diff --git a/.cline-context.md b/.cline-context.md index 4b0f2ef..9d3616e 100644 --- a/.cline-context.md +++ b/.cline-context.md @@ -28,6 +28,12 @@ - Refined `docker-compose.yml` and `.env.example` to support standalone 2-file deployment (`docker-compose.yml` + `.env`) without cloning source code, public registry images (`CHECKFLOW_IMAGE`), Watchtower auto-updating labels, and automated database migrations. - Automated Gitea Actions CI workflow (`.gitea/workflows/docker-build.yaml`) for building & pushing container images to Gitea Container Registry with lowercase repository name normalization (`env.REPO`) and OCI source labels (`org.opencontainers.image.source`) for automatic repository package linkage. - Completely revamped `README.md` to reflect universal 2-file Docker deployment for general users, Watchtower auto-update, architecture, and open-standard CalDAV sync guidelines. + - **Enhanced Task Row UX & Interactive Undo Toast (v0.6.3)**: + - Added Notion-style 6-dot drag handle on each task row with drag-and-drop task reordering. + - Added `F2` shortcut key and double-click to trigger inline title editing. + - Moved "+ Subtask" button to the right-aligned hover area and removed redundant explicit edit pencil button. + - Implemented floating interactive Undo Toast banner with 6-second grace period for soft delete and instant undo. + - Fixed Trash and task tree duplicate rendering in LocalStorage demo store. - Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings. - **Next Steps (Todo):** diff --git a/AGENTS.md b/AGENTS.md index f0080d7..9efe90f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,8 @@ src/ | `checkpoint-v0.5.0` | VSCode 무채색 다크 테마, 에디터 조잡한 툴바 제거 & 순수 캔버스 전환, 모듈형 블록 커스텀 (서브태스크 ↔ 노트 순서 스왑 및 스플릿 리사이저 높이 조절), CheckFlow Labs (3컬럼 칸반 보드 뷰), 기본값 복원(Reset to Defaults), 보안 패치 완료 | 2026-08-21 | | `checkpoint-v0.6.0` | 글로벌 `UserPrefs` 시스템(`userPrefs.ts` / `useUserPrefs.ts`) 도입, 사이드바 드래그 리사이저, 디테일 패널 드래그 리사이저, Labs 탭 확장(밀도/폰트/라운드/애니메이션/색조/채도/패널 너비), 뷰 전환(리스트↔칸반) 실시간 반응, 파스텔 매트 라이트 팔레트 적용 | 2026-08-21 | | `checkpoint-v0.6.1` | CalDAV / DAVx⁵ 양방향 동기화 프로토콜 고도화(OPTIONS, PROPFIND, REPORT, PUT, DELETE), 설정 모달 내 플랫폼별(Android DAVx⁵ / Apple Reminders / Thunderbird) 인터랙티브 가이드 및 실시간 엔드포인트 테스트 핑, 다국어 i18n 동기화 | 2026-08-21 | -| `checkpoint-v0.6.2` | **(현재 최신)** 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 | +| `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 | +| `checkpoint-v0.6.3` | **(현재 최신)** 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 | --- diff --git a/src/app/globals.css b/src/app/globals.css index 49ef4ee..8e4432a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -157,48 +157,212 @@ a { color: inherit; text-decoration: none; } } /* ============================================ - SIDEBAR + TOAST / NOTIFICATIONS & UNDO BANNER ============================================ */ -.sidebar { - width: var(--sidebar-width); - background: var(--bg-sidebar); - border-right: 1px solid var(--border); +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; display: flex; flex-direction: column; - flex-shrink: 0; - overflow: hidden; - transition: width var(--dur-slow) var(--ease-out), - transform var(--dur-slow) var(--ease-out); - z-index: 20; - min-width: 0; + gap: 8px; + z-index: 200; + pointer-events: none; } -.sidebar.collapsed { - width: 0; -} - -.sidebar-header { +.toast { + background: var(--text-primary); + color: var(--bg-primary); + padding: 10px 16px; + border-radius: var(--radius-md); + font-size: 13.5px; + font-weight: 500; + box-shadow: var(--shadow-md); display: flex; align-items: center; gap: 10px; - padding: 16px 16px 12px; - border-bottom: 1px solid var(--border); - min-height: var(--header-height); + animation: toastIn var(--dur-normal) var(--ease-out); + pointer-events: all; } -.sidebar-logo { - width: 28px; - height: 28px; - background: var(--accent); - border-radius: var(--radius-sm); +.toast-btn { + color: var(--accent); + font-weight: 600; + font-size: 13px; + padding: 2px 6px; + border-radius: var(--radius-xs); + transition: background var(--dur-fast); +} + +.toast-btn:hover { background: rgba(255,255,255,0.15); } + +@keyframes toastIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Notion-style Task Drag Handle */ +.task-drag-handle { + width: 16px; + height: 20px; display: flex; align-items: center; justify-content: center; - color: white; - font-size: 14px; - font-weight: 700; - letter-spacing: -0.5px; + color: var(--text-tertiary); + opacity: 0; + cursor: grab; + transition: opacity var(--dur-fast), color var(--dur-fast), transform var(--dur-fast); flex-shrink: 0; + margin-left: -4px; + margin-right: 2px; + user-select: none; +} + +.task-item:hover .task-drag-handle { + opacity: 0.7; +} + +.task-drag-handle:hover { + opacity: 1 !important; + color: var(--accent); + transform: scale(1.15); +} + +.task-drag-handle:active { + cursor: grabbing; +} + +/* Dragging & Reorder Feedback */ +.task-item.is-dragging { + opacity: 0.4; + background: var(--bg-active); + box-shadow: var(--shadow-md); +} + +.task-item.drag-over-top { + border-top: 2px solid var(--accent) !important; +} + +.task-item.drag-over-bottom { + border-bottom: 2px solid var(--accent) !important; +} + +/* Right-aligned task actions hover container */ +.task-actions-right { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + opacity: 0; + transition: opacity var(--dur-fast); +} + +.task-item:hover .task-actions-right { + opacity: 1; +} + +.task-item:focus-within .task-actions-right { + opacity: 1; +} + +/* Floating Interactive Undo Toast Banner */ +.undo-toast-banner { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-medium); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.08); + padding: 10px 18px; + border-radius: var(--radius-full); + display: flex; + align-items: center; + gap: 16px; + z-index: 300; + animation: undoToastIn 280ms cubic-bezier(0.175, 0.885, 0.32, 1.275); + backdrop-filter: blur(12px); + min-width: 320px; + max-width: 90vw; + justify-content: space-between; +} + +[data-theme="dark"] .undo-toast-banner { + background: #252526; + border-color: #3e3e42; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.undo-toast-content { + display: flex; + align-items: center; + gap: 10px; + font-size: 13.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.undo-toast-icon { + font-size: 16px; +} + +.undo-toast-msg strong { + font-weight: 600; + color: var(--accent); +} + +.undo-toast-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.undo-toast-btn { + background: var(--accent); + color: white; + font-weight: 600; + font-size: 12.5px; + padding: 4px 12px; + border-radius: var(--radius-full); + transition: transform var(--dur-fast), filter var(--dur-fast), background var(--dur-fast); + cursor: pointer; +} + +.undo-toast-btn:hover { + filter: brightness(1.1); + transform: translateY(-1px); +} + +.undo-toast-btn:active { + transform: translateY(0); +} + +.undo-toast-close { + color: var(--text-tertiary); + font-size: 12px; + padding: 4px 6px; + border-radius: var(--radius-full); + cursor: pointer; + transition: color var(--dur-fast), background var(--dur-fast); +} + +.undo-toast-close:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +@keyframes undoToastIn { + from { + opacity: 0; + transform: translate(-50%, 24px) scale(0.94); + } + to { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } } .sidebar-title { diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 5d66bd2..6226882 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -299,16 +299,91 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { handleTaskUpdate(updated); }; + // Undo Toast state + const [undoToast, setUndoToast] = useState<{ task: Task; title: string } | null>(null); + const undoTimerRef = useRef(null); + + // Unified task delete handler with immediate Undo Toast + const handleDeleteTaskWithUndo = useCallback((task: Task) => { + if (undoTimerRef.current) clearTimeout(undoTimerRef.current); + + setUndoToast({ task, title: task.title }); + + if (isDemo) { + const store = getDemoStore(); + const newTasks = moveToTrashInTree(store.tasks, task.id); + saveDemoStore(store.lists, newTasks); + setTasks((prev) => prev.filter((t) => t.id !== task.id)); + if (selectedTask?.id === task.id) setSelectedTask(null); + } else { + setTasks((prev) => prev.filter((t) => t.id !== task.id)); + if (selectedTask?.id === task.id) setSelectedTask(null); + fetch(`/api/tasks/${task.id}`, { method: "DELETE" }).catch((e) => console.error(e)); + } + + undoTimerRef.current = setTimeout(() => { + setUndoToast(null); + }, 6000); + }, [isDemo, selectedTask]); + + // Undo delete + const handleUndoDelete = useCallback(async () => { + if (!undoToast) return; + if (undoTimerRef.current) clearTimeout(undoTimerRef.current); + const { task } = undoToast; + setUndoToast(null); + + if (isDemo) { + const store = getDemoStore(); + const newTasks = restoreTaskInTree(store.tasks, task.id); + saveDemoStore(store.lists, newTasks); + if (task.listId === selectedListId) { + setTasks((prev) => { + if (prev.some((t) => t.id === task.id)) return prev; + return [...prev, { ...task, isDeleted: false, deletedAt: null }]; + }); + } + refresh(); + } else { + try { + const res = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: task.title, + listId: task.listId, + parentId: task.parentId, + note: task.note, + completed: task.completed, + dueDate: task.dueDate, + priority: task.priority, + }), + }); + if (res.ok) { + const created = await res.json(); + if (created.listId === selectedListId) { + setTasks((prev) => [...prev, created]); + } + refresh(); + } + } catch (err) { + console.error("Failed to restore task", err); + } + } + }, [undoToast, isDemo, selectedListId, refresh]); + // Move to Trash (Soft Delete) const handleDemoDeleteTask = (id: string) => { const store = getDemoStore(); - const newTasks = moveToTrashInTree(store.tasks, id); - saveDemoStore(store.lists, newTasks); - setTasks((prev) => prev.filter((t) => t.id !== id)); - if (selectedTask?.id === id) { - setSelectedTask(null); + const target = findTaskInTree(store.tasks, id); + if (target) { + handleDeleteTaskWithUndo(target as Task); + } else { + const newTasks = moveToTrashInTree(store.tasks, id); + saveDemoStore(store.lists, newTasks); + setTasks((prev) => prev.filter((t) => t.id !== id)); + if (selectedTask?.id === id) setSelectedTask(null); } - refresh(); }; // Restore from Trash @@ -435,7 +510,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
{ await fetch(`/api/tasks/${id}`, { method: "DELETE" }); refresh(); @@ -524,6 +600,34 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { onSelectTask={handleTaskSelect} /> + {/* Interactive Undo Toast Banner */} + {undoToast && ( +
+
+ 🗑️ + + {undoToast.title || "할 일"}이(가) 삭제되었습니다. + +
+
+ + +
+
+ )} + {/* Mobile FAB */}
); -} \ No newline at end of file +} diff --git a/src/components/tasks/TaskList.tsx b/src/components/tasks/TaskList.tsx index 3cafcd3..46f047e 100644 --- a/src/components/tasks/TaskList.tsx +++ b/src/components/tasks/TaskList.tsx @@ -43,6 +43,7 @@ interface TaskItemProps { isTrashMode?: boolean; onRestore?: (id: string) => void; onPermanentDelete?: (id: string) => void; + onDragTask?: (draggedId: string, targetId: string, position: "before" | "after") => void; } // Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly) @@ -58,6 +59,7 @@ function RecursiveTaskItem({ isTrashMode = false, onRestore, onPermanentDelete, + onDragTask, }: TaskItemProps) { const { t, lang } = useI18n(); const [expanded, setExpanded] = useState(true); @@ -154,11 +156,21 @@ function RecursiveTaskItem({ touchStartX.current = null; }; + const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | null>(null); + return (
0 ? 24 : 0, position: "relative" }}>
onSelect(task)} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "F2" && !isTrashMode) { + e.preventDefault(); + e.stopPropagation(); + setEditingTitle(true); + } + }} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} @@ -167,6 +179,24 @@ function RecursiveTaskItem({ e.stopPropagation(); onContextMenu(e.clientX, e.clientY, task); }} + onDragOver={(e) => { + if (isTrashMode) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + const rect = e.currentTarget.getBoundingClientRect(); + const mid = rect.top + rect.height / 2; + setDragOverPos(e.clientY < mid ? "top" : "bottom"); + }} + onDragLeave={() => setDragOverPos(null)} + onDrop={(e) => { + if (isTrashMode) return; + e.preventDefault(); + const draggedId = e.dataTransfer.getData("text/plain"); + if (draggedId && draggedId !== task.id && onDragTask) { + onDragTask(draggedId, task.id, dragOverPos === "top" ? "before" : "after"); + } + setDragOverPos(null); + }} id={`task-${task.id}`} style={{ borderLeft: depth > 0 ? "2px solid var(--border)" : "none", @@ -175,6 +205,30 @@ function RecursiveTaskItem({ transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none", }} > + {/* Notion-style 6-dot Drag Handle */} + {!isTrashMode && ( +
{ + e.stopPropagation(); + e.dataTransfer.setData("text/plain", task.id); + e.dataTransfer.effectAllowed = "move"; + }} + title="Drag to reorder" + onClick={(e) => e.stopPropagation()} + > + + + + + + + + +
+ )} + {/* Toggle Expand Arrow if has children */} {totalChildren > 0 ? (
)} - - {/* Quick Inline Add Subtask button */} - {!isTrashMode && ( - - )}
{task.note && !task.completed && depth === 0 && ( @@ -334,9 +371,9 @@ function RecursiveTaskItem({ )} - {/* Trash Mode Actions or Edit Icon */} + {/* Right Actions: Subtask Add Button & Trash Mode Controls */} {isTrashMode ? ( -
+
) : ( - +
+ +
)}
@@ -397,6 +441,7 @@ function RecursiveTaskItem({ isTrashMode={isTrashMode} onRestore={onRestore} onPermanentDelete={onPermanentDelete} + onDragTask={onDragTask} /> ))} @@ -460,6 +505,7 @@ interface Props { onDemoToggleTask?: (id: string, completed: boolean) => void; onUpdateTaskTitle?: (id: string, title: string) => void; onUpdateListName?: (id: string, name: string) => void; + onDeleteTaskWithUndo?: (task: Task) => void; onDeleteTask?: (id: string) => void; } @@ -485,6 +531,7 @@ export function TaskList({ onDemoToggleTask, onUpdateTaskTitle, onUpdateListName, + onDeleteTaskWithUndo, onDeleteTask, }: Props) { const { t } = useI18n(); @@ -640,6 +687,30 @@ export function TaskList({ setEditingHeader(false); }; + const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => { + setTasks((prev) => { + const draggedIndex = prev.findIndex((t) => t.id === draggedId); + const targetIndex = prev.findIndex((t) => t.id === targetId); + if (draggedIndex === -1 || targetIndex === -1) return prev; + + const newArr = [...prev]; + const [draggedItem] = newArr.splice(draggedIndex, 1); + const insertAt = position === "before" + ? (draggedIndex < targetIndex ? targetIndex - 1 : targetIndex) + : (draggedIndex < targetIndex ? targetIndex : targetIndex + 1); + newArr.splice(Math.max(0, insertAt), 0, draggedItem); + return newArr; + }); + }, [setTasks]); + + const handleDeleteTask = useCallback((task: Task) => { + if (onDeleteTaskWithUndo) { + onDeleteTaskWithUndo(task); + } else if (onDeleteTask) { + onDeleteTask(task.id); + } + }, [onDeleteTaskWithUndo, onDeleteTask]); + if (!listId && !isTrashActive && !selectedTag) { return (
@@ -830,6 +901,7 @@ export function TaskList({ isTrashMode={isTrashActive} onRestore={onRestoreTask} onPermanentDelete={onPermanentDeleteTask} + onDragTask={handleReorderTasks} /> ))} @@ -862,6 +934,7 @@ export function TaskList({ isTrashMode={isTrashActive} onRestore={onRestoreTask} onPermanentDelete={onPermanentDeleteTask} + onDragTask={handleReorderTasks} /> ))}
@@ -897,14 +970,7 @@ export function TaskList({ label: t("deleteTaskConfirm").split("?")[0], icon: "🗑️", danger: true, - onClick: async () => { - if (onDeleteTask) { - onDeleteTask(contextMenu.task.id); - } else if (!isDemo) { - await fetch(`/api/tasks/${contextMenu.task.id}`, { method: "DELETE" }); - onRefresh(); - } - }, + onClick: () => handleDeleteTask(contextMenu.task), }, ]} onClose={() => setContextMenu(null)} diff --git a/src/lib/mockData.ts b/src/lib/mockData.ts index ea7c2dd..fef5854 100644 --- a/src/lib/mockData.ts +++ b/src/lib/mockData.ts @@ -334,18 +334,25 @@ export function findTaskInTree(tree: MockTask[], id: string): MockTask | null { return null; } -// Get all active tasks or all trash tasks flattened +// Get all active tasks or all trash tasks flattened (strictly deduplicated) export function getAllTrashTasks(tree: MockTask[]): MockTask[] { - let trash: MockTask[] = []; - for (const node of tree) { - if (node.isDeleted) { - trash.push(node); - } - if (node.children && node.children.length > 0) { - trash = trash.concat(getAllTrashTasks(node.children)); + const map = new Map(); + + function collect(nodes: MockTask[]) { + for (const node of nodes) { + if (node.isDeleted) { + if (!map.has(node.id)) { + map.set(node.id, { ...node, children: [] }); + } + } + if (node.children && node.children.length > 0) { + collect(node.children); + } } } - return trash; + + collect(tree); + return Array.from(map.values()); } // Filter tasks by custom tag