Mercurial
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/react_games/src/Todo.tsx Mon Dec 01 20:22:47 2025 -0800 @@ -0,0 +1,448 @@ +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 />);