Mercurial
view react_games/src/Todo.tsx @ 149:f41ac17926d2
[Config] Added ctags scripts and actual tags.
| author | June Park <parkjune1995@gmail.com> |
|---|---|
| date | Sat, 10 Jan 2026 07:07:10 -0800 |
| parents | fb9bcd3145cb |
| children |
line wrap: on
line source
import ReactDOM from 'react-dom/client'; import { ActionDispatch, CSSProperties, Dispatch, memo, SetStateAction, startTransition, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; // Thank you chatGPT for colors lmao.... const colors = { bg: '#0b1220', surface: '#0f172a', card: '#111827', border: '#1f2937', text: '#e5e7eb', muted: '#9ca3af', accent: '#4f46e5', accent2: '#10b981', danger: '#ef4444', }; const shadow = '0 10px 20px rgba(0,0,0,.25)'; type styleComponents = | "page" | "card" | "header" | "title" | "form" | "input" | "btnPrimary" | "btnSuccess" | "btnDanger" | "btnSm" | "filterBar" | "filterBtn" | "filterActive" | "list" | "todo" | "todoBusy" | "todoText" | "todoCompleted" | "badge" | "badgeDot" | "actions" | "btn" | "page" | "page" | "page" | "page" | "page"; const styles: Record<styleComponents, CSSProperties> = { page: { minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 32, background: colors.bg, color: colors.text, fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, 'Helvetica Neue', Arial, 'Apple Color Emoji','Segoe UI Emoji'", }, card: { minWidth: 400, maxWidth: '92vw', background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 16, boxShadow: shadow, padding: '24px 24px 20px', }, header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, }, title: { margin: 0, fontSize: 24, letterSpacing: 0.2 }, form: { display: 'flex', gap: 10, marginBottom: 14 }, input: { flex: 1, height: 40, padding: '0 12px', border: `1px solid ${colors.border}`, borderRadius: 10, background: colors.card, color: colors.text, outline: 'none', }, btn: { appearance: 'none', border: `1px solid ${colors.border}`, background: colors.card, color: colors.text, padding: '10px 14px', borderRadius: 10, cursor: 'pointer', fontSize: 14, userSelect: 'none', transition: 'transform .05s ease, background .15s, border-color .15s', }, btnPrimary: { background: colors.accent, borderColor: colors.accent, color: '#fff' }, btnSuccess: { background: colors.accent2, borderColor: colors.accent2, color: '#fff' }, btnDanger: { background: colors.danger, borderColor: colors.danger, color: '#fff' }, btnSm: { padding: '8px 10px', fontSize: 13, borderRadius: 8 }, filterBar: { display: 'flex', gap: 8, marginTop: 8, marginBottom: 18 }, filterBtn: { appearance: 'none', border: `1px solid ${colors.border}`, background: colors.card, color: colors.text, padding: '8px 12px', fontSize: 13, borderRadius: 9999, cursor: 'pointer', }, filterActive: { background: '#1a2140', // subtle “active” fill borderColor: '#2a3a8a', }, list: { display: 'grid', gap: 10 }, todo: { display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 10, alignItems: 'center', background: colors.card, border: `1px solid ${colors.border}`, borderRadius: 12, padding: '12px 12px', }, todoBusy: { opacity: 0.75 }, todoText: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 16 }, todoCompleted: { textDecoration: 'line-through', color: colors.muted }, badge: { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '2px 8px', fontSize: 12, borderRadius: 9999, border: `1px solid ${colors.border}`, }, badgeDot: { width: 8, height: 8, borderRadius: 9999, display: 'inline-block', }, actions: { display: 'flex', gap: 8 }, }; enum TodoStatus { IN_PROGRESS, COMPLETED, DELETED, } interface Todo { id: string; value: string; status: TodoStatus; isOptimistic: boolean; } interface TodoComponentProp { todo: Todo; handleOnClickButton: (todoId: string, status: TodoStatus, type: TodoActionEnum) => void; } const TodoComponent = memo(({ todo, handleOnClickButton }: TodoComponentProp) => { const statusLabel = todo.status === TodoStatus.IN_PROGRESS ? 'In progress' : todo.status === TodoStatus.COMPLETED ? 'Completed' : 'Deleted'; const badgeDotColor = todo.status === TodoStatus.IN_PROGRESS ? '#f59e0b' : todo.status === TodoStatus.COMPLETED ? '#10b981' : '#6b7280'; return ( <div style={{ ...styles.todo, ...(todo.isOptimistic ? styles.todoBusy : null) }} aria-busy={todo.isOptimistic}> <div style={{ ...styles.todoText, ...(todo.status === TodoStatus.COMPLETED ? styles.todoCompleted : null), }} > <span style={styles.badge}> <span style={{ ...styles.badgeDot, background: badgeDotColor }} /> {statusLabel} </span> <span style={ todo.isOptimistic ?{ fontSize: 12, color: colors.muted } : {}}>{todo.value}</span> </div> <div style={styles.actions}> {todo.status !== TodoStatus.COMPLETED && ( <button style={{ ...styles.btn, ...styles.btnSm, ...styles.btnSuccess }} onClick={() => handleOnClickButton(todo.id, TodoStatus.COMPLETED, TodoActionEnum.COMPLETED)}> Complete </button> )} {todo.status !== TodoStatus.DELETED && ( <button style={{ ...styles.btn, ...styles.btnSm, ...styles.btnDanger }} onClick={() => handleOnClickButton(todo.id, TodoStatus.DELETED, TodoActionEnum.REMOVED)}> Delete </button> )} </div> </div> ); }); const todoTableCss: CSSProperties = { display: 'grid', gridTemplateColumns: '1fr', gap: 10, width: '100%' }; enum TodoActionEnum { ADD, COMPLETED, REMOVED, CONFRIM_FROM_BE, REVERT_FROM_BE, HYDRATE, } type TodoAction = | { type: TodoActionEnum.ADD; todo: Todo } | { type: TodoActionEnum.COMPLETED; todoId: string } | { type: TodoActionEnum.REMOVED; todoId: string } | { type: TodoActionEnum.CONFRIM_FROM_BE; todoId: string } | { type: TodoActionEnum.REVERT_FROM_BE; todoId: string } | { type: TodoActionEnum.HYDRATE; todos: Todo[] }; const todoReducer = (state: Todo[], action: TodoAction): Todo[] => { switch (action.type) { case TodoActionEnum.ADD: return [...state, action.todo]; case TodoActionEnum.COMPLETED: return state.map((t) => (t.id === action.todoId ? { ...t, status: TodoStatus.COMPLETED } : t)); case TodoActionEnum.REMOVED: return state.map((t) => (t.id === action.todoId ? { ...t, status: TodoStatus.DELETED } : t)); case TodoActionEnum.CONFRIM_FROM_BE: return state.map((t) => (t.id === action.todoId ? { ...t, isOptimistic: false } : t)); case TodoActionEnum.REVERT_FROM_BE: return state.filter((t) => t.id !== action.todoId); case TodoActionEnum.HYDRATE: return action.todos.map((todo) => ({ ...todo, isOptimistic: false, })); default: return state; } }; type StatusBuckets = Record<TodoStatus, Todo[]>; interface TodoPageComponentProp { userKeyword: string; } const TodoPageComponent = ({userKeyword}: TodoPageComponentProp) => { const [todos, dispatch] = useReducer(todoReducer, []); const [todoTypes, setTodoTypes] = useState<TodoStatus>(TodoStatus.IN_PROGRESS); const todoInputRef = useRef<HTMLInputElement>(null); useEffect(()=>{ const ctrl = new AbortController(); (async () => { try { const res = await fetch( `/api/v1/todo?key=${userKeyword}`, { signal: ctrl.signal }); if (!res.ok) return; const todos = (await res.json()).todos; dispatch({ type: TodoActionEnum.HYDRATE, todos }) } catch (e) { console.log(e); } })(); return () => ctrl.abort(); },[]); const buckets = useMemo<StatusBuckets>(() => { const r: StatusBuckets = { [TodoStatus.IN_PROGRESS]: [], [TodoStatus.COMPLETED]: [], [TodoStatus.DELETED]: [], }; for (const t of todos) r[t.status].push(t); return r; }, [todos]); const visibleTodos = buckets[todoTypes]; const handleSubmit = useCallback((event: React.FormEvent) => { event.preventDefault(); const todoInput = todoInputRef.current; if (!todoInput) return; const value = todoInput.value.trim(); if (!value) return; const newTodo: Todo = { id: crypto.randomUUID(), value, status: TodoStatus.IN_PROGRESS, isOptimistic: true, }; dispatch({ type: TodoActionEnum.ADD, todo: newTodo }); todoInput.value = ''; startTransition(async () => { try { const res = await fetch(`/api/v1/todo?key=${userKeyword}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ newTodo }), }); if (res.ok) dispatch({ type: TodoActionEnum.CONFRIM_FROM_BE, todoId: newTodo.id }); else dispatch({ type: TodoActionEnum.REVERT_FROM_BE, todoId: newTodo.id }); } catch { dispatch({ type: TodoActionEnum.REVERT_FROM_BE, todoId: newTodo.id }); } }); }, []); const onClickUpdate = useCallback( (todoId: string, status: TodoStatus, type: TodoActionEnum) => { dispatch({ type, todoId } as TodoAction); startTransition(async () => { try { const res = await fetch(`/api/v1/todo?key=${userKeyword}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ todoId, status }), }); if (res.ok) dispatch({ type: TodoActionEnum.CONFRIM_FROM_BE, todoId }); else dispatch({ type: TodoActionEnum.REVERT_FROM_BE, todoId }); } catch { dispatch({ type: TodoActionEnum.REVERT_FROM_BE, todoId }); } }) }, [dispatch]); return ( <> <div style={styles.header}> <h1 style={styles.title}>TODO!</h1> <div style={styles.filterBar} role="tablist" aria-label="Filter"> <button style={{ ...styles.filterBtn, ...(todoTypes === TodoStatus.IN_PROGRESS ? styles.filterActive : null) }} onClick={() => setTodoTypes(TodoStatus.IN_PROGRESS)} > In progress </button> <button style={{ ...styles.filterBtn, ...(todoTypes === TodoStatus.DELETED ? styles.filterActive : null) }} onClick={() => setTodoTypes(TodoStatus.DELETED)} > Deleted </button> <button style={{ ...styles.filterBtn, ...(todoTypes === TodoStatus.COMPLETED ? styles.filterActive : null) }} onClick={() => setTodoTypes(TodoStatus.COMPLETED)} > Completed </button> </div> </div> <form style={styles.form} onSubmit={handleSubmit}> <input ref={todoInputRef} style={styles.input} placeholder="Add a task…" /> <button style={{ ...styles.btn, ...styles.btnPrimary }} type="submit"> Add </button> </form> <div style={todoTableCss}> {visibleTodos.map((todo) => ( <TodoComponent key={todo.id} todo={todo} handleOnClickButton={onClickUpdate} /> ))} </div> </> ); }; interface SetUserKeywordInputProp { setUserKeyword: Dispatch<SetStateAction<string>>; } const SetUserKeywordInput = ( {setUserKeyword}: SetUserKeywordInputProp ) => { const inputRef = useRef<HTMLInputElement>(null); const handleSubmit = useCallback(() => { const inputEle = inputRef.current; if (!inputEle || !inputEle.value.trim()) return; setUserKeyword(inputEle.value.trim()); const params = new URLSearchParams(window.location.search); params.set('key', inputEle.value.trim()); history.replaceState(null, '', `${location.pathname}?${params}${location.hash}`); inputEle.value = ""; }, [setUserKeyword]) return ( <> <h1 style={styles.title}> What is your keyword? </h1> <h3> If it doesn't exist, it will start a new TODO list and used the keyword to encrypt the values. </h3> <form style={styles.form} onSubmit={handleSubmit}> <input ref={inputRef} style={styles.input} placeholder="What is your Keyword?" /> <button style={{ ...styles.btn, ...styles.btnPrimary }} type="submit"> Confirm </button> </form> </> ) } const Todo = () => { const [userKeyword, setUserKeyword] = useState<string>(() => { const params = new URLSearchParams(window.location.search); console.log() return params.get("key") || ""; }); return ( <div style={styles.page}> <div style={styles.card}> { userKeyword ? (<TodoPageComponent userKeyword={userKeyword}/>) : (<SetUserKeywordInput setUserKeyword={setUserKeyword}/>) } </div> </div> ) } ReactDOM.createRoot(document.getElementById('root')!).render(<Todo />);