Mercurial
comparison react_games/src/Todo.tsx @ 37:fb9bcd3145cb
[ReactGames] Few games I made using react just to practice few things.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 01 Dec 2025 20:22:47 -0800 |
| parents | |
| children |
comparison
equal
deleted
inserted
replaced
| 36:84672efec192 | 37:fb9bcd3145cb |
|---|---|
| 1 import ReactDOM from 'react-dom/client'; | |
| 2 import { ActionDispatch, CSSProperties, Dispatch, memo, SetStateAction, startTransition, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; | |
| 3 | |
| 4 // Thank you chatGPT for colors lmao.... | |
| 5 const colors = { | |
| 6 bg: '#0b1220', | |
| 7 surface: '#0f172a', | |
| 8 card: '#111827', | |
| 9 border: '#1f2937', | |
| 10 text: '#e5e7eb', | |
| 11 muted: '#9ca3af', | |
| 12 accent: '#4f46e5', | |
| 13 accent2: '#10b981', | |
| 14 danger: '#ef4444', | |
| 15 }; | |
| 16 | |
| 17 const shadow = '0 10px 20px rgba(0,0,0,.25)'; | |
| 18 | |
| 19 type styleComponents = | |
| 20 | "page" | |
| 21 | "card" | |
| 22 | "header" | |
| 23 | "title" | |
| 24 | "form" | |
| 25 | "input" | |
| 26 | "btnPrimary" | |
| 27 | "btnSuccess" | |
| 28 | "btnDanger" | |
| 29 | "btnSm" | |
| 30 | "filterBar" | |
| 31 | "filterBtn" | |
| 32 | "filterActive" | |
| 33 | "list" | |
| 34 | "todo" | |
| 35 | "todoBusy" | |
| 36 | "todoText" | |
| 37 | "todoCompleted" | |
| 38 | "badge" | |
| 39 | "badgeDot" | |
| 40 | "actions" | |
| 41 | "btn" | |
| 42 | "page" | |
| 43 | "page" | |
| 44 | "page" | |
| 45 | "page" | |
| 46 | "page"; | |
| 47 | |
| 48 const styles: Record<styleComponents, CSSProperties> = { | |
| 49 page: { | |
| 50 minHeight: '100vh', | |
| 51 display: 'grid', | |
| 52 placeItems: 'center', | |
| 53 padding: 32, | |
| 54 background: colors.bg, | |
| 55 color: colors.text, | |
| 56 fontFamily: | |
| 57 "system-ui, -apple-system, Segoe UI, Roboto, 'Helvetica Neue', Arial, 'Apple Color Emoji','Segoe UI Emoji'", | |
| 58 }, | |
| 59 card: { | |
| 60 minWidth: 400, | |
| 61 maxWidth: '92vw', | |
| 62 background: colors.surface, | |
| 63 border: `1px solid ${colors.border}`, | |
| 64 borderRadius: 16, | |
| 65 boxShadow: shadow, | |
| 66 padding: '24px 24px 20px', | |
| 67 }, | |
| 68 header: { | |
| 69 display: 'flex', | |
| 70 alignItems: 'center', | |
| 71 justifyContent: 'space-between', | |
| 72 gap: 12, | |
| 73 marginBottom: 16, | |
| 74 }, | |
| 75 title: { margin: 0, fontSize: 24, letterSpacing: 0.2 }, | |
| 76 | |
| 77 form: { display: 'flex', gap: 10, marginBottom: 14 }, | |
| 78 input: { | |
| 79 flex: 1, | |
| 80 height: 40, | |
| 81 padding: '0 12px', | |
| 82 border: `1px solid ${colors.border}`, | |
| 83 borderRadius: 10, | |
| 84 background: colors.card, | |
| 85 color: colors.text, | |
| 86 outline: 'none', | |
| 87 }, | |
| 88 | |
| 89 btn: { | |
| 90 appearance: 'none', | |
| 91 border: `1px solid ${colors.border}`, | |
| 92 background: colors.card, | |
| 93 color: colors.text, | |
| 94 padding: '10px 14px', | |
| 95 borderRadius: 10, | |
| 96 cursor: 'pointer', | |
| 97 fontSize: 14, | |
| 98 userSelect: 'none', | |
| 99 transition: 'transform .05s ease, background .15s, border-color .15s', | |
| 100 }, | |
| 101 btnPrimary: { background: colors.accent, borderColor: colors.accent, color: '#fff' }, | |
| 102 btnSuccess: { background: colors.accent2, borderColor: colors.accent2, color: '#fff' }, | |
| 103 btnDanger: { background: colors.danger, borderColor: colors.danger, color: '#fff' }, | |
| 104 btnSm: { padding: '8px 10px', fontSize: 13, borderRadius: 8 }, | |
| 105 | |
| 106 filterBar: { display: 'flex', gap: 8, marginTop: 8, marginBottom: 18 }, | |
| 107 filterBtn: { | |
| 108 appearance: 'none', | |
| 109 border: `1px solid ${colors.border}`, | |
| 110 background: colors.card, | |
| 111 color: colors.text, | |
| 112 padding: '8px 12px', | |
| 113 fontSize: 13, | |
| 114 borderRadius: 9999, | |
| 115 cursor: 'pointer', | |
| 116 }, | |
| 117 filterActive: { | |
| 118 background: '#1a2140', // subtle “active” fill | |
| 119 borderColor: '#2a3a8a', | |
| 120 }, | |
| 121 | |
| 122 list: { display: 'grid', gap: 10 }, | |
| 123 | |
| 124 todo: { | |
| 125 display: 'grid', | |
| 126 gridTemplateColumns: '1fr auto auto', | |
| 127 gap: 10, | |
| 128 alignItems: 'center', | |
| 129 background: colors.card, | |
| 130 border: `1px solid ${colors.border}`, | |
| 131 borderRadius: 12, | |
| 132 padding: '12px 12px', | |
| 133 }, | |
| 134 todoBusy: { opacity: 0.75 }, | |
| 135 | |
| 136 todoText: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 16 }, | |
| 137 todoCompleted: { textDecoration: 'line-through', color: colors.muted }, | |
| 138 | |
| 139 badge: { | |
| 140 display: 'inline-flex', | |
| 141 alignItems: 'center', | |
| 142 gap: 6, | |
| 143 padding: '2px 8px', | |
| 144 fontSize: 12, | |
| 145 borderRadius: 9999, | |
| 146 border: `1px solid ${colors.border}`, | |
| 147 }, | |
| 148 badgeDot: { | |
| 149 width: 8, | |
| 150 height: 8, | |
| 151 borderRadius: 9999, | |
| 152 display: 'inline-block', | |
| 153 }, | |
| 154 actions: { display: 'flex', gap: 8 }, | |
| 155 }; | |
| 156 | |
| 157 enum TodoStatus { | |
| 158 IN_PROGRESS, | |
| 159 COMPLETED, | |
| 160 DELETED, | |
| 161 } | |
| 162 | |
| 163 interface Todo { | |
| 164 id: string; | |
| 165 value: string; | |
| 166 status: TodoStatus; | |
| 167 isOptimistic: boolean; | |
| 168 } | |
| 169 | |
| 170 interface TodoComponentProp { | |
| 171 todo: Todo; | |
| 172 handleOnClickButton: (todoId: string, status: TodoStatus, type: TodoActionEnum) => void; | |
| 173 } | |
| 174 | |
| 175 const TodoComponent = memo(({ todo, handleOnClickButton }: TodoComponentProp) => { | |
| 176 | |
| 177 const statusLabel = | |
| 178 todo.status === TodoStatus.IN_PROGRESS ? 'In progress' : | |
| 179 todo.status === TodoStatus.COMPLETED ? 'Completed' : 'Deleted'; | |
| 180 | |
| 181 const badgeDotColor = | |
| 182 todo.status === TodoStatus.IN_PROGRESS ? '#f59e0b' : | |
| 183 todo.status === TodoStatus.COMPLETED ? '#10b981' : | |
| 184 '#6b7280'; | |
| 185 | |
| 186 return ( | |
| 187 <div style={{ ...styles.todo, ...(todo.isOptimistic ? styles.todoBusy : null) }} aria-busy={todo.isOptimistic}> | |
| 188 <div | |
| 189 style={{ | |
| 190 ...styles.todoText, | |
| 191 ...(todo.status === TodoStatus.COMPLETED ? styles.todoCompleted : null), | |
| 192 }} | |
| 193 > | |
| 194 <span style={styles.badge}> | |
| 195 <span style={{ ...styles.badgeDot, background: badgeDotColor }} /> | |
| 196 {statusLabel} | |
| 197 </span> | |
| 198 <span style={ todo.isOptimistic ?{ fontSize: 12, color: colors.muted } : {}}>{todo.value}</span> | |
| 199 </div> | |
| 200 | |
| 201 <div style={styles.actions}> | |
| 202 {todo.status !== TodoStatus.COMPLETED && ( | |
| 203 <button style={{ ...styles.btn, ...styles.btnSm, ...styles.btnSuccess }} | |
| 204 onClick={() => handleOnClickButton(todo.id, TodoStatus.COMPLETED, TodoActionEnum.COMPLETED)}> | |
| 205 Complete | |
| 206 </button> | |
| 207 )} | |
| 208 {todo.status !== TodoStatus.DELETED && ( | |
| 209 <button style={{ ...styles.btn, ...styles.btnSm, ...styles.btnDanger }} | |
| 210 onClick={() => handleOnClickButton(todo.id, TodoStatus.DELETED, TodoActionEnum.REMOVED)}> | |
| 211 Delete | |
| 212 </button> | |
| 213 )} | |
| 214 </div> | |
| 215 </div> | |
| 216 ); | |
| 217 }); | |
| 218 | |
| 219 const todoTableCss: CSSProperties = { display: 'grid', gridTemplateColumns: '1fr', gap: 10, width: '100%' }; | |
| 220 | |
| 221 enum TodoActionEnum { | |
| 222 ADD, | |
| 223 COMPLETED, | |
| 224 REMOVED, | |
| 225 CONFRIM_FROM_BE, | |
| 226 REVERT_FROM_BE, | |
| 227 HYDRATE, | |
| 228 } | |
| 229 | |
| 230 type TodoAction = | |
| 231 | { type: TodoActionEnum.ADD; todo: Todo } | |
| 232 | { type: TodoActionEnum.COMPLETED; todoId: string } | |
| 233 | { type: TodoActionEnum.REMOVED; todoId: string } | |
| 234 | { type: TodoActionEnum.CONFRIM_FROM_BE; todoId: string } | |
| 235 | { type: TodoActionEnum.REVERT_FROM_BE; todoId: string } | |
| 236 | { type: TodoActionEnum.HYDRATE; todos: Todo[] }; | |
| 237 | |
| 238 const todoReducer = (state: Todo[], action: TodoAction): Todo[] => { | |
| 239 switch (action.type) { | |
| 240 case TodoActionEnum.ADD: | |
| 241 return [...state, action.todo]; | |
| 242 case TodoActionEnum.COMPLETED: | |
| 243 return state.map((t) => (t.id === action.todoId ? { ...t, status: TodoStatus.COMPLETED } : t)); | |
| 244 case TodoActionEnum.REMOVED: | |
| 245 return state.map((t) => (t.id === action.todoId ? { ...t, status: TodoStatus.DELETED } : t)); | |
| 246 case TodoActionEnum.CONFRIM_FROM_BE: | |
| 247 return state.map((t) => (t.id === action.todoId ? { ...t, isOptimistic: false } : t)); | |
| 248 case TodoActionEnum.REVERT_FROM_BE: | |
| 249 return state.filter((t) => t.id !== action.todoId); | |
| 250 case TodoActionEnum.HYDRATE: | |
| 251 return action.todos.map((todo) => ({ | |
| 252 ...todo, | |
| 253 isOptimistic: false, | |
| 254 })); | |
| 255 default: | |
| 256 return state; | |
| 257 } | |
| 258 }; | |
| 259 | |
| 260 type StatusBuckets = Record<TodoStatus, Todo[]>; | |
| 261 | |
| 262 interface TodoPageComponentProp { | |
| 263 userKeyword: string; | |
| 264 } | |
| 265 | |
| 266 const TodoPageComponent = ({userKeyword}: TodoPageComponentProp) => { | |
| 267 const [todos, dispatch] = useReducer(todoReducer, []); | |
| 268 const [todoTypes, setTodoTypes] = useState<TodoStatus>(TodoStatus.IN_PROGRESS); | |
| 269 const todoInputRef = useRef<HTMLInputElement>(null); | |
| 270 | |
| 271 useEffect(()=>{ | |
| 272 const ctrl = new AbortController(); | |
| 273 (async () => { | |
| 274 try { | |
| 275 const res = await fetch( | |
| 276 `/api/v1/todo?key=${userKeyword}`, { signal: ctrl.signal }); | |
| 277 if (!res.ok) return; | |
| 278 const todos = (await res.json()).todos; | |
| 279 dispatch({ type: TodoActionEnum.HYDRATE, todos }) | |
| 280 } catch (e) { | |
| 281 console.log(e); | |
| 282 } | |
| 283 })(); | |
| 284 return () => ctrl.abort(); | |
| 285 },[]); | |
| 286 | |
| 287 const buckets = useMemo<StatusBuckets>(() => { | |
| 288 const r: StatusBuckets = { | |
| 289 [TodoStatus.IN_PROGRESS]: [], | |
| 290 [TodoStatus.COMPLETED]: [], | |
| 291 [TodoStatus.DELETED]: [], | |
| 292 }; | |
| 293 for (const t of todos) r[t.status].push(t); | |
| 294 return r; | |
| 295 }, [todos]); | |
| 296 | |
| 297 const visibleTodos = buckets[todoTypes]; | |
| 298 | |
| 299 const handleSubmit = useCallback((event: React.FormEvent) => { | |
| 300 event.preventDefault(); | |
| 301 const todoInput = todoInputRef.current; | |
| 302 if (!todoInput) return; | |
| 303 const value = todoInput.value.trim(); | |
| 304 if (!value) return; | |
| 305 | |
| 306 const newTodo: Todo = { | |
| 307 id: crypto.randomUUID(), | |
| 308 value, | |
| 309 status: TodoStatus.IN_PROGRESS, | |
| 310 isOptimistic: true, | |
| 311 }; | |
| 312 dispatch({ type: TodoActionEnum.ADD, todo: newTodo }); | |
| 313 todoInput.value = ''; | |
| 314 | |
| 315 startTransition(async () => { | |
| 316 try { | |
| 317 const res = await fetch(`/api/v1/todo?key=${userKeyword}`, { | |
| 318 method: 'POST', | |
| 319 headers: { 'Content-Type': 'application/json' }, | |
| 320 body: JSON.stringify({ newTodo }), | |
| 321 }); | |
| 322 if (res.ok) dispatch({ type: TodoActionEnum.CONFRIM_FROM_BE, todoId: newTodo.id }); | |
| 323 else dispatch({ type: TodoActionEnum.REVERT_FROM_BE, todoId: newTodo.id }); | |
| 324 } catch { | |
| 325 dispatch({ type: TodoActionEnum.REVERT_FROM_BE, todoId: newTodo.id }); | |
| 326 } | |
| 327 }); | |
| 328 }, []); | |
| 329 | |
| 330 const onClickUpdate = useCallback( | |
| 331 (todoId: string, status: TodoStatus, type: TodoActionEnum) => { | |
| 332 dispatch({ type, todoId } as TodoAction); | |
| 333 startTransition(async () => { | |
| 334 try { | |
| 335 const res = await fetch(`/api/v1/todo?key=${userKeyword}`, { | |
| 336 method: 'PATCH', | |
| 337 headers: { 'Content-Type': 'application/json' }, | |
| 338 body: JSON.stringify({ todoId, status }), | |
| 339 }); | |
| 340 if (res.ok) dispatch({ type: TodoActionEnum.CONFRIM_FROM_BE, todoId }); | |
| 341 else dispatch({ type: TodoActionEnum.REVERT_FROM_BE, todoId }); | |
| 342 } catch { | |
| 343 dispatch({ type: TodoActionEnum.REVERT_FROM_BE, todoId }); | |
| 344 } | |
| 345 }) | |
| 346 }, [dispatch]); | |
| 347 | |
| 348 return ( | |
| 349 <> | |
| 350 <div style={styles.header}> | |
| 351 <h1 style={styles.title}>TODO!</h1> | |
| 352 <div style={styles.filterBar} role="tablist" aria-label="Filter"> | |
| 353 <button | |
| 354 style={{ ...styles.filterBtn, ...(todoTypes === TodoStatus.IN_PROGRESS ? styles.filterActive : null) }} | |
| 355 onClick={() => setTodoTypes(TodoStatus.IN_PROGRESS)} | |
| 356 > | |
| 357 In progress | |
| 358 </button> | |
| 359 <button | |
| 360 style={{ ...styles.filterBtn, ...(todoTypes === TodoStatus.DELETED ? styles.filterActive : null) }} | |
| 361 onClick={() => setTodoTypes(TodoStatus.DELETED)} | |
| 362 > | |
| 363 Deleted | |
| 364 </button> | |
| 365 <button | |
| 366 style={{ ...styles.filterBtn, ...(todoTypes === TodoStatus.COMPLETED ? styles.filterActive : null) }} | |
| 367 onClick={() => setTodoTypes(TodoStatus.COMPLETED)} | |
| 368 > | |
| 369 Completed | |
| 370 </button> | |
| 371 </div> | |
| 372 </div> | |
| 373 | |
| 374 <form style={styles.form} onSubmit={handleSubmit}> | |
| 375 <input ref={todoInputRef} style={styles.input} placeholder="Add a task…" /> | |
| 376 <button style={{ ...styles.btn, ...styles.btnPrimary }} type="submit"> | |
| 377 Add | |
| 378 </button> | |
| 379 </form> | |
| 380 | |
| 381 <div style={todoTableCss}> | |
| 382 {visibleTodos.map((todo) => ( | |
| 383 <TodoComponent key={todo.id} todo={todo} handleOnClickButton={onClickUpdate} /> | |
| 384 ))} | |
| 385 </div> | |
| 386 </> | |
| 387 ); | |
| 388 }; | |
| 389 | |
| 390 interface SetUserKeywordInputProp { | |
| 391 setUserKeyword: Dispatch<SetStateAction<string>>; | |
| 392 } | |
| 393 | |
| 394 const SetUserKeywordInput = ( | |
| 395 {setUserKeyword}: SetUserKeywordInputProp | |
| 396 ) => { | |
| 397 const inputRef = useRef<HTMLInputElement>(null); | |
| 398 | |
| 399 const handleSubmit = useCallback(() => { | |
| 400 const inputEle = inputRef.current; | |
| 401 if (!inputEle || !inputEle.value.trim()) return; | |
| 402 setUserKeyword(inputEle.value.trim()); | |
| 403 const params = new URLSearchParams(window.location.search); | |
| 404 params.set('key', inputEle.value.trim()); | |
| 405 history.replaceState(null, '', `${location.pathname}?${params}${location.hash}`); | |
| 406 inputEle.value = ""; | |
| 407 }, [setUserKeyword]) | |
| 408 | |
| 409 return ( | |
| 410 <> | |
| 411 <h1 style={styles.title}> | |
| 412 What is your keyword? | |
| 413 </h1> | |
| 414 <h3> | |
| 415 If it doesn't exist, it will start a new TODO list and used the keyword to encrypt the values. | |
| 416 </h3> | |
| 417 <form style={styles.form} onSubmit={handleSubmit}> | |
| 418 <input ref={inputRef} style={styles.input} placeholder="What is your Keyword?" /> | |
| 419 <button style={{ ...styles.btn, ...styles.btnPrimary }} type="submit"> | |
| 420 Confirm | |
| 421 </button> | |
| 422 </form> | |
| 423 </> | |
| 424 ) | |
| 425 } | |
| 426 | |
| 427 const Todo = () => { | |
| 428 const [userKeyword, setUserKeyword] = useState<string>(() => { | |
| 429 const params = new URLSearchParams(window.location.search); | |
| 430 console.log() | |
| 431 return params.get("key") || ""; | |
| 432 }); | |
| 433 | |
| 434 return ( | |
| 435 <div style={styles.page}> | |
| 436 <div style={styles.card}> | |
| 437 { | |
| 438 userKeyword ? | |
| 439 (<TodoPageComponent userKeyword={userKeyword}/>) : | |
| 440 (<SetUserKeywordInput setUserKeyword={setUserKeyword}/>) | |
| 441 } | |
| 442 </div> | |
| 443 </div> | |
| 444 ) | |
| 445 | |
| 446 } | |
| 447 | |
| 448 ReactDOM.createRoot(document.getElementById('root')!).render(<Todo />); |