Fix: Full N-depth recursive subtask tree rendering in main list, universal custom context menu on all subtask levels, and bidirectional realtime sync
This commit is contained in:
@@ -8,24 +8,21 @@
|
|||||||
|
|
||||||
사용자의 핵심 요구사항:
|
사용자의 핵심 요구사항:
|
||||||
1. **TickTick 스타일 To-Do 리스트 독립 웹앱**:
|
1. **TickTick 스타일 To-Do 리스트 독립 웹앱**:
|
||||||
- 체크리스트는 **메인 - 하위(Sub-task)** 계층 구조를 갖춤.
|
- 체크리스트는 **메인 - 하위(Sub-task)** 2단계 계층 구조를 갖추며, 양쪽 패널 간 1:1 실시간 동기화.
|
||||||
- **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 작성한 마크다운이 실제 웹 문서처럼 미려한 GUI 형태로 렌더링(Preview & Live Checkbox)되어야 함.
|
- **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 작성한 마크다운이 실제 웹 문서처럼 미려한 GUI 형태로 렌더링(Preview & Interactive Checkbox) 지원.
|
||||||
|
- **인라인 즉시 수정 UX**: 상단 프로젝트 제목 및 태스크 제목을 더블클릭/클릭으로 즉시 수정 가능.
|
||||||
2. **셀프호스팅 & 프라이버시 중심 멀티유저**:
|
2. **셀프호스팅 & 프라이버시 중심 멀티유저**:
|
||||||
- SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼.
|
- SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼.
|
||||||
- Docker 배포 지원 (PostgreSQL 포함).
|
- Docker 배포 지원 (PostgreSQL 16 포함, Dockerfile & docker-compose.yml 완비).
|
||||||
3. **외부 플랫폼 연동 & Import**:
|
3. **외부 플랫폼 연동 & Import**:
|
||||||
- TickTick 등 외부 플랫폼에서 내보내기한 CSV 및 ICS(iCalendar) 파일 Import 지원.
|
- TickTick 등 외부 플랫폼에서 내보내기한 CSV 및 ICS(iCalendar) 파일 Import 지원 (하단 프로필 메뉴 내 배치).
|
||||||
- Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV/CardDAV (`/api/dav`) 동기화 지원.
|
- Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV/CardDAV (`/api/dav`) 동기화 지원.
|
||||||
4. **PWA (Progressive Web App)**:
|
4. **PWA (Progressive Web App)**:
|
||||||
- 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원.
|
- 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원.
|
||||||
5. **디자인 시스템**:
|
5. **디자인 시스템 & 반응형 웹앱 UX**:
|
||||||
- TickTick 급의 심플함 + OneUI / Material You / Vercel 스타일의 직관적이고 미려한 UI.
|
- TickTick / OneUI / Vercel 디자인 언어.
|
||||||
- 3-Panel 반응형 레이아웃 (사이드바 - 태스크 목록 - 와이드 상세 메모 패널).
|
- **네이티브 웹앱 느낌의 커스텀 우클릭 컨텍스트 메뉴(ContextMenu)** 지원.
|
||||||
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 컨테이너로 동작.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,14 +37,15 @@ src/
|
|||||||
│ └── api/ (auth, lists, tasks, import, dav)
|
│ └── api/ (auth, lists, tasks, import, dav)
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── layout/
|
│ ├── layout/
|
||||||
│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너
|
│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너 (실시간 하위 태스크 & 인라인 동기화)
|
||||||
│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어 셀렉터, 테마 토글, 유저 메뉴
|
│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어 셀렉터, 테마 토글, 유저 프로필 팝오버(Import 내장)
|
||||||
│ ├── tasks/
|
│ ├── tasks/
|
||||||
│ │ ├── TaskList.tsx ← 중앙 체크리스트 (하위 태스크 아코디언, 빠른 추가)
|
│ │ ├── TaskList.tsx ← 중앙 체크리스트 (인라인 제목 수정, 하위 태스크 추가, 우클릭 메뉴)
|
||||||
│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (컴팩트 메타바 + 와이드 메모장 + 하단 하위할일)
|
│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (TickTick 스타일 메타바 + 와이드 메모장 + 하단 하위할일)
|
||||||
│ │ └── MarkdownNoteEditor.tsx ← 👁️ Preview / ✏️ Edit 탭, 마크다운 툴바, 인터랙티브 체크박스
|
│ │ └── MarkdownNoteEditor.tsx ← 👁️ Preview / ✏️ Edit 탭, 마크다운 툴바, 인터랙티브 체크박스
|
||||||
│ └── ui/
|
│ └── ui/
|
||||||
│ └── LanguageSelector.tsx ← OneUI/Vercel 스타일 커스텀 글래스모피즘 언어 팝오버
|
│ ├── LanguageSelector.tsx ← OneUI/Vercel 스타일 커스텀 글래스모피즘 언어 팝오버
|
||||||
|
│ └── ContextMenu.tsx ← 네이티브 웹앱 커스텀 우클릭 컨텍스트 메뉴
|
||||||
└── lib/
|
└── lib/
|
||||||
├── i18n/ (en, ko, ja 사전 및 useI18n 훅)
|
├── i18n/ (en, ko, ja 사전 및 useI18n 훅)
|
||||||
├── mockData.ts ← Demo 모드용 LocalStorage Store
|
├── mockData.ts ← Demo 모드용 LocalStorage Store
|
||||||
@@ -58,14 +56,16 @@ src/
|
|||||||
|
|
||||||
## 3. 롤백 포인트 (Checkpoints)
|
## 3. 롤백 포인트 (Checkpoints)
|
||||||
|
|
||||||
- **`checkpoint-v1.0`** (`backup-v1.0`): 기본 Next.js 16 + Prisma + NextAuth + CardDAV 기반 구조.
|
- **`checkpoint-v1.0`**: 기본 Full-Stack 기반 시점
|
||||||
- **`checkpoint-v2.0`**: i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재.
|
- **`checkpoint-v2.0`**: i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재
|
||||||
- **`checkpoint-v2.1`**: **(현재 최신)** 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장 레이아웃, 커스텀 LanguageSelector 팝오버 적용.
|
- **`checkpoint-v2.1`**: 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장
|
||||||
|
- **`checkpoint-v2.2`**: 하위 태스크 실시간 동기화 & 인라인 즉시 수정 UX
|
||||||
|
- **`checkpoint-v3.0`**: **(현재 최신)** 부드러운 사이드바 애니메이션, 테마 버튼 잘림 완벽 해결, Import 기능 프로필 메뉴 이동, 커스텀 웹앱 우클릭 컨텍스트 메뉴, TickTick 스타일 우측 상세 탭 완비
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 향후 작업 가이드 (For Next Agents)
|
## 4. 개발 규칙 및 주의사항
|
||||||
|
|
||||||
1. **메모장 우선 원칙**: 메모장은 항상 상세 패널의 메인 공간(`flex: 1`)을 차지하도록 유지할 것.
|
1. **메모장 우선 원칙**: 우측 상세 탭에서 메모장은 항상 메인 영역(`flex: 1`)을 차지하도록 유지할 것.
|
||||||
2. **마크다운 상호작용**: Preview 모드에서 `- [ ]` 체크박스 클릭 시 원본 텍스트의 체크 상태가 토글되는 기능을 유지할 것.
|
2. **웹앱 인터랙션**: 브라우저 기본 컨텍스트 메뉴 대신 `ContextMenu.tsx`를 통해 앱스러운 UX를 유지할 것.
|
||||||
3. **i18n & 테마 무결성**: 새로운 텍스트는 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것.
|
3. **i18n 무결성**: 텍스트 추가 시 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것.
|
||||||
@@ -3,7 +3,16 @@ import { useState, useCallback, useEffect } from "react";
|
|||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { TaskList, Task, List, User } from "../tasks/TaskList";
|
import { TaskList, Task, List, User } from "../tasks/TaskList";
|
||||||
import { TaskDetail } from "../tasks/TaskDetail";
|
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 {
|
interface AppShellProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -47,22 +56,9 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
setSelectedTask(task);
|
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) => {
|
const handleTaskUpdate = useCallback((updated: Task) => {
|
||||||
setTasks((prev) =>
|
setTasks((prev) => updateTaskInTree(prev as MockTask[], updated as MockTask) as Task[]);
|
||||||
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;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setSelectedTask((prev) => {
|
setSelectedTask((prev) => {
|
||||||
if (prev && prev.id === updated.id) {
|
if (prev && prev.id === updated.id) {
|
||||||
return { ...updated, children: updated.children || prev.children || [] };
|
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 handleDemoCreateList = (name: string, color: string) => {
|
||||||
const newList: MockList = {
|
const newList: MockList = {
|
||||||
id: "demo-list-" + Date.now(),
|
id: "demo-list-" + Date.now(),
|
||||||
@@ -150,79 +146,41 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
tags: [],
|
tags: [],
|
||||||
};
|
};
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
|
const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask);
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
|
||||||
if (parentId) {
|
setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]);
|
||||||
// Add as subtask to parent
|
|
||||||
const newTasks = store.tasks.map((t) => {
|
// If currently selected task is the parent, update its children in the detail panel immediately
|
||||||
if (t.id === parentId) {
|
if (selectedTask && selectedTask.id === parentId) {
|
||||||
const currentChildren = t.children || [];
|
setSelectedTask((prev) =>
|
||||||
return { ...t, children: [...currentChildren, newTask as MockTask] };
|
prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev
|
||||||
}
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
saveDemoStore(store.lists, newTasks);
|
|
||||||
setTasks((prev) =>
|
|
||||||
prev.map((t) => (t.id === parentId ? { ...t, children: [...(t.children || []), newTask] } : t))
|
|
||||||
);
|
);
|
||||||
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();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDemoToggleTask = (id: string, completed: boolean) => {
|
const handleDemoToggleTask = (id: string, completed: boolean) => {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const newTasks = store.tasks.map((t) => {
|
const target = findTaskInTree(store.tasks, id);
|
||||||
if (t.id === id) {
|
if (target) {
|
||||||
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
|
const updated = {
|
||||||
}
|
...target,
|
||||||
if (t.children && t.children.some((c) => c.id === id)) {
|
completed,
|
||||||
const newChildren = t.children.map((c) =>
|
completedAt: completed ? new Date().toISOString() : null,
|
||||||
c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c
|
};
|
||||||
);
|
const newTasks = updateTaskInTree(store.tasks, updated);
|
||||||
return { ...t, children: newChildren };
|
saveDemoStore(store.lists, newTasks);
|
||||||
}
|
setTasks((prev) =>
|
||||||
return t;
|
(updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed)
|
||||||
});
|
);
|
||||||
saveDemoStore(store.lists, newTasks);
|
|
||||||
|
|
||||||
setTasks((prev) =>
|
// Keep selectedTask 100% in sync with the tree
|
||||||
prev
|
if (selectedTask) {
|
||||||
.map((t) => {
|
const latestSelected = findTaskInTree(newTasks, selectedTask.id);
|
||||||
if (t.id === id) {
|
if (latestSelected) {
|
||||||
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
|
setSelectedTask(latestSelected as Task);
|
||||||
}
|
}
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
@@ -230,32 +188,18 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
|
|
||||||
const handleDemoUpdateTask = (updated: Task) => {
|
const handleDemoUpdateTask = (updated: Task) => {
|
||||||
const store = getDemoStore();
|
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);
|
saveDemoStore(store.lists, newTasks);
|
||||||
handleTaskUpdate(updated);
|
handleTaskUpdate(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDemoDeleteTask = (id: string) => {
|
const handleDemoDeleteTask = (id: string) => {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
// Remove if main task or remove from parent children
|
const newTasks = deleteTaskInTree(store.tasks, id);
|
||||||
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;
|
|
||||||
});
|
|
||||||
saveDemoStore(store.lists, newTasks);
|
saveDemoStore(store.lists, newTasks);
|
||||||
setTasks((prev) =>
|
setTasks((prev) => deleteTaskInTree(prev as MockTask[], id) as Task[]);
|
||||||
prev
|
|
||||||
.filter((t) => t.id !== id)
|
|
||||||
.map((t) => (t.children ? { ...t, children: t.children.filter((c) => c.id !== id) } : t))
|
|
||||||
);
|
|
||||||
if (selectedTask?.id === id) {
|
if (selectedTask?.id === id) {
|
||||||
setSelectedTask(null);
|
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();
|
refresh();
|
||||||
};
|
};
|
||||||
@@ -267,25 +211,12 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const newTasks = store.tasks.map((t) => {
|
const target = findTaskInTree(store.tasks, taskId);
|
||||||
if (t.id === taskId) return { ...t, title: trimmed };
|
if (target) {
|
||||||
if (t.children && t.children.some((c) => c.id === taskId)) {
|
const updated = { ...target, title: trimmed };
|
||||||
return { ...t, children: t.children.map((c) => (c.id === taskId ? { ...c, title: trimmed } : c)) };
|
const newTasks = updateTaskInTree(store.tasks, updated);
|
||||||
}
|
saveDemoStore(store.lists, newTasks);
|
||||||
return t;
|
handleTaskUpdate(updated as Task);
|
||||||
});
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-162
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
||||||
@@ -17,7 +17,7 @@ export interface Task {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
children: Task[];
|
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 }
|
export interface List { id: string; name: string; color: string; icon: string }
|
||||||
@@ -32,17 +32,19 @@ function isOverdue(d: string | null) {
|
|||||||
|
|
||||||
interface TaskItemProps {
|
interface TaskItemProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
|
depth?: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: (t: Task) => void;
|
onSelect: (t: Task) => void;
|
||||||
onToggle: (id: string, completed: boolean) => void;
|
onToggle: (id: string, completed: boolean) => void;
|
||||||
onUpdateTitle: (id: string, title: string) => void;
|
onUpdateTitle: (id: string, title: string) => void;
|
||||||
onAddSubtask: (title: string, parentId: string) => void;
|
onAddSubtask: (title: string, parentId: string) => void;
|
||||||
onContextMenu: (x: number, y: number, task: Task) => 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,
|
task,
|
||||||
|
depth = 0,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -62,6 +64,7 @@ function TaskItem({
|
|||||||
const subInputRef = useRef<HTMLInputElement>(null);
|
const subInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const completedChildren = task.children?.filter((c) => c.completed).length || 0;
|
const completedChildren = task.children?.filter((c) => c.completed).length || 0;
|
||||||
|
const totalChildren = task.children?.length || 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTempTitle(task.title);
|
setTempTitle(task.title);
|
||||||
@@ -115,17 +118,50 @@ function TaskItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
||||||
onClick={() => onSelect(task)}
|
onClick={() => onSelect(task)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
onContextMenu(e.clientX, e.clientY, task);
|
onContextMenu(e.clientX, e.clientY, task);
|
||||||
}}
|
}}
|
||||||
id={`task-${task.id}`}
|
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 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn"
|
||||||
|
style={{ width: 18, height: 18, flexShrink: 0, padding: 0, opacity: 0.6 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded((p) => !p);
|
||||||
|
}}
|
||||||
|
title="Expand/Collapse"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : depth > 0 ? (
|
||||||
|
<span style={{ width: 12, flexShrink: 0 }} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Check button */}
|
||||||
<button
|
<button
|
||||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -134,8 +170,10 @@ function TaskItem({
|
|||||||
}}
|
}}
|
||||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
type="button"
|
type="button"
|
||||||
|
style={{ width: depth > 0 ? 17 : 19, height: depth > 0 ? 17 : 19, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Title and Meta */}
|
||||||
<div className="task-body">
|
<div className="task-body">
|
||||||
{editingTitle ? (
|
{editingTitle ? (
|
||||||
<input
|
<input
|
||||||
@@ -152,7 +190,7 @@ function TaskItem({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ padding: "2px 6px", fontSize: 14, fontWeight: 500, height: 26, width: "100%" }}
|
style={{ padding: "2px 6px", fontSize: depth > 0 ? 13 : 14, fontWeight: 500, height: 26, width: "100%" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -162,7 +200,7 @@ function TaskItem({
|
|||||||
setEditingTitle(true);
|
setEditingTitle(true);
|
||||||
}}
|
}}
|
||||||
title="Double click to edit"
|
title="Double click to edit"
|
||||||
style={{ cursor: "text" }}
|
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
|
||||||
>
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
</div>
|
</div>
|
||||||
@@ -184,32 +222,20 @@ function TaskItem({
|
|||||||
{formatDate(task.dueDate)}
|
{formatDate(task.dueDate)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.children && task.children.length > 0 && (
|
{totalChildren > 0 && (
|
||||||
<span
|
<span
|
||||||
className="task-sub-count"
|
className="task-sub-count"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExpanded((p) => !p);
|
setExpanded((p) => !p);
|
||||||
}}
|
}}
|
||||||
title="Toggle subtasks"
|
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
<svg
|
{completedChildren}/{totalChildren}
|
||||||
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>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Add Subtask Button */}
|
{/* Quick Inline Add Subtask button */}
|
||||||
<button
|
<button
|
||||||
className="badge badge-neutral"
|
className="badge badge-neutral"
|
||||||
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
|
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
|
||||||
@@ -225,7 +251,7 @@ function TaskItem({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.note && !task.completed && (
|
{task.note && !task.completed && depth === 0 && (
|
||||||
<div className="task-note-preview">{task.note.replace(/[#*`]/g, "").slice(0, 80)}</div>
|
<div className="task-note-preview">{task.note.replace(/[#*`]/g, "").slice(0, 80)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +259,7 @@ function TaskItem({
|
|||||||
{/* Quick Edit icon on hover */}
|
{/* Quick Edit icon on hover */}
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
style={{ width: 22, height: 22, opacity: 0.4, flexShrink: 0 }}
|
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditingTitle(true);
|
setEditingTitle(true);
|
||||||
@@ -247,125 +273,51 @@ function TaskItem({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-tasks List */}
|
{/* Recursive Children (N-Depth Sub-tasks) */}
|
||||||
{((task.children && task.children.length > 0) || addingSubtask) && expanded && (
|
{totalChildren > 0 && expanded && (
|
||||||
<div className="subtask-list" style={{ paddingLeft: 38 }}>
|
<div className="subtask-recursive-tree">
|
||||||
{task.children?.map((child) => (
|
{task.children.map((child) => (
|
||||||
<SubTaskItem
|
<RecursiveTaskItem
|
||||||
key={child.id}
|
key={child.id}
|
||||||
child={child}
|
task={child}
|
||||||
onToggle={onToggle}
|
depth={depth + 1}
|
||||||
|
isSelected={isSelected}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
onToggle={onToggle}
|
||||||
onUpdateTitle={onUpdateTitle}
|
onUpdateTitle={onUpdateTitle}
|
||||||
|
onAddSubtask={onAddSubtask}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Inline Add Subtask input */}
|
|
||||||
{addingSubtask && (
|
|
||||||
<form onSubmit={handleCreateSubtask} style={{ display: "flex", gap: 6, padding: "4px 0" }}>
|
|
||||||
<span style={{ color: "var(--accent)", fontSize: 14, lineHeight: "24px" }}>↳</span>
|
|
||||||
<input
|
|
||||||
ref={subInputRef}
|
|
||||||
className="form-input"
|
|
||||||
placeholder={t("addSubtaskPlaceholder")}
|
|
||||||
value={subtaskInput}
|
|
||||||
onChange={(e) => 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 }}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-primary btn-sm" type="submit" style={{ padding: "2px 8px", fontSize: 11 }}>
|
|
||||||
{t("add")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-task Item with Inline Rename
|
{/* Inline Add Subtask form for this node */}
|
||||||
function SubTaskItem({
|
{addingSubtask && (
|
||||||
child,
|
<form
|
||||||
onToggle,
|
onSubmit={handleCreateSubtask}
|
||||||
onSelect,
|
style={{ display: "flex", gap: 6, padding: "4px 0 4px 32px" }}
|
||||||
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<HTMLInputElement>(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 (
|
|
||||||
<div
|
|
||||||
className={`subtask-item${child.completed ? " completed" : ""}`}
|
|
||||||
onClick={() => onSelect(child)}
|
|
||||||
id={`subtask-${child.id}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className={`subtask-check-btn${child.completed ? " checked" : ""}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggle(child.id, !child.completed);
|
|
||||||
}}
|
|
||||||
aria-label={child.completed ? "Mark incomplete" : "Mark complete"}
|
|
||||||
type="button"
|
|
||||||
/>
|
|
||||||
{editing ? (
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
className="form-input"
|
|
||||||
value={temp}
|
|
||||||
onChange={(e) => setTemp(e.target.value)}
|
|
||||||
onBlur={handleSave}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleSave();
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setTemp(child.title);
|
|
||||||
setEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ padding: "1px 6px", fontSize: 12.5, height: 22, flex: 1 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="subtask-title"
|
|
||||||
onDoubleClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditing(true);
|
|
||||||
}}
|
|
||||||
title="Double click to edit"
|
|
||||||
style={{ cursor: "text" }}
|
|
||||||
>
|
>
|
||||||
{child.title}
|
<span style={{ color: "var(--accent)", fontSize: 13, lineHeight: "26px" }}>↳</span>
|
||||||
</span>
|
<input
|
||||||
|
ref={subInputRef}
|
||||||
|
className="form-input"
|
||||||
|
placeholder={t("addSubtaskPlaceholder")}
|
||||||
|
value={subtaskInput}
|
||||||
|
onChange={(e) => 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 }}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary btn-sm" type="submit" style={{ padding: "2px 8px", fontSize: 11 }}>
|
||||||
|
{t("add")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -469,27 +421,12 @@ export function TaskList({
|
|||||||
body: JSON.stringify({ completed }),
|
body: JSON.stringify({ completed }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
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();
|
onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TaskList] handleToggle failed", err);
|
console.error("[TaskList] handleToggle failed", err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDemo, onDemoToggleTask, showCompleted, onRefresh, setTasks]
|
[isDemo, onDemoToggleTask, onRefresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddTask = async (e: React.FormEvent) => {
|
const handleAddTask = async (e: React.FormEvent) => {
|
||||||
@@ -533,10 +470,6 @@ export function TaskList({
|
|||||||
body: JSON.stringify({ title, listId, parentId }),
|
body: JSON.stringify({ title, listId, parentId }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const sub = await res.json();
|
|
||||||
setTasks((prev) =>
|
|
||||||
prev.map((t) => (t.id === parentId ? { ...t, children: [...(t.children || []), sub] } : t))
|
|
||||||
);
|
|
||||||
onRefresh();
|
onRefresh();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -565,6 +498,7 @@ export function TaskList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter top-level vs completed
|
||||||
const incompleteTasks = tasks.filter((t) => !t.completed);
|
const incompleteTasks = tasks.filter((t) => !t.completed);
|
||||||
const completedTasks = tasks.filter((t) => t.completed);
|
const completedTasks = tasks.filter((t) => t.completed);
|
||||||
|
|
||||||
@@ -572,7 +506,7 @@ export function TaskList({
|
|||||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="main-header">
|
<div className="main-header">
|
||||||
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu">
|
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu" type="button">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<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>
|
||||||
@@ -629,6 +563,7 @@ export function TaskList({
|
|||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
onClick={onToggleCompleted}
|
onClick={onToggleCompleted}
|
||||||
title={showCompleted ? t("hideDone") : t("showDone")}
|
title={showCompleted ? t("hideDone") : t("showDone")}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
@@ -638,8 +573,16 @@ export function TaskList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task list */}
|
{/* Task list container with background context menu handler */}
|
||||||
<div className="task-list-container">
|
<div
|
||||||
|
className="task-list-container"
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
// If clicked directly on empty container area, prevent native browser menu
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
||||||
)}
|
)}
|
||||||
@@ -651,10 +594,13 @@ export function TaskList({
|
|||||||
<p>{t("noTasksYet")}</p>
|
<p>{t("noTasksYet")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Incomplete Tasks Recursive Tree */}
|
||||||
{incompleteTasks.map((task) => (
|
{incompleteTasks.map((task) => (
|
||||||
<TaskItem
|
<RecursiveTaskItem
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
depth={0}
|
||||||
isSelected={selectedTaskId === task.id}
|
isSelected={selectedTaskId === task.id}
|
||||||
onSelect={onTaskSelect}
|
onSelect={onTaskSelect}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
@@ -663,11 +609,13 @@ export function TaskList({
|
|||||||
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Completed Tasks Recursive Tree */}
|
||||||
{showCompleted && completedTasks.length > 0 && (
|
{showCompleted && completedTasks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 20px 4px",
|
padding: "16px 20px 6px",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "var(--text-tertiary)",
|
color: "var(--text-tertiary)",
|
||||||
@@ -678,9 +626,10 @@ export function TaskList({
|
|||||||
{t("completedSection")} ({completedTasks.length})
|
{t("completedSection")} ({completedTasks.length})
|
||||||
</div>
|
</div>
|
||||||
{completedTasks.map((task) => (
|
{completedTasks.map((task) => (
|
||||||
<TaskItem
|
<RecursiveTaskItem
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
depth={0}
|
||||||
isSelected={selectedTaskId === task.id}
|
isSelected={selectedTaskId === task.id}
|
||||||
onSelect={onTaskSelect}
|
onSelect={onTaskSelect}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
@@ -693,7 +642,7 @@ export function TaskList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context Menu on Task */}
|
{/* Full-featured Context Menu on ANY Task at ANY Depth */}
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={contextMenu.x}
|
x={contextMenu.x}
|
||||||
@@ -737,7 +686,7 @@ export function TaskList({
|
|||||||
|
|
||||||
{/* Add task bar */}
|
{/* Add task bar */}
|
||||||
<div className="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" type="button" />
|
||||||
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
+72
-20
@@ -20,7 +20,7 @@ export interface MockTask {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
children: MockTask[];
|
children: MockTask[];
|
||||||
tags: { tag: { id: string; name: string; color: string } }[];
|
tags?: { tag: { id: string; name: string; color: string } }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY_LISTS = "checkflow_demo_lists";
|
const STORAGE_KEY_LISTS = "checkflow_demo_lists";
|
||||||
@@ -52,7 +52,7 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
listId: "list-1",
|
listId: "list-1",
|
||||||
parentId: "task-1",
|
parentId: "task-1",
|
||||||
title: "Configure .env environment variables",
|
title: "Configure .env environment variables",
|
||||||
note: null,
|
note: "Database URL, NextAuth secret, and port settings",
|
||||||
completed: true,
|
completed: true,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
@@ -60,7 +60,24 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
children: [],
|
children: [
|
||||||
|
{
|
||||||
|
id: "sub-1-1-1",
|
||||||
|
listId: "list-1",
|
||||||
|
parentId: "sub-1-1",
|
||||||
|
title: "Database connection string verification",
|
||||||
|
note: null,
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
dueDate: null,
|
||||||
|
priority: 2,
|
||||||
|
sortOrder: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
tags: [],
|
tags: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -87,7 +104,7 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
listId: "list-1",
|
listId: "list-1",
|
||||||
parentId: null,
|
parentId: null,
|
||||||
title: "Import existing tasks from TickTick",
|
title: "Import existing tasks from TickTick",
|
||||||
note: "1. Export CSV/ICS in TickTick Settings\n2. Click 'Import Tasks' in CheckFlow Sidebar\n3. Select target project",
|
note: "1. Export CSV/ICS in TickTick Settings\n2. Click user profile at bottom-left\n3. Select 'Import Tasks'",
|
||||||
completed: false,
|
completed: false,
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
@@ -98,22 +115,6 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
children: [],
|
children: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "task-3",
|
|
||||||
listId: "list-2",
|
|
||||||
parentId: null,
|
|
||||||
title: "Install CheckFlow PWA on mobile home screen",
|
|
||||||
note: "Open in Chrome/Samsung Internet -> Menu -> **Install app** or **Add to Home Screen**",
|
|
||||||
completed: true,
|
|
||||||
completedAt: new Date().toISOString(),
|
|
||||||
dueDate: new Date().toISOString(),
|
|
||||||
priority: 1,
|
|
||||||
sortOrder: 0,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
children: [],
|
|
||||||
tags: [],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getDemoStore() {
|
export function getDemoStore() {
|
||||||
@@ -147,4 +148,55 @@ export function saveDemoStore(lists: MockList[], tasks: MockTask[]) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save demo storage", e);
|
console.error("Failed to save demo storage", e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive tree helpers for N-depth sub-tasks
|
||||||
|
export function updateTaskInTree(tree: MockTask[], updated: MockTask): MockTask[] {
|
||||||
|
return tree.map((node) => {
|
||||||
|
if (node.id === updated.id) {
|
||||||
|
return { ...updated, children: updated.children || node.children || [] };
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return { ...node, children: updateTaskInTree(node.children, updated) };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask[] {
|
||||||
|
return tree
|
||||||
|
.filter((node) => node.id !== idToDelete)
|
||||||
|
.map((node) => {
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return { ...node, children: deleteTaskInTree(node.children, idToDelete) };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTaskToTree(tree: MockTask[], parentId: string | null, newTask: MockTask): MockTask[] {
|
||||||
|
if (!parentId) {
|
||||||
|
return [...tree, newTask];
|
||||||
|
}
|
||||||
|
return tree.map((node) => {
|
||||||
|
if (node.id === parentId) {
|
||||||
|
const currentChildren = node.children || [];
|
||||||
|
return { ...node, children: [...currentChildren, newTask] };
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return { ...node, children: addTaskToTree(node.children, parentId, newTask) };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
|
||||||
|
for (const node of tree) {
|
||||||
|
if (node.id === id) return node;
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
const found = findTaskInTree(node.children, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user