view react_games/src/Todo.tsx @ 135:ffb764d2fcc5

[HgWeb] Updated hg web so it works
author June Park <parkjune1995@gmail.com>
date Fri, 09 Jan 2026 11:17:20 -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 />);