From cadb2c78cf37d121d211b22ddb83c80b91f37e0b Mon Sep 17 00:00:00 2001 From: Wonhee Han <47270724+neruhan01@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:45:55 +0900 Subject: [PATCH] Fix: Full N-depth recursive subtask tree rendering in main list, universal custom context menu on all subtask levels, and bidirectional realtime sync --- AGENTS.md | 48 ++--- src/components/layout/AppShell.tsx | 165 +++++------------ src/components/tasks/TaskList.tsx | 273 ++++++++++++----------------- src/lib/mockData.ts | 92 +++++++--- 4 files changed, 255 insertions(+), 323 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e1a5d6c..fedc1cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,24 +8,21 @@ 사용자의 핵심 요구사항: 1. **TickTick 스타일 To-Do 리스트 독립 웹앱**: - - 체크리스트는 **메인 - 하위(Sub-task)** 계층 구조를 갖춤. - - **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 작성한 마크다운이 실제 웹 문서처럼 미려한 GUI 형태로 렌더링(Preview & Live Checkbox)되어야 함. + - 체크리스트는 **메인 - 하위(Sub-task)** 2단계 계층 구조를 갖추며, 양쪽 패널 간 1:1 실시간 동기화. + - **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 작성한 마크다운이 실제 웹 문서처럼 미려한 GUI 형태로 렌더링(Preview & Interactive Checkbox) 지원. + - **인라인 즉시 수정 UX**: 상단 프로젝트 제목 및 태스크 제목을 더블클릭/클릭으로 즉시 수정 가능. 2. **셀프호스팅 & 프라이버시 중심 멀티유저**: - SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼. - - Docker 배포 지원 (PostgreSQL 포함). + - Docker 배포 지원 (PostgreSQL 16 포함, Dockerfile & docker-compose.yml 완비). 3. **외부 플랫폼 연동 & Import**: - - TickTick 등 외부 플랫폼에서 내보내기한 CSV 및 ICS(iCalendar) 파일 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 & Phase 3)**: - - **다국어 (i18n)**: 영어(기본) → 한국어 → 일본어 순서 지원 (커스텀 팝오버 UI 탑재). - - **테마 모드**: 시스템(System) / 라이트(Light) / 다크(Dark) 3-Way 토글. - - **DB 없는 데모/미리보기 모드**: 로컬 환경에서 DB 구동 없이도 UI/UX 및 모든 기능을 즉시 시연 및 확인할 수 있는 Demo 모드 (`/demo`, LocalStorage 연동). - - **인프라**: Nginx Proxy Manager (NPM)로 역방향 프록시할 예정이므로 자체 SSL/Nginx 없이 포트 3000 컨테이너로 동작. +5. **디자인 시스템 & 반응형 웹앱 UX**: + - TickTick / OneUI / Vercel 디자인 언어. + - **네이티브 웹앱 느낌의 커스텀 우클릭 컨텍스트 메뉴(ContextMenu)** 지원. + - 부드러운 사이드바 애니메이션 및 화면 테마/언어 컨트롤 잘림 방지. --- @@ -40,14 +37,15 @@ src/ │ └── api/ (auth, lists, tasks, import, dav) ├── components/ │ ├── layout/ -│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너 -│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어 셀렉터, 테마 토글, 유저 메뉴 +│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너 (실시간 하위 태스크 & 인라인 동기화) +│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어 셀렉터, 테마 토글, 유저 프로필 팝오버(Import 내장) │ ├── tasks/ -│ │ ├── TaskList.tsx ← 중앙 체크리스트 (하위 태스크 아코디언, 빠른 추가) -│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (컴팩트 메타바 + 와이드 메모장 + 하단 하위할일) +│ │ ├── TaskList.tsx ← 중앙 체크리스트 (인라인 제목 수정, 하위 태스크 추가, 우클릭 메뉴) +│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (TickTick 스타일 메타바 + 와이드 메모장 + 하단 하위할일) │ │ └── MarkdownNoteEditor.tsx ← 👁️ Preview / ✏️ Edit 탭, 마크다운 툴바, 인터랙티브 체크박스 │ └── ui/ -│ └── LanguageSelector.tsx ← OneUI/Vercel 스타일 커스텀 글래스모피즘 언어 팝오버 +│ ├── LanguageSelector.tsx ← OneUI/Vercel 스타일 커스텀 글래스모피즘 언어 팝오버 +│ └── ContextMenu.tsx ← 네이티브 웹앱 커스텀 우클릭 컨텍스트 메뉴 └── lib/ ├── i18n/ (en, ko, ja 사전 및 useI18n 훅) ├── mockData.ts ← Demo 모드용 LocalStorage Store @@ -58,14 +56,16 @@ src/ ## 3. 롤백 포인트 (Checkpoints) -- **`checkpoint-v1.0`** (`backup-v1.0`): 기본 Next.js 16 + Prisma + NextAuth + CardDAV 기반 구조. -- **`checkpoint-v2.0`**: i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재. -- **`checkpoint-v2.1`**: **(현재 최신)** 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장 레이아웃, 커스텀 LanguageSelector 팝오버 적용. +- **`checkpoint-v1.0`**: 기본 Full-Stack 기반 시점 +- **`checkpoint-v2.0`**: i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재 +- **`checkpoint-v2.1`**: 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장 +- **`checkpoint-v2.2`**: 하위 태스크 실시간 동기화 & 인라인 즉시 수정 UX +- **`checkpoint-v3.0`**: **(현재 최신)** 부드러운 사이드바 애니메이션, 테마 버튼 잘림 완벽 해결, Import 기능 프로필 메뉴 이동, 커스텀 웹앱 우클릭 컨텍스트 메뉴, TickTick 스타일 우측 상세 탭 완비 --- -## 4. 향후 작업 가이드 (For Next Agents) +## 4. 개발 규칙 및 주의사항 -1. **메모장 우선 원칙**: 메모장은 항상 상세 패널의 메인 공간(`flex: 1`)을 차지하도록 유지할 것. -2. **마크다운 상호작용**: Preview 모드에서 `- [ ]` 체크박스 클릭 시 원본 텍스트의 체크 상태가 토글되는 기능을 유지할 것. -3. **i18n & 테마 무결성**: 새로운 텍스트는 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것. \ No newline at end of file +1. **메모장 우선 원칙**: 우측 상세 탭에서 메모장은 항상 메인 영역(`flex: 1`)을 차지하도록 유지할 것. +2. **웹앱 인터랙션**: 브라우저 기본 컨텍스트 메뉴 대신 `ContextMenu.tsx`를 통해 앱스러운 UX를 유지할 것. +3. **i18n 무결성**: 텍스트 추가 시 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것. \ No newline at end of file diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 62109ac..51c74e0 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -3,7 +3,16 @@ import { useState, useCallback, useEffect } from "react"; import { Sidebar } from "./Sidebar"; import { TaskList, Task, List, User } from "../tasks/TaskList"; import { TaskDetail } from "../tasks/TaskDetail"; -import { getDemoStore, saveDemoStore, MockList, MockTask } from "@/lib/mockData"; +import { + getDemoStore, + saveDemoStore, + MockList, + MockTask, + updateTaskInTree, + deleteTaskInTree, + addTaskToTree, + findTaskInTree, +} from "@/lib/mockData"; interface AppShellProps { user: User; @@ -47,22 +56,9 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { setSelectedTask(task); }, []); - // Update a task and sync with main list and selected panel + // Update a task in full tree (supports unlimited N-depth nesting) const handleTaskUpdate = useCallback((updated: Task) => { - setTasks((prev) => - prev.map((t) => { - if (t.id === updated.id) { - return { ...updated, children: updated.children || t.children || [] }; - } - // If a sub-task was updated directly, update parent's children - if (t.children && t.children.some((c) => c.id === updated.id)) { - const newChildren = t.children.map((c) => (c.id === updated.id ? updated : c)); - return { ...t, children: newChildren }; - } - return t; - }) - ); - + setTasks((prev) => updateTaskInTree(prev as MockTask[], updated as MockTask) as Task[]); setSelectedTask((prev) => { if (prev && prev.id === updated.id) { return { ...updated, children: updated.children || prev.children || [] }; @@ -105,7 +101,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { } }; - // Demo Handlers + // Demo Handlers with Full N-Depth Recursive Tree Support const handleDemoCreateList = (name: string, color: string) => { const newList: MockList = { id: "demo-list-" + Date.now(), @@ -150,79 +146,41 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { tags: [], }; const store = getDemoStore(); + const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask); + saveDemoStore(store.lists, newTasks); - if (parentId) { - // Add as subtask to parent - const newTasks = store.tasks.map((t) => { - if (t.id === parentId) { - const currentChildren = t.children || []; - return { ...t, children: [...currentChildren, newTask as MockTask] }; - } - return t; - }); - saveDemoStore(store.lists, newTasks); - setTasks((prev) => - prev.map((t) => (t.id === parentId ? { ...t, children: [...(t.children || []), newTask] } : t)) + setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]); + + // If currently selected task is the parent, update its children in the detail panel immediately + if (selectedTask && selectedTask.id === parentId) { + setSelectedTask((prev) => + prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev ); - if (selectedTask && selectedTask.id === parentId) { - setSelectedTask((prev) => (prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev)); - } - } else { - // Top-level task - 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 }; - } - if (t.children && t.children.some((c) => c.id === id)) { - const newChildren = t.children.map((c) => - c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c - ); - return { ...t, children: newChildren }; - } - return t; - }); - saveDemoStore(store.lists, newTasks); + const target = findTaskInTree(store.tasks, id); + if (target) { + const updated = { + ...target, + completed, + completedAt: completed ? new Date().toISOString() : null, + }; + const newTasks = updateTaskInTree(store.tasks, updated); + saveDemoStore(store.lists, newTasks); + setTasks((prev) => + (updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed) + ); - setTasks((prev) => - prev - .map((t) => { - if (t.id === id) { - return { ...t, completed, completedAt: completed ? new Date().toISOString() : null }; - } - if (t.children && t.children.some((c) => c.id === id)) { - const newChildren = t.children.map((c) => - c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c - ); - return { ...t, children: newChildren }; - } - return t; - }) - .filter((t) => showCompleted || !t.completed) - ); - - if (selectedTask) { - if (selectedTask.id === id) { - setSelectedTask((prev) => (prev ? { ...prev, completed, completedAt: completed ? new Date().toISOString() : null } : prev)); - } else if (selectedTask.children && selectedTask.children.some((c) => c.id === id)) { - setSelectedTask((prev) => - prev - ? { - ...prev, - children: prev.children.map((c) => - c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c - ), - } - : prev - ); + // Keep selectedTask 100% in sync with the tree + if (selectedTask) { + const latestSelected = findTaskInTree(newTasks, selectedTask.id); + if (latestSelected) { + setSelectedTask(latestSelected as Task); + } } } refresh(); @@ -230,32 +188,18 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { const handleDemoUpdateTask = (updated: Task) => { const store = getDemoStore(); - const newTasks = store.tasks.map((t) => (t.id === updated.id ? (updated as MockTask) : t)); + const newTasks = updateTaskInTree(store.tasks, updated as MockTask); saveDemoStore(store.lists, newTasks); handleTaskUpdate(updated); }; const handleDemoDeleteTask = (id: string) => { const store = getDemoStore(); - // Remove if main task or remove from parent children - const newTasks = store.tasks - .filter((t) => t.id !== id) - .map((t) => { - if (t.children && t.children.some((c) => c.id === id)) { - return { ...t, children: t.children.filter((c) => c.id !== id) }; - } - return t; - }); + const newTasks = deleteTaskInTree(store.tasks, id); saveDemoStore(store.lists, newTasks); - setTasks((prev) => - prev - .filter((t) => t.id !== id) - .map((t) => (t.children ? { ...t, children: t.children.filter((c) => c.id !== id) } : t)) - ); + setTasks((prev) => deleteTaskInTree(prev as MockTask[], id) as Task[]); if (selectedTask?.id === id) { setSelectedTask(null); - } else if (selectedTask?.children?.some((c) => c.id === id)) { - setSelectedTask((prev) => (prev ? { ...prev, children: prev.children.filter((c) => c.id !== id) } : prev)); } refresh(); }; @@ -267,25 +211,12 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { if (isDemo) { const store = getDemoStore(); - const newTasks = store.tasks.map((t) => { - if (t.id === taskId) return { ...t, title: trimmed }; - if (t.children && t.children.some((c) => c.id === taskId)) { - return { ...t, children: t.children.map((c) => (c.id === taskId ? { ...c, title: trimmed } : c)) }; - } - return t; - }); - saveDemoStore(store.lists, newTasks); - setTasks((prev) => - prev.map((t) => { - if (t.id === taskId) return { ...t, title: trimmed }; - if (t.children && t.children.some((c) => c.id === taskId)) { - return { ...t, children: t.children.map((c) => (c.id === taskId ? { ...c, title: trimmed } : c)) }; - } - return t; - }) - ); - if (selectedTask?.id === taskId) { - setSelectedTask((prev) => (prev ? { ...prev, title: trimmed } : prev)); + const target = findTaskInTree(store.tasks, taskId); + if (target) { + const updated = { ...target, title: trimmed }; + const newTasks = updateTaskInTree(store.tasks, updated); + saveDemoStore(store.lists, newTasks); + handleTaskUpdate(updated as Task); } return; } diff --git a/src/components/tasks/TaskList.tsx b/src/components/tasks/TaskList.tsx index ef57488..49b597b 100644 --- a/src/components/tasks/TaskList.tsx +++ b/src/components/tasks/TaskList.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { useI18n } from "@/lib/i18n"; import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu"; @@ -17,7 +17,7 @@ export interface Task { createdAt: string; updatedAt: string; children: Task[]; - tags: { tag: { id: string; name: string; color: string } }[]; + tags?: { tag: { id: string; name: string; color: string } }[]; } export interface List { id: string; name: string; color: string; icon: string } @@ -32,17 +32,19 @@ function isOverdue(d: string | null) { interface TaskItemProps { task: Task; + depth?: number; isSelected: boolean; onSelect: (t: Task) => void; onToggle: (id: string, completed: boolean) => void; onUpdateTitle: (id: string, title: string) => void; onAddSubtask: (title: string, parentId: string) => void; onContextMenu: (x: number, y: number, task: Task) => void; - onDeleteTask?: (id: string) => void; } -function TaskItem({ +// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly) +function RecursiveTaskItem({ task, + depth = 0, isSelected, onSelect, onToggle, @@ -62,6 +64,7 @@ function TaskItem({ const subInputRef = useRef(null); const completedChildren = task.children?.filter((c) => c.completed).length || 0; + const totalChildren = task.children?.length || 0; useEffect(() => { setTempTitle(task.title); @@ -115,17 +118,50 @@ function TaskItem({ }; return ( -
+
0 ? 24 : 0, position: "relative" }}>
onSelect(task)} onContextMenu={(e) => { e.preventDefault(); + e.stopPropagation(); onContextMenu(e.clientX, e.clientY, task); }} id={`task-${task.id}`} - style={{ group: "item" } as React.CSSProperties} + style={{ + borderLeft: depth > 0 ? "2px solid var(--border)" : "none", + marginLeft: depth > 0 ? 8 : 0, + }} > + {/* Toggle Expand Arrow if has children */} + {totalChildren > 0 ? ( + + ) : depth > 0 ? ( + + ) : null} + + {/* Check button */}
- {task.note && !task.completed && ( + {task.note && !task.completed && depth === 0 && (
{task.note.replace(/[#*`]/g, "").slice(0, 80)}
)}
@@ -233,7 +259,7 @@ function TaskItem({ {/* Quick Edit icon on hover */}
- {/* Sub-tasks List */} - {((task.children && task.children.length > 0) || addingSubtask) && expanded && ( -
- {task.children?.map((child) => ( - 0 && expanded && ( +
+ {task.children.map((child) => ( + ))} - - {/* Inline Add Subtask input */} - {addingSubtask && ( -
- - setSubtaskInput(e.target.value)} - onBlur={() => { - if (!subtaskInput.trim()) setAddingSubtask(false); - }} - onKeyDown={(e) => { - if (e.key === "Escape") setAddingSubtask(false); - }} - style={{ height: 26, fontSize: 12.5, padding: "2px 8px", flex: 1 }} - /> - -
- )}
)} -
- ); -} -// Sub-task Item with Inline Rename -function SubTaskItem({ - child, - onToggle, - onSelect, - onUpdateTitle, -}: { - child: Task; - onToggle: (id: string, completed: boolean) => void; - onSelect: (t: Task) => void; - onUpdateTitle: (id: string, title: string) => void; -}) { - const [editing, setEditing] = useState(false); - const [temp, setTemp] = useState(child.title); - const ref = useRef(null); - - useEffect(() => { - if (editing) { - ref.current?.focus(); - ref.current?.select(); - } - }, [editing]); - - const handleSave = () => { - const trimmed = temp.trim(); - if (trimmed && trimmed !== child.title) { - onUpdateTitle(child.id, trimmed); - } else { - setTemp(child.title); - } - setEditing(false); - }; - - return ( -
onSelect(child)} - id={`subtask-${child.id}`} - > - + )}
); @@ -469,27 +421,12 @@ export function TaskList({ 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 }; - if (t.children && t.children.some((c) => c.id === id)) { - return { - ...t, - children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)), - }; - } - return t; - }) - .filter((t) => showCompleted || !t.completed) - ); onRefresh(); } catch (err) { console.error("[TaskList] handleToggle failed", err); } }, - [isDemo, onDemoToggleTask, showCompleted, onRefresh, setTasks] + [isDemo, onDemoToggleTask, onRefresh] ); const handleAddTask = async (e: React.FormEvent) => { @@ -533,10 +470,6 @@ export function TaskList({ body: JSON.stringify({ title, listId, parentId }), }); if (res.ok) { - const sub = await res.json(); - setTasks((prev) => - prev.map((t) => (t.id === parentId ? { ...t, children: [...(t.children || []), sub] } : t)) - ); onRefresh(); } } catch (err) { @@ -565,6 +498,7 @@ export function TaskList({ ); } + // Filter top-level vs completed const incompleteTasks = tasks.filter((t) => !t.completed); const completedTasks = tasks.filter((t) => t.completed); @@ -572,7 +506,7 @@ export function TaskList({
{/* Header */}
-
- {/* Task list */} -
+ {/* Task list container with background context menu handler */} +
{ + // If clicked directly on empty container area, prevent native browser menu + if (e.target === e.currentTarget) { + e.preventDefault(); + } + }} + > {loading && (
{t("loading")}
)} @@ -651,10 +594,13 @@ export function TaskList({

{t("noTasksYet")}

)} + + {/* Incomplete Tasks Recursive Tree */} {incompleteTasks.map((task) => ( - setContextMenu({ x, y, task: tItem })} /> ))} + + {/* Completed Tasks Recursive Tree */} {showCompleted && completedTasks.length > 0 && (
{completedTasks.map((task) => ( - - {/* Context Menu on Task */} + {/* Full-featured Context Menu on ANY Task at ANY Depth */} {contextMenu && ( -