Feature: Add i18n (en/ko/ja), 3-way theme (system/light/dark), and local demo preview mode

This commit is contained in:
Wonhee Han
2026-08-20 14:07:42 +09:00
parent dbfaa0fcb2
commit 3710c313e9
13 changed files with 1611 additions and 655 deletions
+193 -83
View File
@@ -1,28 +1,28 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useI18n } from "@/lib/i18n";
interface Task {
id: string; listId: string; parentId: string | null; title: string; note: string | null;
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
sortOrder: number; createdAt: string; updatedAt: string;
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
export interface Task {
id: string;
listId: string;
parentId: string | null;
title: string;
note: string | null;
completed: boolean;
completedAt: string | null;
dueDate: string | null;
priority: number;
sortOrder: number;
createdAt: string;
updatedAt: string;
children: Task[];
tags: { tag: { id: string; name: string; color: string } }[];
}
interface List { id: string; name: string; color: string; icon: string }
interface User { id: string; name?: string | null; email?: string | null }
export interface List { id: string; name: string; color: string; icon: string }
export interface User { id: string; name?: string | null; email?: string | null }
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
const PRIORITY_LABELS = ["", "Low", "Medium", "High"];
function formatDate(d: string | null) {
if (!d) return null;
const date = new Date(d);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
if (isToday) return "Today";
if (isTomorrow) return "Tomorrow";
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function isOverdue(d: string | null) {
if (!d) return false;
@@ -30,14 +30,29 @@ function isOverdue(d: string | null) {
}
interface TaskItemProps {
task: Task; isSelected: boolean;
task: Task;
isSelected: boolean;
onSelect: (t: Task) => void;
onToggle: (id: string, completed: boolean) => void;
}
function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
const [expanded, setExpanded] = useState(false);
const completedChildren = task.children.filter((c) => c.completed).length;
const { t, lang } = useI18n();
const [expanded, setExpanded] = useState(true);
const completedChildren = task.children?.filter((c) => c.completed).length || 0;
const formatDate = (d: string | null) => {
if (!d) return null;
const date = new Date(d);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
if (isToday) return t("today");
if (isTomorrow) return t("tomorrow");
return date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" });
};
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
return (
<div>
@@ -48,27 +63,49 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
>
<button
className={`task-check-btn${task.completed ? " checked" : ""}`}
onClick={(e) => { e.stopPropagation(); onToggle(task.id, !task.completed); }}
onClick={(e) => {
e.stopPropagation();
onToggle(task.id, !task.completed);
}}
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
/>
<div className="task-body">
<div className="task-title">{task.title}</div>
<div className="task-meta">
{task.priority > 0 && (
<div className="task-priority-dot" style={{ background: PRIORITY_COLORS[task.priority] }} title={PRIORITY_LABELS[task.priority]} />
<div
className="task-priority-dot"
style={{ background: PRIORITY_COLORS[task.priority] }}
title={priorityLabels[task.priority]}
/>
)}
{task.dueDate && (
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg>
{formatDate(task.dueDate)}
</span>
)}
{task.children.length > 0 && (
{task.children && task.children.length > 0 && (
<span
className="task-sub-count"
onClick={(e) => { e.stopPropagation(); setExpanded((p) => !p); }}
onClick={(e) => {
e.stopPropagation();
setExpanded((p) => !p);
}}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
{completedChildren}/{task.children.length}
</span>
)}
@@ -80,7 +117,7 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
</div>
{/* Sub-tasks */}
{task.children.length > 0 && expanded && (
{task.children && task.children.length > 0 && expanded && (
<div className="subtask-list">
{task.children.map((child) => (
<div
@@ -91,7 +128,10 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
>
<button
className={`subtask-check-btn${child.completed ? " checked" : ""}`}
onClick={(e) => { e.stopPropagation(); onToggle(child.id, !child.completed); }}
onClick={(e) => {
e.stopPropagation();
onToggle(child.id, !child.completed);
}}
aria-label={child.completed ? "Mark incomplete" : "Mark complete"}
/>
<span className="subtask-title">{child.title}</span>
@@ -104,21 +144,46 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
}
interface Props {
user: User; listId: string | null; lists: List[]; tasks: Task[];
user: User;
listId: string | null;
lists: List[];
tasks: Task[];
setTasks: React.Dispatch<React.SetStateAction<Task[]>>;
selectedTaskId: string | null; onTaskSelect: (t: Task | null) => void;
showCompleted: boolean; onToggleCompleted: () => void;
onMenuOpen: () => void; onRefresh: () => void;
selectedTaskId: string | null;
onTaskSelect: (t: Task | null) => void;
showCompleted: boolean;
onToggleCompleted: () => void;
onMenuOpen: () => void;
onRefresh: () => void;
isDemo?: boolean;
onDemoAddTask?: (title: string, listId: string) => void;
onDemoToggleTask?: (id: string, completed: boolean) => void;
}
export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId, onTaskSelect, showCompleted, onToggleCompleted, onMenuOpen, onRefresh }: Props) {
export function TaskList({
user,
listId,
lists,
tasks,
setTasks,
selectedTaskId,
onTaskSelect,
showCompleted,
onToggleCompleted,
onMenuOpen,
onRefresh,
isDemo = false,
onDemoAddTask,
onDemoToggleTask,
}: Props) {
const { t } = useI18n();
const [newTaskTitle, setNewTaskTitle] = useState("");
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const currentList = lists.find((l) => l.id === listId);
const fetchTasks = useCallback(async () => {
if (!listId) return;
if (!listId || isDemo) return;
setLoading(true);
try {
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
@@ -130,9 +195,11 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
} finally {
setLoading(false);
}
}, [listId, showCompleted]);
}, [listId, showCompleted, isDemo, setTasks]);
useEffect(() => { fetchTasks(); }, [fetchTasks]);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
useEffect(() => {
const handler = () => inputRef.current?.focus();
@@ -140,32 +207,48 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
return () => document.removeEventListener("checkflow:addTask", handler);
}, []);
const handleToggle = useCallback(async (id: string, completed: boolean) => {
try {
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const updated = await res.json();
// Functional update avoids stale closure on `tasks`
setTasks((prev) =>
prev.map((t) => {
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
return { ...t, children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)) };
}).filter((t) => showCompleted || !t.completed)
);
onRefresh();
} catch (err) {
console.error("[TaskList] handleToggle failed", err);
}
}, [showCompleted, onRefresh]);
const handleToggle = useCallback(
async (id: string, completed: boolean) => {
if (isDemo) {
if (onDemoToggleTask) onDemoToggleTask(id, completed);
return;
}
try {
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const updated = await res.json();
setTasks((prev) =>
prev
.map((t) => {
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
return { ...t, children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [] };
})
.filter((t) => showCompleted || !t.completed)
);
onRefresh();
} catch (err) {
console.error("[TaskList] handleToggle failed", err);
}
},
[isDemo, onDemoToggleTask, showCompleted, onRefresh, setTasks]
);
const handleAddTask = async (e: React.FormEvent) => {
e.preventDefault();
const title = newTaskTitle.trim();
if (!title || !listId) return;
if (isDemo) {
if (onDemoAddTask) onDemoAddTask(title, listId);
setNewTaskTitle("");
return;
}
try {
const res = await fetch("/api/tasks", {
method: "POST",
@@ -185,8 +268,10 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
if (!listId) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<p>Select a list to get started</p>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}>
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
<p>{t("selectListToStart")}</p>
</div>
);
}
@@ -199,20 +284,24 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
{/* Header */}
<div className="main-header">
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<div className="main-header-title" style={{ color: currentList?.color }}>
{currentList?.name || "Tasks"}
{currentList?.name || t("tasks")}
</div>
<div className="main-header-actions">
<button
id="toggle-completed-btn"
className="btn btn-ghost btn-sm"
onClick={onToggleCompleted}
title={showCompleted ? "Hide completed" : "Show completed"}
title={showCompleted ? t("hideDone") : t("showDone")}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>
{showCompleted ? "Hide done" : "Show done"}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
{showCompleted ? t("hideDone") : t("showDone")}
</button>
</div>
</div>
@@ -220,47 +309,68 @@ export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId,
{/* Task list */}
<div className="task-list-container">
{loading && (
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>Loading...</div>
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
)}
{!loading && incompleteTasks.length === 0 && completedTasks.length === 0 && (
<div className="task-list-empty">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<p>No tasks yet. Add one below!</p>
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
<p>{t("noTasksYet")}</p>
</div>
)}
{incompleteTasks.map((task) => (
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
<TaskItem
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onSelect={onTaskSelect}
onToggle={handleToggle}
/>
))}
{showCompleted && completedTasks.length > 0 && (
<div>
<div style={{ padding: "12px 20px 4px", fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
Completed ({completedTasks.length})
<div
style={{
padding: "12px 20px 4px",
fontSize: 11,
fontWeight: 600,
color: "var(--text-tertiary)",
textTransform: "uppercase",
letterSpacing: "0.5px",
}}
>
{t("completedSection")} ({completedTasks.length})
</div>
{completedTasks.map((task) => (
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
<TaskItem
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onSelect={onTaskSelect}
onToggle={handleToggle}
/>
))}
</div>
)}
</div>
{/* Add task */}
{/* Add task bar */}
<div className="add-task-bar">
<button
className="task-check-btn"
style={{ opacity: 0.4, flexShrink: 0 }}
aria-hidden="true"
/>
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" />
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
<input
ref={inputRef}
id="add-task-input"
className="add-task-input"
placeholder="Add a task..."
placeholder={t("addTaskPlaceholder")}
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
{newTaskTitle.trim() && (
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">Add</button>
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">
{t("add")}
</button>
)}
</form>
</div>