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
+126 -13
View File
@@ -1,19 +1,16 @@
"use client";
import { useState, useCallback } from "react";
"use client";
import { useState, useCallback, useEffect } from "react";
import { Sidebar } from "./Sidebar";
import { TaskList } from "../tasks/TaskList";
import { TaskList, Task, List, User } from "../tasks/TaskList";
import { TaskDetail } from "../tasks/TaskDetail";
import { getDemoStore, saveDemoStore, MockList, MockTask } from "@/lib/mockData";
interface User { id: string; name?: string | null; email?: string | null; }
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
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 AppShellProps {
user: User;
isDemo?: boolean;
}
export function AppShell({ user }: { user: User }) {
export function AppShell({ user, isDemo = false }: AppShellProps) {
const [lists, setLists] = useState<List[]>([]);
const [selectedListId, setSelectedListId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
@@ -22,6 +19,28 @@ export function AppShell({ user }: { user: User }) {
const [showCompleted, setShowCompleted] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
// Demo store loading
useEffect(() => {
if (isDemo) {
const store = getDemoStore();
setLists(store.lists);
if (store.lists.length > 0) {
setSelectedListId(store.lists[0].id);
}
}
}, [isDemo]);
// Demo tasks filter
useEffect(() => {
if (isDemo && selectedListId) {
const store = getDemoStore();
const listTasks = (store.tasks as Task[]).filter(
(t) => t.listId === selectedListId && (showCompleted ? true : !t.completed)
);
setTasks(listTasks);
}
}, [isDemo, selectedListId, showCompleted, refreshKey]);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const handleTaskSelect = useCallback((task: Task | null) => {
@@ -39,6 +58,89 @@ export function AppShell({ user }: { user: User }) {
setSidebarOpen(false);
}, []);
// Demo Handlers
const handleDemoCreateList = (name: string, color: string) => {
const newList: MockList = {
id: "demo-list-" + Date.now(),
name,
color,
icon: "list",
};
const store = getDemoStore();
const newLists = [...store.lists, newList];
saveDemoStore(newLists, store.tasks);
setLists(newLists);
setSelectedListId(newList.id);
};
const handleDemoDeleteList = (id: string) => {
const store = getDemoStore();
const newLists = store.lists.filter((l) => l.id !== id);
const newTasks = store.tasks.filter((t) => t.listId !== id);
saveDemoStore(newLists, newTasks);
setLists(newLists);
if (selectedListId === id) {
setSelectedListId(newLists[0]?.id || null);
}
refresh();
};
const handleDemoAddTask = (title: string, listId: string) => {
const newTask: Task = {
id: "demo-task-" + Date.now(),
listId,
parentId: null,
title,
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 0,
sortOrder: tasks.length,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [],
tags: [],
};
const store = getDemoStore();
const newTasks = [...store.tasks, newTask as MockTask];
saveDemoStore(store.lists, newTasks);
setTasks((prev) => [...prev, newTask]);
refresh();
};
const handleDemoToggleTask = (id: string, completed: boolean) => {
const store = getDemoStore();
const newTasks = store.tasks.map((t) => {
if (t.id === id) {
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
}
return {
...t,
children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [],
};
});
saveDemoStore(store.lists, newTasks);
refresh();
};
const handleDemoUpdateTask = (updated: Task) => {
const store = getDemoStore();
const newTasks = store.tasks.map((t) => (t.id === updated.id ? (updated as MockTask) : t));
saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setSelectedTask(updated);
};
const handleDemoDeleteTask = (id: string) => {
const store = getDemoStore();
const newTasks = store.tasks.filter((t) => t.id !== id);
saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id));
setSelectedTask(null);
refresh();
};
return (
<div className="app-layout">
{/* Mobile overlay */}
@@ -58,6 +160,9 @@ export function AppShell({ user }: { user: User }) {
onListSelect={handleListSelect}
mobileOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
isDemo={isDemo}
onDemoCreateList={handleDemoCreateList}
onDemoDeleteList={handleDemoDeleteList}
/>
<div className="main-content">
@@ -74,6 +179,9 @@ export function AppShell({ user }: { user: User }) {
onToggleCompleted={() => setShowCompleted((p) => !p)}
onMenuOpen={() => setSidebarOpen(true)}
onRefresh={refresh}
isDemo={isDemo}
onDemoAddTask={handleDemoAddTask}
onDemoToggleTask={handleDemoToggleTask}
/>
</div>
@@ -82,8 +190,14 @@ export function AppShell({ user }: { user: User }) {
task={selectedTask}
onClose={() => setSelectedTask(null)}
onUpdate={handleTaskUpdate}
onDelete={() => { setSelectedTask(null); refresh(); }}
onDelete={() => {
setSelectedTask(null);
refresh();
}}
listId={selectedTask.listId}
isDemo={isDemo}
onDemoUpdateTask={handleDemoUpdateTask}
onDemoDeleteTask={handleDemoDeleteTask}
/>
)}
@@ -93,7 +207,6 @@ export function AppShell({ user }: { user: User }) {
id="mobile-add-task"
aria-label="Add task"
onClick={() => {
// trigger add task from task list — use custom event
document.dispatchEvent(new CustomEvent("checkflow:addTask"));
}}
>
+201 -78
View File
@@ -1,88 +1,131 @@
"use client";
"use client";
import { useState, useEffect, useRef } from "react";
import { signOut } from "next-auth/react";
import { useI18n, Language } from "@/lib/i18n";
import { useTheme, ThemeMode } from "@/app/providers";
interface User { id: string; name?: string | null; email?: string | null }
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
const LIST_COLORS = ["#4B7BF5","#EF4444","#10B981","#F59E0B","#8B5CF6","#EC4899","#06B6D4","#F97316","#6366F1","#14B8A6"];
const LIST_ICONS: Record<string, string> = { list:"≡", inbox:"⌂", star:"★", work:"💼", personal:"👤", shopping:"🛒", health:"❤️", study:"📚" };
const LIST_COLORS = ["#4B7BF5", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316", "#6366F1", "#14B8A6"];
interface SidebarProps {
user: User; lists: List[]; setLists: (l: List[]) => void;
selectedListId: string | null; onListSelect: (id: string) => void;
mobileOpen: boolean; onClose: () => void;
user: User;
lists: List[];
setLists: React.Dispatch<React.SetStateAction<List[]>>;
selectedListId: string | null;
onListSelect: (id: string) => void;
mobileOpen: boolean;
onClose: () => void;
isDemo?: boolean;
onDemoCreateList?: (name: string, color: string) => void;
onDemoDeleteList?: (id: string) => void;
}
export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, mobileOpen, onClose }: SidebarProps) {
export function Sidebar({
user,
lists,
setLists,
selectedListId,
onListSelect,
mobileOpen,
onClose,
isDemo = false,
onDemoCreateList,
onDemoDeleteList,
}: SidebarProps) {
const { t, lang, setLang } = useI18n();
const { theme, setTheme, toggleTheme } = useTheme();
const [showNewList, setShowNewList] = useState(false);
const [newListName, setNewListName] = useState("");
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
const [editList, setEditList] = useState<List | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [showImport, setShowImport] = useState(false);
const [importListId, setImportListId] = useState("");
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState("");
const [theme, setTheme] = useState("light");
const inputRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const t = localStorage.getItem("theme") || "light";
setTheme(t);
}, []);
useEffect(() => {
fetch("/api/lists")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) {
setLists(data);
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
}
});
}, []);
if (!isDemo) {
fetch("/api/lists")
.then((r) => (r.ok ? r.json() : []))
.then((data) => {
if (Array.isArray(data)) {
setLists(data);
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
}
})
.catch((err) => console.error("Failed to load lists", err));
}
}, [isDemo]);
useEffect(() => {
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
}, [showNewList]);
const toggleTheme = () => {
const next = theme === "light" ? "dark" : "light";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.setAttribute("data-theme", next);
};
const createList = async () => {
const name = newListName.trim();
if (!name) return;
const res = await fetch("/api/lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, color: newListColor }),
});
if (res.ok) {
const list = await res.json();
setLists([...lists, list]);
if (isDemo) {
if (onDemoCreateList) onDemoCreateList(name, newListColor);
setNewListName("");
setShowNewList(false);
onListSelect(list.id);
return;
}
try {
const res = await fetch("/api/lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, color: newListColor }),
});
if (res.ok) {
const list = await res.json();
setLists((prev) => [...prev, list]);
setNewListName("");
setShowNewList(false);
onListSelect(list.id);
}
} catch (err) {
console.error("Failed to create list", err);
}
};
const deleteList = async (id: string) => {
if (!confirm("Delete this list and all its tasks?")) return;
await fetch(`/api/lists/${id}`, { method: "DELETE" });
const updated = lists.filter((l) => l.id !== id);
setLists(updated);
if (selectedListId === id) onListSelect(updated[0]?.id || "");
if (!confirm(t("deleteListConfirm"))) return;
if (isDemo) {
if (onDemoDeleteList) onDemoDeleteList(id);
return;
}
try {
await fetch(`/api/lists/${id}`, { method: "DELETE" });
setLists((prev) => {
const updated = prev.filter((l) => l.id !== id);
if (selectedListId === id) onListSelect(updated[0]?.id || "");
return updated;
});
} catch (err) {
console.error("Failed to delete list", err);
}
};
const handleImport = async () => {
const file = fileRef.current?.files?.[0];
if (!file || !importListId) return;
if (isDemo) {
setImportResult("✓ Demo Mode: Import simulated successfully");
setTimeout(() => { setShowImport(false); setImportResult(""); }, 1500);
return;
}
setImporting(true);
try {
const formData = new FormData();
@@ -92,11 +135,12 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
const data = await res.json().catch(() => ({}));
if (res.ok) {
setImportResult(`✓ Imported ${data.imported} tasks`);
// Reset file input
if (fileRef.current) fileRef.current.value = "";
// Refresh list counts
const listsRes = await fetch("/api/lists");
if (listsRes.ok) { const updated = await listsRes.json(); if (Array.isArray(updated)) setLists(updated); }
if (listsRes.ok) {
const updated = await listsRes.json();
if (Array.isArray(updated)) setLists(updated);
}
setTimeout(() => { setShowImport(false); setImportResult(""); }, 2000);
} else {
setImportResult(`Error: ${data.error || "Import failed"}`);
@@ -116,20 +160,69 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
{/* Header */}
<div className="sidebar-header">
<div className="sidebar-logo"></div>
<span className="sidebar-title">CheckFlow</span>
<button className="icon-btn" id="theme-toggle" onClick={toggleTheme} title="Toggle theme" style={{ marginLeft: "auto" }}>
{theme === "light" ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<div style={{ display: "flex", flexDirection: "column" }}>
<span className="sidebar-title">{t("appName")}</span>
{isDemo && (
<span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}>
{t("demoBadge")}
</span>
)}
</button>
</div>
{/* Top Quick Actions (Lang & Theme) */}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 4 }}>
{/* Language Selector */}
<select
aria-label={t("language")}
value={lang}
onChange={(e) => setLang(e.target.value as Language)}
style={{
background: "var(--bg-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-xs)",
fontSize: 11,
padding: "3px 4px",
cursor: "pointer",
fontWeight: 600,
}}
>
<option value="en">EN</option>
<option value="ko"></option>
<option value="ja"></option>
</select>
{/* Theme 3-Way Toggle Button */}
<button
className="icon-btn"
id="theme-toggle"
onClick={toggleTheme}
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
style={{ width: 28, height: 28 }}
>
{theme === "system" ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
) : theme === "light" ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" /><line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /><line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" /><line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
</div>
</div>
{/* Nav */}
<nav className="sidebar-nav">
<div className="sidebar-section">
<div className="sidebar-section-label">Lists</div>
<div className="sidebar-section-label">{t("lists")}</div>
{lists.map((list) => (
<div
key={list.id}
@@ -143,10 +236,15 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
<button
className="icon-btn"
style={{ width: 22, height: 22, opacity: 0.5 }}
onClick={(e) => { e.stopPropagation(); deleteList(list.id); }}
onClick={(e) => {
e.stopPropagation();
deleteList(list.id);
}}
title="Delete list"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
</svg>
</button>
</div>
))}
@@ -168,28 +266,35 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
<input
ref={inputRef}
className="form-input"
placeholder="List name"
placeholder={t("listNamePlaceholder")}
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") createList(); if (e.key === "Escape") setShowNewList(false); }}
onKeyDown={(e) => {
if (e.key === "Enter") createList();
if (e.key === "Escape") setShowNewList(false);
}}
style={{ marginBottom: 6 }}
/>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList}>Create</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)}>Cancel</button>
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList}>{t("create")}</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)}>{t("cancel")}</button>
</div>
</div>
) : (
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New List
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t("newList")}
</button>
)}
<div className="sidebar-section" style={{ borderTop: "1px solid var(--border)", paddingTop: 12, marginTop: 4 }}>
<button id="import-btn" className="sidebar-add-btn" onClick={() => { setShowImport(true); setImportListId(lists[0]?.id || ""); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import Tasks
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
{t("importTasks")}
</button>
</div>
</nav>
@@ -199,15 +304,29 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
<div className="user-avatar" style={{ background: "#4B7BF5" }}>{initials}</div>
<div className="user-info">
<div className="user-name">{user.name}</div>
<div className="user-email">{user.email}</div>
<div className="user-name">{user.name || (isDemo ? "Demo User" : "User")}</div>
<div className="user-email">{user.email || (isDemo ? "demo@checkflow.local" : "")}</div>
</div>
{userMenuOpen && (
<div className="dropdown" style={{ bottom: "100%", left: 0, right: 0, marginBottom: 4 }}>
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Sign out
</div>
{isDemo ? (
<div
className="dropdown-item"
onClick={() => { window.location.href = "/login"; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
</svg>
{t("signIn")}
</div>
) : (
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
</svg>
{t("signOut")}
</div>
)}
</div>
)}
</div>
@@ -217,25 +336,29 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
{showImport && (
<div className="modal-overlay" onClick={() => setShowImport(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Import Tasks</h2>
<h2 className="modal-title">{t("importModalTitle")}</h2>
<div className="form-group">
<label className="form-label">Target List</label>
<label className="form-label">{t("targetList")}</label>
<select className="form-input" value={importListId} onChange={(e) => setImportListId(e.target.value)}>
{lists.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">File (CSV or ICS from TickTick)</label>
<label className="form-label">{t("fileSelectLabel")}</label>
<input ref={fileRef} type="file" accept=".csv,.ics" className="form-input" />
</div>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 12 }}>
TickTick: Settings Export Export as CSV or iCalendar
{t("tickTickExportHint")}
</p>
{importResult && <p style={{ fontSize: 13, color: importResult.startsWith("✓") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>{importResult}</p>}
{importResult && (
<p style={{ fontSize: 13, color: importResult.startsWith("✓") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>
{importResult}
</p>
)}
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setShowImport(false)}>Cancel</button>
<button className="btn btn-ghost" onClick={() => setShowImport(false)}>{t("cancel")}</button>
<button id="import-submit-btn" className="btn btn-primary" onClick={handleImport} disabled={importing}>
{importing ? "Importing..." : "Import"}
{importing ? t("importing") : t("importBtn")}
</button>
</div>
</div>
+282 -147
View File
@@ -1,28 +1,31 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
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 } }[];
}
const PRIORITY_MAP = [
{ label: "None", color: "var(--text-tertiary)", icon: "" },
{ label: "Low", color: "var(--priority-low)", icon: "▼" },
{ label: "Medium", color: "var(--priority-medium)", icon: "▶" },
{ label: "High", color: "var(--priority-high)", icon: "▲" },
];
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useI18n } from "@/lib/i18n";
import { Task } from "./TaskList";
interface Props {
task: Task; listId: string;
task: Task;
listId: string;
onClose: () => void;
onUpdate: (t: Task) => void;
onDelete: () => void;
isDemo?: boolean;
onDemoUpdateTask?: (updated: Task) => void;
onDemoDeleteTask?: (id: string) => void;
}
export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props) {
export function TaskDetail({
task,
listId,
onClose,
onUpdate,
onDelete,
isDemo = false,
onDemoUpdateTask,
onDemoDeleteTask,
}: Props) {
const { t, lang } = useI18n();
const [title, setTitle] = useState(task.title);
const [note, setNote] = useState(task.note || "");
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
@@ -31,12 +34,18 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
const [subtasks, setSubtasks] = useState(task.children || []);
const [saving, setSaving] = useState(false);
// Store timer + current task id in refs to prevent stale saves across task switches
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTaskId = useRef(task.id);
const noteRef = useRef<HTMLTextAreaElement>(null);
// Sync state when switching to a different task — clear any pending timer first
const priorityMap = [
{ label: t("priorityNone"), color: "var(--text-tertiary)", icon: "" },
{ label: t("priorityLow"), color: "var(--priority-low)", icon: "▼" },
{ label: t("priorityMedium"), color: "var(--priority-medium)", icon: "▶" },
{ label: t("priorityHigh"), color: "var(--priority-high)", icon: "▲" },
];
// Sync state when switching task
useEffect(() => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
@@ -48,100 +57,172 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
setPriority(task.priority);
setSubtasks(task.children || []);
}, [task.id]);
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
const save = useCallback(async (taskId: string, data: Record<string, unknown>) => {
// Guard: don't save if task has changed since debounce was scheduled
if (taskId !== currentTaskId.current) return;
setSaving(true);
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
const save = useCallback(
async (taskId: string, data: Record<string, unknown>) => {
if (taskId !== currentTaskId.current) return;
setSaving(true);
if (isDemo) {
const updatedTask: Task = {
...task,
...data,
children: subtasks,
updatedAt: new Date().toISOString(),
} as Task;
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
onUpdate(updatedTask);
setSaving(false);
return;
}
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const updated = await res.json();
if (taskId === currentTaskId.current) {
onUpdate({ ...updated, children: subtasks });
}
}
} catch (err) {
console.error("[TaskDetail] save failed", err);
} finally {
if (taskId === currentTaskId.current) setSaving(false);
}
},
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
);
const debounceSave = useCallback(
(overrides: Record<string, unknown>) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
const taskId = currentTaskId.current;
saveTimer.current = setTimeout(() => save(taskId, overrides), 600);
},
[save]
);
// Insert markdown at cursor position
const insertAtCursor = useCallback(
(before: string, after = "", placeholder = "") => {
const ta = noteRef.current;
if (!ta) {
setNote((n) => {
const v = n + before + placeholder + after;
debounceSave({ note: v });
return v;
});
return;
}
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? ta.value.length;
const selected = ta.value.slice(start, end) || placeholder;
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
setNote(newVal);
debounceSave({ note: newVal });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + selected.length;
});
if (res.ok) {
const updated = await res.json();
// Only update if we're still on the same task
if (taskId === currentTaskId.current) {
},
[debounceSave]
);
const handleToggleCompleted = useCallback(
async (newCompleted: boolean) => {
if (isDemo) {
const updatedTask: Task = {
...task,
completed: newCompleted,
completedAt: newCompleted ? new Date().toISOString() : null,
children: subtasks,
};
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
onUpdate(updatedTask);
return;
}
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: newCompleted }),
});
if (res.ok) {
const updated = await res.json();
onUpdate({ ...updated, children: subtasks });
}
} catch (err) {
console.error("[TaskDetail] toggle failed", err);
}
} catch (err) {
console.error("[TaskDetail] save failed", err);
} finally {
if (taskId === currentTaskId.current) setSaving(false);
}
}, [onUpdate, subtasks]);
const debounceSave = useCallback((overrides: Record<string, unknown>) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
const taskId = currentTaskId.current;
saveTimer.current = setTimeout(() => save(taskId, overrides), 800);
}, [save]);
// Insert text at cursor position in the note textarea
const insertAtCursor = useCallback((before: string, after = "", placeholder = "") => {
const ta = noteRef.current;
if (!ta) {
setNote((n) => { const v = n + before + placeholder + after; debounceSave({ note: v }); return v; });
return;
}
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? ta.value.length;
const selected = ta.value.slice(start, end) || placeholder;
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
setNote(newVal);
debounceSave({ note: newVal });
// Restore cursor after React re-render
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + selected.length;
});
}, [debounceSave]);
const handleToggleCompleted = useCallback(async (newCompleted: boolean) => {
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: newCompleted }),
});
if (res.ok) {
const updated = await res.json();
onUpdate({ ...updated, children: subtasks });
}
} catch (err) {
console.error("[TaskDetail] toggle failed", err);
}
}, [task.id, subtasks, onUpdate]);
},
[isDemo, onDemoUpdateTask, task, subtasks, onUpdate]
);
const handleDelete = useCallback(async () => {
if (!confirm("Delete this task?")) return;
if (!confirm(t("deleteTaskConfirm"))) return;
if (isDemo) {
if (onDemoDeleteTask) onDemoDeleteTask(task.id);
onDelete();
return;
}
try {
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
onDelete();
} catch (err) {
console.error("[TaskDetail] delete failed", err);
}
}, [task.id, onDelete]);
}, [t, isDemo, onDemoDeleteTask, task.id, onDelete]);
const addSubtask = useCallback(async () => {
const t = newSubtitle.trim();
if (!t) return;
const tTitle = newSubtitle.trim();
if (!tTitle) return;
if (isDemo) {
const newSub: Task = {
id: "demo-sub-" + Date.now(),
listId,
parentId: task.id,
title: tTitle,
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 0,
sortOrder: subtasks.length,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [],
tags: [],
};
const nextSubs = [...subtasks, newSub];
setSubtasks(nextSubs);
setNewSubtitle("");
const updatedParent = { ...task, children: nextSubs };
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
onUpdate(updatedParent);
return;
}
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: t, listId, parentId: task.id }),
body: JSON.stringify({ title: tTitle, listId, parentId: task.id }),
});
if (res.ok) {
const sub = await res.json();
@@ -155,40 +236,70 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
} catch (err) {
console.error("[TaskDetail] addSubtask failed", err);
}
}, [newSubtitle, listId, task, onUpdate]);
}, [newSubtitle, isDemo, listId, task, subtasks, onDemoUpdateTask, onUpdate]);
const toggleSubtask = useCallback(async (sub: Task) => {
try {
const res = await fetch(`/api/tasks/${sub.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !sub.completed }),
});
if (res.ok) {
const updated = await res.json();
const toggleSubtask = useCallback(
async (sub: Task) => {
const newCompleted = !sub.completed;
if (isDemo) {
const nextSubs = subtasks.map((s) =>
s.id === sub.id
? { ...s, completed: newCompleted, completedAt: newCompleted ? new Date().toISOString() : null }
: s
);
setSubtasks(nextSubs);
const updatedParent = { ...task, children: nextSubs };
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
onUpdate(updatedParent);
return;
}
try {
const res = await fetch(`/api/tasks/${sub.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: newCompleted }),
});
if (res.ok) {
const updated = await res.json();
setSubtasks((prev) => {
const next = prev.map((s) => (s.id === sub.id ? updated : s));
onUpdate({ ...task, children: next });
return next;
});
}
} catch (err) {
console.error("[TaskDetail] toggleSubtask failed", err);
}
},
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
);
const deleteSubtask = useCallback(
async (id: string) => {
if (isDemo) {
const nextSubs = subtasks.filter((s) => s.id !== id);
setSubtasks(nextSubs);
const updatedParent = { ...task, children: nextSubs };
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
onUpdate(updatedParent);
return;
}
try {
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
setSubtasks((prev) => {
const next = prev.map((s) => (s.id === sub.id ? updated : s));
const next = prev.filter((s) => s.id !== id);
onUpdate({ ...task, children: next });
return next;
});
} catch (err) {
console.error("[TaskDetail] deleteSubtask failed", err);
}
} catch (err) {
console.error("[TaskDetail] toggleSubtask failed", err);
}
}, [task, onUpdate]);
const deleteSubtask = useCallback(async (id: string) => {
try {
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
setSubtasks((prev) => {
const next = prev.filter((s) => s.id !== id);
onUpdate({ ...task, children: next });
return next;
});
} catch (err) {
console.error("[TaskDetail] deleteSubtask failed", err);
}
}, [task, onUpdate]);
},
[isDemo, subtasks, task, onDemoUpdateTask, onUpdate]
);
const completedCount = subtasks.filter((s) => s.completed).length;
const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0;
@@ -204,13 +315,17 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
/>
<span style={{ flex: 1, fontSize: 12, color: "var(--text-tertiary)", fontWeight: 500 }}>
{saving ? "Saving" : "Auto-saved"}
{saving ? t("saving") : t("autoSaved")}
</span>
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title="Delete task">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title={t("deleteTaskConfirm")}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
</svg>
</button>
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title="Close">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title={t("cancel")}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
@@ -225,7 +340,7 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
setTitle(e.target.value);
debounceSave({ title: e.target.value });
}}
placeholder="Task title"
placeholder={t("taskTitlePlaceholder")}
rows={2}
style={{
textDecoration: task.completed ? "line-through" : "none",
@@ -235,15 +350,18 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
{/* Priority */}
<div className="detail-section">
<div className="detail-section-label">Priority</div>
<div className="detail-section-label">{t("priority")}</div>
<div className="priority-selector">
{PRIORITY_MAP.map((p, i) => (
{priorityMap.map((p, i) => (
<button
key={i}
id={`priority-${i}`}
className={`priority-btn${priority === i ? ` active-${p.label.toLowerCase()}` : ""}`}
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
style={{ color: priority === i ? p.color : undefined }}
onClick={() => { setPriority(i); debounceSave({ priority: i }); }}
onClick={() => {
setPriority(i);
debounceSave({ priority: i });
}}
>
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
{p.label}
@@ -254,7 +372,7 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
{/* Due date */}
<div className="detail-section">
<div className="detail-section-label">Due Date</div>
<div className="detail-section-label">{t("dueDate")}</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
id="due-date-input"
@@ -270,32 +388,35 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
{dueDate && (
<button
className="btn btn-ghost btn-sm"
onClick={() => { setDueDate(""); debounceSave({ dueDate: null }); }}
onClick={() => {
setDueDate("");
debounceSave({ dueDate: null });
}}
>
Clear
{t("clear")}
</button>
)}
</div>
</div>
{/* Note / Memo */}
{/* Notes — wide memo area */}
<div className="detail-section" style={{ flex: 1 }}>
<div className="detail-section-label">Notes</div>
<div className="detail-section-label">{t("notes")}</div>
<div className="note-editor-wrap">
<div className="note-editor-toolbar">
<button className="note-toolbar-btn" title="Bold (Ctrl+B)" onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
<button className="note-toolbar-btn" title="Italic (Ctrl+I)" style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
<button className="note-toolbar-btn" title="Heading" onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
<button className="note-toolbar-btn" title="Bullet list" onClick={() => insertAtCursor("\n- ", "", "item")}></button>
<button className="note-toolbar-btn" title="Numbered list" onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
<button className="note-toolbar-btn" title="Checkbox" onClick={() => insertAtCursor("\n- [ ] ", "", "task")}></button>
<button className="note-toolbar-btn" title="Code" style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
<button className="note-toolbar-btn" title={t("bold")} onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
<button className="note-toolbar-btn" title={t("italic")} style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
<button className="note-toolbar-btn" title={t("heading")} onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
<button className="note-toolbar-btn" title={t("bulletList")} onClick={() => insertAtCursor("\n- ", "", "item")}></button>
<button className="note-toolbar-btn" title={t("numberedList")} onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
<button className="note-toolbar-btn" title={t("checkbox")} onClick={() => insertAtCursor("\n- [ ] ", "", "task")}></button>
<button className="note-toolbar-btn" title={t("code")} style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
</div>
<textarea
ref={noteRef}
id="note-textarea"
className="note-textarea"
placeholder={"Add notes, details, or anything you need to remember…\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox"}
placeholder={t("notesPlaceholder")}
value={note}
onChange={(e) => {
setNote(e.target.value);
@@ -308,9 +429,11 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
{/* Sub-tasks */}
<div className="detail-section">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<div className="detail-section-label">Sub-tasks</div>
<div className="detail-section-label">{t("subtasks")}</div>
{subtasks.length > 0 && (
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{completedCount}/{subtasks.length}</span>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
{completedCount}/{subtasks.length}
</span>
)}
</div>
@@ -339,7 +462,9 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
onClick={() => deleteSubtask(sub.id)}
aria-label="Delete subtask"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
@@ -347,16 +472,21 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span>
<input
id="add-subtask-input"
placeholder="Add sub-task…"
placeholder={t("addSubtaskPlaceholder")}
style={{ flex: 1, background: "none", fontSize: 13, color: "var(--text-primary)" }}
value={newSubtitle}
onChange={(e) => setNewSubtitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); addSubtask(); }
if (e.key === "Enter") {
e.preventDefault();
addSubtask();
}
}}
/>
{newSubtitle.trim() && (
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>Add</button>
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>
{t("add")}
</button>
)}
</div>
</div>
@@ -364,7 +494,12 @@ export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props)
{/* Metadata */}
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}>
Created {new Date(task.createdAt).toLocaleDateString("ko-KR", { year: "numeric", month: "short", day: "numeric" })}
{t("created")}{" "}
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</div>
</div>
</aside>
+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>