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