Fix: Real-time 2-level subtask sync between panels and inline title editing for list and tasks

This commit is contained in:
2026-08-20 14:27:57 +09:00
parent 0dfd7991ee
commit ed8c9187a7
29 changed files with 1811 additions and 96 deletions
+36 -50
View File
@@ -9,7 +9,7 @@
사용자의 핵심 요구사항: 사용자의 핵심 요구사항:
1. **TickTick 스타일 To-Do 리스트 독립 웹앱**: 1. **TickTick 스타일 To-Do 리스트 독립 웹앱**:
- 체크리스트는 **메인 - 하위(Sub-task)** 계층 구조를 갖춤. - 체크리스트는 **메인 - 하위(Sub-task)** 계층 구조를 갖춤.
- 메인 체크리스트에 귀속된 **넓직한 메모장(Markdown 노트)** 제공. - **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 작성한 마크다운이 실제 웹 문서처럼 미려한 GUI 형태로 렌더링(Preview & Live Checkbox)되어야 함.
2. **셀프호스팅 & 프라이버시 중심 멀티유저**: 2. **셀프호스팅 & 프라이버시 중심 멀티유저**:
- SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼. - SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼.
- Docker 배포 지원 (PostgreSQL 포함). - Docker 배포 지원 (PostgreSQL 포함).
@@ -20,66 +20,52 @@
- 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원. - 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원.
5. **디자인 시스템**: 5. **디자인 시스템**:
- TickTick 급의 심플함 + OneUI / Material You / Vercel 스타일의 직관적이고 미려한 UI. - TickTick 급의 심플함 + OneUI / Material You / Vercel 스타일의 직관적이고 미려한 UI.
- 3-Panel 반응형 레이아웃 (사이드바 - 태스크 목록 - 넓은 상세 메모 패널). - 3-Panel 반응형 레이아웃 (사이드바 - 태스크 목록 - 와이드 상세 메모 패널).
6. **추가 요구사항 (Phase 2)**: 6. **추가 요구사항 (Phase 2 & Phase 3)**:
- **다국어 (i18n)**: 영어(기본) → 한국어 → 일본어 순서 지원. - **다국어 (i18n)**: 영어(기본) → 한국어 → 일본어 순서 지원 (커스텀 팝오버 UI 탑재).
- **테마 모드**: 시스템(System) / 라이트(Light) / 다크(Dark) 3-Way 토글. - **테마 모드**: 시스템(System) / 라이트(Light) / 다크(Dark) 3-Way 토글.
- **DB 없는 데모/미리보기 모드**: 로컬 환경에서 DB 구동 없이도 UI/UX 및 모든 기능을 즉시 시연 및 확인할 수 있는 Demo 모드 지원. - **DB 없는 데모/미리보기 모드**: 로컬 환경에서 DB 구동 없이도 UI/UX 및 모든 기능을 즉시 시연 및 확인할 수 있는 Demo 모드 (`/demo`, LocalStorage 연동).
- **인프라**: Nginx Proxy Manager (NPM)로 역방향 프록시할 예정이므로 자체 SSL/Nginx 없이 포트 3000 컨테이너로 동작. - **인프라**: Nginx Proxy Manager (NPM)로 역방향 프록시할 예정이므로 자체 SSL/Nginx 없이 포트 3000 컨테이너로 동작.
--- ---
## 2. 기술 스택 (Tech Stack) ## 2. 주요 컴포넌트 구조
- **Frontend**: Next.js 16 (App Router), TypeScript, Vanilla CSS (TailwindCSS 지양, 고품질 CSS Variable 디자인 시스템) ```
- **Backend / API**: Next.js API Routes (Route Handlers) src/
- **ORM / DB**: Prisma ORM, PostgreSQL (Production/Docker) + Local/Demo In-Memory/LocalStorage Fallback ├── app/
- **Auth**: NextAuth.js (Credentials Provider, JWT 전략) │ ├── layout.tsx / page.tsx / providers.tsx
- **DAV Server**: `/api/dav/[...path]` (iCalendar VTODO 표준 구현, Basic Auth 지원) │ ├── demo/page.tsx ← DB 연결 없이 LocalStorage 기반으로 구동되는 완전한 데모 페이지
- **PWA**: Service Worker (`public/sw.js`), Web App Manifest (`public/manifest.json`) │ ├── login/page.tsx / register/page.tsx
- **Container**: `Dockerfile` (Node 20 Alpine Standalone), `docker-compose.yml` (PostgreSQL 16 + Auto migration + App) │ └── api/ (auth, lists, tasks, import, dav)
├── components/
│ ├── layout/
│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너
│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어 셀렉터, 테마 토글, 유저 메뉴
│ ├── tasks/
│ │ ├── TaskList.tsx ← 중앙 체크리스트 (하위 태스크 아코디언, 빠른 추가)
│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (컴팩트 메타바 + 와이드 메모장 + 하단 하위할일)
│ │ └── MarkdownNoteEditor.tsx ← 👁️ Preview / ✏️ Edit 탭, 마크다운 툴바, 인터랙티브 체크박스
│ └── ui/
│ └── LanguageSelector.tsx ← OneUI/Vercel 스타일 커스텀 글래스모피즘 언어 팝오버
└── lib/
├── i18n/ (en, ko, ja 사전 및 useI18n 훅)
├── mockData.ts ← Demo 모드용 LocalStorage Store
├── auth.ts / prisma.ts
```
--- ---
## 3. 주요 결정 및 트러블슈팅 히스토리 ## 3. 롤백 포인트 (Checkpoints)
### [버그 1] Next.js 16 `middleware` Deprecation - **`checkpoint-v1.0`** (`backup-v1.0`): 기본 Next.js 16 + Prisma + NextAuth + CardDAV 기반 구조.
- **현상**: Next.js 16 최신 버전에서 `src/middleware.ts` 빌드 시 오류 발생. - **`checkpoint-v2.0`**: i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재.
- **해결**: Next.js 16 스펙에 맞춰 `src/proxy.ts`로 마이그레이션 (`export async function proxy(...)`). - **`checkpoint-v2.1`**: **(현재 최신)** 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장 레이아웃, 커스텀 LanguageSelector 팝오버 적용.
### [버그 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) ## 4. 향후 작업 가이드 (For Next Agents)
- **Git Tag `checkpoint-v1.0` / Branch `backup-v1.0`**: 기본 Full-Stack 구조 및 버그픽스 완료 시점. 1. **메모장 우선 원칙**: 메모장은 항상 상세 패널의 메인 공간(`flex: 1`)을 차지하도록 유지할 것.
- 언제든 문제가 발생하면 `git checkout checkpoint-v1.0`으로 복구 가능. 2. **마크다운 상호작용**: Preview 모드에서 `- [ ]` 체크박스 클릭 시 원본 텍스트의 체크 상태가 토글되는 기능을 유지할 것.
3. **i18n & 테마 무결성**: 새로운 텍스트는 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것.
---
## 5. 향후 작업 가이드 (For Next Agents)
1. **디자인 무결성**: 바닐라 CSS 변수(`var(--accent)`, `var(--bg-primary)` 등)를 준수하고 모바일 반응형(768px 이하)을 항상 고려할 것.
2. **다국어(i18n)**: 텍스트 추가 시 `src/lib/i18n`의 en/ko/ja 사전에 키를 반드시 함께 등록할 것.
3. **데모 모드 지원**: DB 연결 불가 상태에서도 사용자가 UI를 체험할 수 있는 환경을 유지할 것.
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0785)https://js.stripe.com/v3/controller-with-preconnect-5db5e83329f7b3486c3557ee2790d9cb.html#__shared_params__[version]=v3&__shared_params__[light_experiment_assignments]=%7B%22token%22%3A%22feb38f29-c9a3-42da-acfd-c19172cfb719%22%2C%22assignments%22%3A%7B%22api_fastpath_habanero_v1%22%3A%22control%22%7D%7D&apiKey=pk_live_J95N9bhJGECTDWXInMXdcspN&stripeJsId=feb38f29-c9a3-42da-acfd-c19172cfb719&stripeObjId=sobj-f89fa765-6e66-47f7-83fe-a378314d43b5&firstStripeInstanceCreatedLatency=25&controllerCount=1&isCheckout=false&stripeJsLoadTime=1787194929312.7&manualBrowserDeprecationRollout=false&mids[guid]=NA&mids[muid]=NA&mids[sid]=NA&referrer=https%3A%2F%2Fticktick.com%2Fwebapp%23p%2F6a6c58fb8f087e90bd8da7a4%2Ftasks%2F6a6c625491d8cd0680e74606&controllerId=__privateStripeController7581 -->
<html lang="ko"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><link rel="preconnect" href="https://api.stripe.com/" crossorigin=""><link rel="preconnect" href="https://merchant-ui-api.stripe.com/" crossorigin=""><meta http-equiv="origin-trial" content="AtD0WrnMwAPI4nWWCvreE+vpgPVz45SO/1fG1IZRNpBsdWZOZN6SKr0ynC11KuzrvT903WrEU+N9Ik/RpiCRTAEAAABbeyJvcmlnaW4iOiJodHRwczovL3N0cmlwZS5jb206NDQzIiwiZmVhdHVyZSI6IlRwY2QiLCJleHBpcnkiOjE3MzUzNDM5OTksImlzU3ViZG9tYWluIjp0cnVlfQ=="><script defer="defer" src="./shared-7577eac54e88cecc1d82b6c392bfe2cf.js.다운로드"></script><script defer="defer" src="./controller-with-preconnect-f5313d6dc0cd5231b1bbefb30935e946.js.다운로드"></script></head><body></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0295)https://m.stripe.network/inner.html#url=https%3A%2F%2Fticktick.com%2Fwebapp%23p%2F6a6c58fb8f087e90bd8da7a4%2Ftasks%2F6a6c625491d8cd0680e74606&title=%EC%9B%8C%ED%99%80%20-%20TickTick&referrer=https%3A%2F%2Fticktick.com%2Fwebapp&muid=NA&sid=NA&version=6&preview=false&__shared_params__[version]=v3 -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title>StripeM-Inner</title></head><body><script>!function(){var e=document.createElement("script");e.defer=!0,e.src="out-4.5.45.js",e.onload=function(){var e;window.StripeM&&(e=window.location.hash,/ping=false/.test(e)||(e=(e=e.match(/version=(4|6)/))?e[1]:"4",window.StripeM.p({t:!0,v:e})),e=function(e){if(window.opener||window.parent||window)try{var i=((t=JSON.parse(e.data)).message||t).action,t=t.message?t.message.payload:t;switch(i){case"ping":window.StripeM.p({t:!0,o:{muid:t.muid,sid:t.sid,referrer:t.referrer,url:t.url,title:t.title,v2:t.v2},v:t.version||"4"});break;case"track":if(!t.source||!t.data)return;window.StripeM.b({muid:t.muid,sid:t.sid,url:t.url,source:t.source,data:t.data},t.version||"4")}}catch(e){}},window.addEventListener?window.addEventListener("message",e,!1):window.attachEvent("onMessage",e))},document.body.appendChild(e)}()</script><script defer="" src="./out-4.5.45.js.다운로드"></script></body></html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(){"use strict";var e="https://m.stripe.network",n=window.location.hash,t=/preview=true/.test(n)?"inner-preview.html":"inner.html",o=document.createElement("iframe");o.src="".concat(e,"/").concat(t).concat(n);var i=function(n){if(n.origin===e){var t=window.opener||window.parent||window;if(!t)return;t.postMessage(n.data,"*")}else o.contentWindow.postMessage(n.data,"*")};window.addEventListener?window.addEventListener("message",i,!1):window.attachEvent("onMessage",i),document.body&&document.body.appendChild(o)}();
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0330)https://js.stripe.com/v3/m-outer-3437aaddcdf6922d623e172c2d6f9278.html#url=https%3A%2F%2Fticktick.com%2Fwebapp%23p%2F6a6c58fb8f087e90bd8da7a4%2Ftasks%2F6a6c625491d8cd0680e74606&title=%EC%9B%8C%ED%99%80%20-%20TickTick&referrer=https%3A%2F%2Fticktick.com%2Fwebapp&muid=NA&sid=NA&version=6&preview=false&__shared_params__[version]=v3 -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><script defer="defer" src="./m-outer-15a2b40a058ddff1cffdb63779fe3de1.js.다운로드"></script></head><body><iframe src="./inner.html"></iframe></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+183 -17
View File
@@ -30,7 +30,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
} }
}, [isDemo]); }, [isDemo]);
// Demo tasks filter // Demo tasks filter & reconstruct hierarchical structure
useEffect(() => { useEffect(() => {
if (isDemo && selectedListId) { if (isDemo && selectedListId) {
const store = getDemoStore(); const store = getDemoStore();
@@ -47,9 +47,28 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
setSelectedTask(task); setSelectedTask(task);
}, []); }, []);
// Update a task and sync with main list and selected panel
const handleTaskUpdate = useCallback((updated: Task) => { const handleTaskUpdate = useCallback((updated: Task) => {
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t))); setTasks((prev) =>
setSelectedTask(updated); 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) => {
if (prev && prev.id === updated.id) {
return { ...updated, children: updated.children || prev.children || [] };
}
return prev;
});
}, []); }, []);
const handleListSelect = useCallback((id: string) => { const handleListSelect = useCallback((id: string) => {
@@ -58,6 +77,34 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
setSidebarOpen(false); setSidebarOpen(false);
}, []); }, []);
// Update List Name (Inline edit on header or sidebar)
const handleUpdateListName = async (id: string, newName: string) => {
const trimmed = newName.trim();
if (!trimmed) return;
if (isDemo) {
const store = getDemoStore();
const newLists = store.lists.map((l) => (l.id === id ? { ...l, name: trimmed } : l));
saveDemoStore(newLists, store.tasks);
setLists(newLists);
return;
}
try {
const res = await fetch(`/api/lists/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (res.ok) {
const updated = await res.json();
setLists((prev) => prev.map((l) => (l.id === id ? { ...l, name: updated.name } : l)));
}
} catch (err) {
console.error("Failed to update list name", err);
}
};
// Demo Handlers // Demo Handlers
const handleDemoCreateList = (name: string, color: string) => { const handleDemoCreateList = (name: string, color: string) => {
const newList: MockList = { const newList: MockList = {
@@ -85,11 +132,11 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
refresh(); refresh();
}; };
const handleDemoAddTask = (title: string, listId: string) => { const handleDemoAddTask = (title: string, listId: string, parentId: string | null = null) => {
const newTask: Task = { const newTask: Task = {
id: "demo-task-" + Date.now(), id: "demo-task-" + Date.now(),
listId, listId,
parentId: null, parentId: parentId || null,
title, title,
note: null, note: null,
completed: false, completed: false,
@@ -103,9 +150,29 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
tags: [], tags: [],
}; };
const store = getDemoStore(); const store = getDemoStore();
const newTasks = [...store.tasks, newTask as MockTask];
saveDemoStore(store.lists, newTasks); if (parentId) {
setTasks((prev) => [...prev, newTask]); // 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))
);
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();
}; };
@@ -115,12 +182,49 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
if (t.id === id) { if (t.id === id) {
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null }; return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
} }
return { if (t.children && t.children.some((c) => c.id === id)) {
...t, const newChildren = t.children.map((c) =>
children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [], c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c
}; );
return { ...t, children: newChildren };
}
return t;
}); });
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
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
);
}
}
refresh(); refresh();
}; };
@@ -128,19 +232,79 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const store = getDemoStore(); const store = getDemoStore();
const newTasks = store.tasks.map((t) => (t.id === updated.id ? (updated as MockTask) : t)); const newTasks = store.tasks.map((t) => (t.id === updated.id ? (updated as MockTask) : t));
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t))); handleTaskUpdate(updated);
setSelectedTask(updated);
}; };
const handleDemoDeleteTask = (id: string) => { const handleDemoDeleteTask = (id: string) => {
const store = getDemoStore(); const store = getDemoStore();
const newTasks = store.tasks.filter((t) => t.id !== id); // 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;
});
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id)); setTasks((prev) =>
setSelectedTask(null); prev
.filter((t) => t.id !== id)
.map((t) => (t.children ? { ...t, children: t.children.filter((c) => c.id !== id) } : t))
);
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(); refresh();
}; };
// Inline update task title from main list
const handleUpdateTaskTitle = async (taskId: string, newTitle: string) => {
const trimmed = newTitle.trim();
if (!trimmed) return;
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));
}
return;
}
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: trimmed }),
});
if (res.ok) {
const updated = await res.json();
handleTaskUpdate(updated);
}
} catch (err) {
console.error("Failed to update task title", err);
}
};
return ( return (
<div className="app-layout"> <div className="app-layout">
{/* Mobile overlay */} {/* Mobile overlay */}
@@ -182,6 +346,8 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
isDemo={isDemo} isDemo={isDemo}
onDemoAddTask={handleDemoAddTask} onDemoAddTask={handleDemoAddTask}
onDemoToggleTask={handleDemoToggleTask} onDemoToggleTask={handleDemoToggleTask}
onUpdateTaskTitle={handleUpdateTaskTitle}
onUpdateListName={handleUpdateListName}
/> />
</div> </div>
+357 -29
View File
@@ -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";
@@ -34,13 +34,49 @@ interface TaskItemProps {
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;
onAddSubtask: (title: string, parentId: string) => void;
onDeleteTask?: (id: string) => void;
} }
function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) { function TaskItem({
task,
isSelected,
onSelect,
onToggle,
onUpdateTitle,
onAddSubtask,
}: TaskItemProps) {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [editingTitle, setEditingTitle] = useState(false);
const [tempTitle, setTempTitle] = useState(task.title);
const [addingSubtask, setAddingSubtask] = useState(false);
const [subtaskInput, setSubtaskInput] = useState("");
const editInputRef = 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;
useEffect(() => {
setTempTitle(task.title);
}, [task.title]);
useEffect(() => {
if (editingTitle) {
editInputRef.current?.focus();
editInputRef.current?.select();
}
}, [editingTitle]);
useEffect(() => {
if (addingSubtask) {
subInputRef.current?.focus();
}
}, [addingSubtask]);
const formatDate = (d: string | null) => { const formatDate = (d: string | null) => {
if (!d) return null; if (!d) return null;
const date = new Date(d); const date = new Date(d);
@@ -54,12 +90,34 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")]; const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
const handleSaveTitle = () => {
const trimmed = tempTitle.trim();
if (trimmed && trimmed !== task.title) {
onUpdateTitle(task.id, trimmed);
} else {
setTempTitle(task.title);
}
setEditingTitle(false);
};
const handleCreateSubtask = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = subtaskInput.trim();
if (trimmed) {
onAddSubtask(trimmed, task.id);
setSubtaskInput("");
setAddingSubtask(false);
setExpanded(true);
}
};
return ( return (
<div> <div style={{ 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)}
id={`task-${task.id}`} id={`task-${task.id}`}
style={{ group: "item" } as React.CSSProperties}
> >
<button <button
className={`task-check-btn${task.completed ? " checked" : ""}`} className={`task-check-btn${task.completed ? " checked" : ""}`}
@@ -68,9 +126,41 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
onToggle(task.id, !task.completed); onToggle(task.id, !task.completed);
}} }}
aria-label={task.completed ? "Mark incomplete" : "Mark complete"} aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
type="button"
/> />
<div className="task-body"> <div className="task-body">
<div className="task-title">{task.title}</div> {editingTitle ? (
<input
ref={editInputRef}
className="form-input"
value={tempTitle}
onChange={(e) => setTempTitle(e.target.value)}
onBlur={handleSaveTitle}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveTitle();
if (e.key === "Escape") {
setTempTitle(task.title);
setEditingTitle(false);
}
}}
onClick={(e) => e.stopPropagation()}
style={{ padding: "2px 6px", fontSize: 14, fontWeight: 500, height: 26, width: "100%" }}
/>
) : (
<div
className="task-title"
onDoubleClick={(e) => {
e.stopPropagation();
setEditingTitle(true);
}}
title="Double click to edit"
style={{ cursor: "text" }}
>
{task.title}
</div>
)}
<div className="task-meta"> <div className="task-meta">
{task.priority > 0 && ( {task.priority > 0 && (
<div <div
@@ -94,6 +184,8 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
e.stopPropagation(); e.stopPropagation();
setExpanded((p) => !p); setExpanded((p) => !p);
}} }}
title="Toggle subtasks"
style={{ cursor: "pointer" }}
> >
<svg <svg
width="10" width="10"
@@ -109,40 +201,169 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
{completedChildren}/{task.children.length} {completedChildren}/{task.children.length}
</span> </span>
)} )}
{/* Quick Add Subtask Button */}
<button
className="badge badge-neutral"
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
onClick={(e) => {
e.stopPropagation();
setAddingSubtask(true);
setExpanded(true);
}}
title="Add subtask"
type="button"
>
+ {t("subtasks")}
</button>
</div> </div>
{task.note && !task.completed && ( {task.note && !task.completed && (
<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>
{/* Quick Edit icon on hover */}
<button
className="icon-btn"
style={{ width: 22, height: 22, opacity: 0.4, flexShrink: 0 }}
onClick={(e) => {
e.stopPropagation();
setEditingTitle(true);
}}
title="Edit title"
type="button"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
</div> </div>
{/* Sub-tasks */} {/* Sub-tasks List */}
{task.children && task.children.length > 0 && expanded && ( {((task.children && task.children.length > 0) || addingSubtask) && expanded && (
<div className="subtask-list"> <div className="subtask-list" style={{ paddingLeft: 38 }}>
{task.children.map((child) => ( {task.children?.map((child) => (
<div <SubTaskItem
key={child.id} key={child.id}
className={`subtask-item${child.completed ? " completed" : ""}`} child={child}
onClick={() => onSelect(child)} onToggle={onToggle}
id={`subtask-${child.id}`} onSelect={onSelect}
> onUpdateTitle={onUpdateTitle}
<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"}
/>
<span className="subtask-title">{child.title}</span>
</div>
))} ))}
{/* 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> </div>
); );
} }
// 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<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()}
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>
)}
</div>
);
}
interface Props { interface Props {
user: User; user: User;
listId: string | null; listId: string | null;
@@ -156,8 +377,10 @@ interface Props {
onMenuOpen: () => void; onMenuOpen: () => void;
onRefresh: () => void; onRefresh: () => void;
isDemo?: boolean; isDemo?: boolean;
onDemoAddTask?: (title: string, listId: string) => void; onDemoAddTask?: (title: string, listId: string, parentId?: string | null) => void;
onDemoToggleTask?: (id: string, completed: boolean) => void; onDemoToggleTask?: (id: string, completed: boolean) => void;
onUpdateTaskTitle?: (id: string, title: string) => void;
onUpdateListName?: (id: string, name: string) => void;
} }
export function TaskList({ export function TaskList({
@@ -175,13 +398,30 @@ export function TaskList({
isDemo = false, isDemo = false,
onDemoAddTask, onDemoAddTask,
onDemoToggleTask, onDemoToggleTask,
onUpdateTaskTitle,
onUpdateListName,
}: Props) { }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [newTaskTitle, setNewTaskTitle] = useState(""); const [newTaskTitle, setNewTaskTitle] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editingHeader, setEditingHeader] = useState(false);
const [headerTitle, setHeaderTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const headerInputRef = useRef<HTMLInputElement>(null);
const currentList = lists.find((l) => l.id === listId); const currentList = lists.find((l) => l.id === listId);
useEffect(() => {
if (currentList) setHeaderTitle(currentList.name);
}, [currentList]);
useEffect(() => {
if (editingHeader) {
headerInputRef.current?.focus();
headerInputRef.current?.select();
}
}, [editingHeader]);
const fetchTasks = useCallback(async () => { const fetchTasks = useCallback(async () => {
if (!listId || isDemo) return; if (!listId || isDemo) return;
setLoading(true); setLoading(true);
@@ -226,7 +466,13 @@ export function TaskList({
prev prev
.map((t) => { .map((t) => {
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt }; if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
return { ...t, children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [] }; 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) .filter((t) => showCompleted || !t.completed)
); );
@@ -244,7 +490,7 @@ export function TaskList({
if (!title || !listId) return; if (!title || !listId) return;
if (isDemo) { if (isDemo) {
if (onDemoAddTask) onDemoAddTask(title, listId); if (onDemoAddTask) onDemoAddTask(title, listId, null);
setNewTaskTitle(""); setNewTaskTitle("");
return; return;
} }
@@ -265,6 +511,41 @@ export function TaskList({
} }
}; };
const handleAddSubtaskInline = async (title: string, parentId: string) => {
if (!listId) return;
if (isDemo) {
if (onDemoAddTask) onDemoAddTask(title, listId, parentId);
return;
}
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
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) {
console.error("Failed to add subtask inline", err);
}
};
const handleSaveHeaderTitle = () => {
const trimmed = headerTitle.trim();
if (trimmed && listId && onUpdateListName && trimmed !== currentList?.name) {
onUpdateListName(listId, trimmed);
} else if (currentList) {
setHeaderTitle(currentList.name);
}
setEditingHeader(false);
};
if (!listId) { if (!listId) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}> <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
@@ -288,9 +569,52 @@ export function TaskList({
<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>
</button> </button>
<div className="main-header-title" style={{ color: currentList?.color }}>
{currentList?.name || t("tasks")} {/* Inline Editable List Title */}
</div> {editingHeader ? (
<input
ref={headerInputRef}
id="header-rename-input"
className="form-input"
value={headerTitle}
onChange={(e) => setHeaderTitle(e.target.value)}
onBlur={handleSaveHeaderTitle}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveHeaderTitle();
if (e.key === "Escape") {
if (currentList) setHeaderTitle(currentList.name);
setEditingHeader(false);
}
}}
onClick={(e) => e.stopPropagation()}
autoFocus
style={{ fontSize: 18, fontWeight: 700, height: 36, padding: "2px 10px", maxWidth: 360 }}
/>
) : (
<div
className="main-header-title"
id="main-header-title"
style={{
color: currentList?.color,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 8px",
borderRadius: "var(--radius-sm)",
transition: "background var(--dur-fast)",
}}
onClick={(e) => {
e.stopPropagation();
setEditingHeader(true);
}}
title="Click to rename list"
>
<span>{currentList?.name || t("tasks")}</span>
<span style={{ fontSize: 13, opacity: 0.5 }}></span>
</div>
)}
<div className="main-header-actions"> <div className="main-header-actions">
<button <button
id="toggle-completed-btn" id="toggle-completed-btn"
@@ -326,6 +650,8 @@ export function TaskList({
isSelected={selectedTaskId === task.id} isSelected={selectedTaskId === task.id}
onSelect={onTaskSelect} onSelect={onTaskSelect}
onToggle={handleToggle} onToggle={handleToggle}
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
onAddSubtask={handleAddSubtaskInline}
/> />
))} ))}
{showCompleted && completedTasks.length > 0 && ( {showCompleted && completedTasks.length > 0 && (
@@ -349,6 +675,8 @@ export function TaskList({
isSelected={selectedTaskId === task.id} isSelected={selectedTaskId === task.id}
onSelect={onTaskSelect} onSelect={onTaskSelect}
onToggle={handleToggle} onToggle={handleToggle}
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
onAddSubtask={handleAddSubtaskInline}
/> />
))} ))}
</div> </div>