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 />);