view react_games/src/Todo.tsx @ 71:75de5903355c

Giagantic changes that update Dowa library to be more align with stb style array and hashmap. Updated Seobeo to be caching on server side instead of file level caching. Deleted bunch of things I don't really use.
author June Park <parkjune1995@gmail.com>
date Sun, 28 Dec 2025 20:34:22 -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 />);