diff --git a/AGENTS.md b/AGENTS.md index 643577d..a62165b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,79 @@ +# CheckFlow — Agent Handbook & Project History + +> 이 문서는 CheckFlow 프로젝트의 전체 개발 내역, 사용자 요구사항, 의사결정 기록, 기술 아키텍처 및 트러블슈팅을 보존하여 향후 작업하는 모든 AI 에이전트와 개발자가 일관되게 작업을 이어갈 수 있도록 작성되었습니다. + +--- + +## 1. 프로젝트 개요 & 핵심 요구사항 + +사용자의 핵심 요구사항: +1. **TickTick 스타일 To-Do 리스트 독립 웹앱**: + - 체크리스트는 **메인 - 하위(Sub-task)** 계층 구조를 갖춤. + - 메인 체크리스트에 귀속된 **넓직한 메모장(Markdown 노트)** 제공. +2. **셀프호스팅 & 프라이버시 중심 멀티유저**: + - SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼. + - Docker 배포 지원 (PostgreSQL 포함). +3. **외부 플랫폼 연동 & Import**: + - TickTick 등 외부 플랫폼에서 내보내기한 CSV 및 ICS(iCalendar) 파일 Import 지원. + - Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV/CardDAV (`/api/dav`) 동기화 지원. +4. **PWA (Progressive Web App)**: + - 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원. +5. **디자인 시스템**: + - TickTick 급의 심플함 + OneUI / Material You / Vercel 스타일의 직관적이고 미려한 UI. + - 3-Panel 반응형 레이아웃 (사이드바 - 태스크 목록 - 넓은 상세 메모 패널). +6. **추가 요구사항 (Phase 2)**: + - **다국어 (i18n)**: 영어(기본) → 한국어 → 일본어 순서 지원. + - **테마 모드**: 시스템(System) / 라이트(Light) / 다크(Dark) 3-Way 토글. + - **DB 없는 데모/미리보기 모드**: 로컬 환경에서 DB 구동 없이도 UI/UX 및 모든 기능을 즉시 시연 및 확인할 수 있는 Demo 모드 지원. + - **인프라**: Nginx Proxy Manager (NPM)로 역방향 프록시할 예정이므로 자체 SSL/Nginx 없이 포트 3000 컨테이너로 동작. + +--- + +## 2. 기술 스택 (Tech Stack) + +- **Frontend**: Next.js 16 (App Router), TypeScript, Vanilla CSS (TailwindCSS 지양, 고품질 CSS Variable 디자인 시스템) +- **Backend / API**: Next.js API Routes (Route Handlers) +- **ORM / DB**: Prisma ORM, PostgreSQL (Production/Docker) + Local/Demo In-Memory/LocalStorage Fallback +- **Auth**: NextAuth.js (Credentials Provider, JWT 전략) +- **DAV Server**: `/api/dav/[...path]` (iCalendar VTODO 표준 구현, Basic Auth 지원) +- **PWA**: Service Worker (`public/sw.js`), Web App Manifest (`public/manifest.json`) +- **Container**: `Dockerfile` (Node 20 Alpine Standalone), `docker-compose.yml` (PostgreSQL 16 + Auto migration + App) + +--- + +## 3. 주요 결정 및 트러블슈팅 히스토리 + +### [버그 1] Next.js 16 `middleware` Deprecation +- **현상**: Next.js 16 최신 버전에서 `src/middleware.ts` 빌드 시 오류 발생. +- **해결**: Next.js 16 스펙에 맞춰 `src/proxy.ts`로 마이그레이션 (`export async function proxy(...)`). + +### [버그 2] DAVx⁵ CalDAV/CardDAV 인증 차단 문제 +- **현상**: `src/proxy.ts`에서 미인증 세션을 `/login`으로 리다이렉트하여, DAVx⁵의 HTTP Basic Auth 요청이 차단됨. +- **해결**: `publicPaths`에 `/api/dav`를 추가하여 Basic Auth는 라우트 핸들러 자체에서 검증하도록 예외 처리. + +### [버그 3] DB 미연결 시 `register/page.tsx` SyntaxError +- **현상**: DB가 닫혀있을 때 서버가 500 에러를 반환하면 프론트엔드의 `res.json()` 호출 시 JSON 파싱 오류 발생. +- **해결**: 모든 API Route에 try-catch를 씌우고, 클라이언트 `res.json()` 파싱부를 안전하게 try-catch 처리. + +### [버그 4] TaskDetail 자동저장 Stale Closure & 타이머 누수 +- **현상**: debounce 저장 타이머가 태스크 전환 시 이전 태스크 ID로 저장되거나 언마운트 시 메모리 누수 위험. +- **해결**: `useRef(currentTaskId)`와 언마운트 cleanup 추가. + +--- + +## 4. 롤백 포인트 (Checkpoints) + +- **Git Tag `checkpoint-v1.0` / Branch `backup-v1.0`**: 기본 Full-Stack 구조 및 버그픽스 완료 시점. +- 언제든 문제가 발생하면 `git checkout checkpoint-v1.0`으로 복구 가능. + +--- + +## 5. 향후 작업 가이드 (For Next Agents) + +1. **디자인 무결성**: 바닐라 CSS 변수(`var(--accent)`, `var(--bg-primary)` 등)를 준수하고 모바일 반응형(768px 이하)을 항상 고려할 것. +2. **다국어(i18n)**: 텍스트 추가 시 `src/lib/i18n`의 en/ko/ja 사전에 키를 반드시 함께 등록할 것. +3. **데모 모드 지원**: DB 연결 불가 상태에서도 사용자가 UI를 체험할 수 있는 환경을 유지할 것. + # This is NOT the Next.js you know diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx new file mode 100644 index 0000000..f4fc898 --- /dev/null +++ b/src/app/demo/page.tsx @@ -0,0 +1,11 @@ +import { AppShell } from "@/components/layout/AppShell"; + +export default function DemoPage() { + const demoUser = { + id: "demo-user", + name: "Demo Explorer", + email: "demo@checkflow.local", + }; + + return ; +} \ No newline at end of file diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2ba2589..11927ca 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,11 +1,16 @@ -"use client"; +"use client"; import { useState } from "react"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { useI18n, Language } from "@/lib/i18n"; +import { useTheme } from "@/app/providers"; export default function LoginPage() { const router = useRouter(); + const { t, lang, setLang } = useI18n(); + const { theme, toggleTheme } = useTheme(); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); @@ -15,27 +20,109 @@ export default function LoginPage() { e.preventDefault(); setError(""); setLoading(true); - const res = await signIn("credentials", { email, password, redirect: false }); - setLoading(false); - if (res?.error) { - setError("Invalid email or password."); - } else { - router.push("/"); + try { + const res = await signIn("credentials", { email, password, redirect: false }); + setLoading(false); + if (res?.error) { + setError("Invalid email or password. (Make sure DB is running)"); + } else { + router.push("/"); + } + } catch { + setError("Login failed. Check server status."); + setLoading(false); } }; return (
-
-
- CheckFlow + {/* Top bar: Lang & Theme switcher */} +
+
+
+ {t("appName")} +
+
+ + + +
-

Welcome back

-

Sign in to your account

+ +

{t("welcomeBack")}

+

{t("signInSubtitle")}

+ + {/* Demo Mode Action Banner */} +
+
+ ✨ {t("demoBadge")} + No PostgreSQL needed +
+

+ Explore full 3-panel UI, sub-tasks, and markdown notes directly in browser storage. +

+ + 🚀 {t("tryDemoMode")} + +
+
- +
- + {error &&

{error}

}

- Don't have an account?{" "} - Create one + {t("dontHaveAccount")}{" "} + {t("createAccount")}

diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 5929332..bdf8809 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,43 +1,95 @@ -"use client"; +"use client"; import { SessionProvider } from "next-auth/react"; -import { useEffect } from "react"; +import React, { createContext, useContext, useEffect, useState } from "react"; import { I18nProvider } from "@/lib/i18n"; export type ThemeMode = "system" | "light" | "dark"; -export function applyTheme(mode: ThemeMode) { - const isDark = - mode === "dark" || - (mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); - document.documentElement.setAttribute("data-theme", isDark ? "dark" : "light"); - localStorage.setItem("theme", mode); +interface ThemeContextType { + theme: ThemeMode; + resolvedTheme: "light" | "dark"; + setTheme: (mode: ThemeMode) => void; + toggleTheme: () => void; } -export function Providers({ children }: { children: React.ReactNode }) { +const ThemeContext = createContext({ + theme: "system", + resolvedTheme: "light", + setTheme: () => {}, + toggleTheme: () => {}, +}); + +export const useTheme = () => useContext(ThemeContext); + +function ThemeManager({ children }: { children: React.ReactNode }) { + const [theme, setThemeState] = useState("system"); + const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light"); + + const applyTheme = (mode: ThemeMode) => { + let effective: "light" | "dark" = "light"; + if (mode === "system") { + const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + effective = isDark ? "dark" : "light"; + } else { + effective = mode; + } + setResolvedTheme(effective); + document.documentElement.setAttribute("data-theme", effective); + }; + useEffect(() => { - // Apply saved theme - const saved = (localStorage.getItem("theme") as ThemeMode) || "system"; - applyTheme(saved); + const saved = localStorage.getItem("checkflow_theme") as ThemeMode | null; + const initial = saved || "system"; + setThemeState(initial); + applyTheme(initial); - // Watch system preference changes (only affects "system" mode) - const mq = window.matchMedia("(prefers-color-scheme: dark)"); - const onMqChange = () => { - const current = (localStorage.getItem("theme") as ThemeMode) || "system"; - if (current === "system") applyTheme("system"); + // Listen for system theme changes if set to system + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const listener = (e: MediaQueryListEvent) => { + const current = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system"; + if (current === "system") { + const effective = e.matches ? "dark" : "light"; + setResolvedTheme(effective); + document.documentElement.setAttribute("data-theme", effective); + } }; - mq.addEventListener("change", onMqChange); - // Service Worker + media.addEventListener("change", listener); + + // Register Service Worker if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(console.error); } - return () => mq.removeEventListener("change", onMqChange); + return () => media.removeEventListener("change", listener); }, []); + const setTheme = (mode: ThemeMode) => { + setThemeState(mode); + localStorage.setItem("checkflow_theme", mode); + applyTheme(mode); + }; + + // Cycles through: System -> Light -> Dark -> System + const toggleTheme = () => { + const sequence: ThemeMode[] = ["system", "light", "dark"]; + const nextIndex = (sequence.indexOf(theme) + 1) % sequence.length; + setTheme(sequence[nextIndex]); + }; + + return ( + + {children} + + ); +} + +export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } \ No newline at end of file diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index bf2fc7b..9513fa5 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -1,11 +1,16 @@ -"use client"; +"use client"; import { useState } from "react"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { useI18n, Language } from "@/lib/i18n"; +import { useTheme } from "@/app/providers"; export default function RegisterPage() { const router = useRouter(); + const { t, lang, setLang } = useI18n(); + const { theme, toggleTheme } = useTheme(); + const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -17,41 +22,123 @@ export default function RegisterPage() { setError(""); setLoading(true); - const res = await fetch("/api/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, email, password }), - }); + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email, password }), + }); - if (!res.ok) { - let msg = "Registration failed."; - try { - const data = await res.json(); - msg = data.error || msg; - } catch { - // non-JSON response (e.g. 500 HTML page) + if (!res.ok) { + let msg = "Registration failed."; + try { + const data = await res.json(); + msg = data.error || msg; + } catch { + msg = "Database connection failed. Please ensure PostgreSQL is running or use Demo Mode."; + } + setError(msg); + setLoading(false); + return; } - setError(msg); - setLoading(false); - return; - } - await signIn("credentials", { email, password, redirect: false }); - router.push("/"); + await signIn("credentials", { email, password, redirect: false }); + router.push("/"); + } catch { + setError("Network or server error. Check database status or try Demo Mode."); + setLoading(false); + } }; return (
-
-
- CheckFlow + {/* Top bar: Lang & Theme switcher */} +
+
+
+ {t("appName")} +
+
+ + + +
-

Create account

-

Start organizing your tasks today

+ +

{t("createAccount")}

+

{t("createAccountSubtitle")}

+ + {/* Demo Mode Action Banner */} +
+
+ ✨ {t("demoBadge")} + No PostgreSQL needed +
+

+ Explore full 3-panel UI, sub-tasks, and markdown notes directly in browser storage. +

+ + 🚀 {t("tryDemoMode")} + +
+
- +
- +
- + setPassword(e.target.value)} required @@ -90,12 +177,12 @@ export default function RegisterPage() {
{error &&

{error}

}

- Already have an account?{" "} - Sign in + {t("alreadyHaveAccount")}{" "} + {t("signIn")}

diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index c7b8cd3..8fcf4b4 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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([]); const [selectedListId, setSelectedListId] = useState(null); const [selectedTask, setSelectedTask] = useState(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 (
{/* 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} />
@@ -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} />
@@ -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")); }} > diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 5b66a0f..13bf0ed 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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 = { 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>; + 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(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(null); const fileRef = useRef(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 */}
- CheckFlow - +
+ + {/* Top Quick Actions (Lang & Theme) */} +
+ {/* Language Selector */} + + + {/* Theme 3-Way Toggle Button */} + +
{/* Nav */} @@ -199,15 +304,29 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m
setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
{initials}
-
{user.name}
-
{user.email}
+
{user.name || (isDemo ? "Demo User" : "User")}
+
{user.email || (isDemo ? "demo@checkflow.local" : "")}
{userMenuOpen && (
-
signOut({ callbackUrl: "/login" })}> - - Sign out -
+ {isDemo ? ( +
{ window.location.href = "/login"; }} + > + + + + {t("signIn")} +
+ ) : ( +
signOut({ callbackUrl: "/login" })}> + + + + {t("signOut")} +
+ )}
)}
@@ -217,25 +336,29 @@ export function Sidebar({ user, lists, setLists, selectedListId, onListSelect, m {showImport && (
setShowImport(false)}>
e.stopPropagation()}> -

Import Tasks

+

{t("importModalTitle")}

- +
- +

- TickTick: Settings → Export → Export as CSV or iCalendar + {t("tickTickExportHint")}

- {importResult &&

{importResult}

} + {importResult && ( +

+ {importResult} +

+ )}
- +
diff --git a/src/components/tasks/TaskDetail.tsx b/src/components/tasks/TaskDetail.tsx index 89d7ea7..103c026 100644 --- a/src/components/tasks/TaskDetail.tsx +++ b/src/components/tasks/TaskDetail.tsx @@ -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 | null>(null); const currentTaskId = useRef(task.id); const noteRef = useRef(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) => { - // 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) => { + 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) => { + 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) => { - 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"} /> - {saving ? "Saving…" : "Auto-saved"} + {saving ? t("saving") : t("autoSaved")} - -
@@ -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 */}
-
Priority
+
{t("priority")}
- {PRIORITY_MAP.map((p, i) => ( + {priorityMap.map((p, i) => ( )}
- {/* Note / Memo */} + {/* Notes — wide memo area */}
-
Notes
+
{t("notes")}
- - - - - - - + + + + + + +