diff --git a/AGENTS.md b/AGENTS.md index fedc1cf..501efe3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,130 @@ -# CheckFlow — Agent Handbook & Project History +# CheckFlow — Agent Handbook & Project History + +> 이 문서는 CheckFlow 프로젝트의 전체 개발 내역, 사용자 요구사항, 의사결정 기록, 기술 아키텍처를 보존하여 향후 작업하는 모든 AI 에이전트와 개발자가 일관되게 작업을 이어갈 수 있도록 작성되었습니다. +> **다음 에이전트에게**: 반드시 이 파일을 먼저 읽고, 작업 완료 후 업데이트하라. + +--- + +## 1. 프로젝트 개요 & 핵심 요구사항 + +사용자의 핵심 요구사항: +1. **TickTick 스타일 To-Do 리스트 독립 웹앱**: + - 체크리스트는 **메인 - 하위(Sub-task)** N단계 계층 구조를 갖추며, 양쪽 패널 간 1:1 실시간 동기화. + - **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 마크다운이 GUI로 렌더링됨. + - **인라인 즉시 수정 UX**: 프로젝트 제목 및 태스크 제목을 클릭으로 즉시 수정 가능. +2. **셀프호스팅 & 프라이버시 중심 멀티유저**: + - SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼. + - Docker 배포 지원 (PostgreSQL 16 포함, Dockerfile & docker-compose.yml 완비). +3. **외부 플랫폼 연동 & Import**: + - TickTick CSV/ICS Import 지원 (프로필 메뉴 내 배치). + - Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV (`/api/dav`) 동기화 지원. +4. **모바일 바텀시트 드로어**: + - 스마트폰에서 우측 상세 탭이 **바텀 시트**로 아래서 위로 슬라이드업. + - 아래로 120px 이상 스와이프하면 패널 닫힘. + - 오버레이 클릭으로도 패널 닫힘. + +--- + +## 2. 주요 컴포넌트 구조 + +``` +src/ +├── app/ +│ ├── demo/page.tsx ← DB 없이 LocalStorage 기반 데모 페이지 +│ ├── admin/page.tsx ← 어드민 대시보드 +│ └── api/ (auth, lists, tasks, import, dav) +├── components/ +│ ├── layout/ +│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너 + 모바일 오버레이 관리 +│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어, 테마, 프로필/Import +│ ├── tasks/ +│ │ ├── TaskList.tsx ← 체크리스트 (재귀 트리, 인라인 수정, 우클릭 메뉴) +│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (TickTick 스타일, 바텀시트 터치) +│ │ └── MarkdownNoteEditor.tsx ← Preview/Edit 탭, 툴바, URL 클릭 지원 +│ ├── settings/ +│ │ └── SettingsModal.tsx ← Profile/Preferences/Sync/Admin 4탭 +│ └── ui/ +│ ├── LanguageSelector.tsx ← 글래스모피즘 언어 팝오버 +│ ├── ContextMenu.tsx ← 커스텀 우클릭 메뉴 +│ └── CommandPalette.tsx ← Ctrl+K 글로벌 검색 +└── lib/ + ├── i18n/ (en, ko, ja + useI18n 훅) + └── mockData.ts ← Demo LocalStorage Store (trash, tags, settings) +``` + +--- + +## 3. 롤백 포인트 (Git Tags) + +| Tag | 내용 | 날짜 | +|---|---|---| +| `checkpoint-v1.0` | 기본 Full-Stack 기반 | 초기 | +| `checkpoint-v2.0` | i18n, 3-Way Theme, Demo Mode | - | +| `checkpoint-v2.1` | 리치 마크다운 렌더러, 적응형 메모장 | - | +| `checkpoint-v2.2` | 하위 태스크 실시간 동기화 & 인라인 수정 | - | +| `checkpoint-v3.0` | 사이드바 애니메이션, Import 이동, 커스텀 메뉴, TickTick 스타일 | - | +| `checkpoint-v3.2` | N-depth 재귀 체크리스트, 태그, 휴지통, 설정 모달, 타임스탬프 | - | +| `checkpoint-v4.0` | **(현재 최신)** 모바일 바텀시트, 스와이프 닫기, 오버레이 | 2026-08-20 | + +--- + +## 4. 구현 완료 기능 + +- [x] i18n 3개국어 (영어 기본, 한국어, 일본어) +- [x] 시스템/라이트/다크 테마 토글 +- [x] Demo 모드 (LocalStorage 기반) +- [x] N단계 재귀 체크리스트 (RecursiveTaskItem) +- [x] 인라인 제목 수정 +- [x] 커스텀 우클릭 컨텍스트 메뉴 (전체 트리) +- [x] Created / Edited 타임스탬프 +- [x] 커스텀 태그 (색상 자동 부여) +- [x] 휴지통 (복원/영구삭제/보관 기간 설정) +- [x] 마크다운 Preview/Edit + URL 새 탭 열기 +- [x] 설정 모달 (Profile/Preferences/Sync/Admin) +- [x] 어드민 페이지 (/admin) +- [x] Ctrl+K 글로벌 명령 팔레트 +- [x] TickTick CSV/ICS 임포트 +- [x] CalDAV 동기화 안내 +- [x] **모바일 바텀시트 드로어** (Y축 스와이프, 오버레이) +- [x] FAB 버튼 (모바일 태스크 추가) + +--- + +## 5. 개발 규칙 및 주의사항 + +1. **메모장 우선 원칙**: 우측 패널에서 메모장은 항상 `flex: 1`. +2. **컨텍스트 메뉴**: `ContextMenu.tsx` 사용, 브라우저 기본 메뉴 비활성화. +3. **i18n 무결성**: 새 텍스트는 en/ko/ja 사전에 모두 추가. +4. **모바일 CSS 규칙**: + - `detail-panel mobile-open` 클래스가 바텀시트를 제어. + - **절대** `task-detail-panel` 클래스로 되돌리지 말 것 (구버전). + - 모바일: `translateY(100%) → translateY(0)` 애니메이션. + - 데스크탑: `detail-panel`이 flex 방향 측면 패널. +5. **터치 제스처**: Y축 아래 방향 120px 이상 스와이프 → `onClose()`. +6. **언어**: 사용자 응답 및 에이전트 간 소통은 한국어를 기본으로 할 것. + +--- + +## 6. 모델 변경 이력 + +| 시점 | 모델 | +|---|---| +| 초기 ~ Checkpoint v2.x | Claude Sonnet 4.6 | +| Checkpoint v2.x ~ v3.x | Gemini 2.5 Flash | +| Checkpoint v4.0 | Gemini (현재) | + +--- + +## 7. 개발 환경 + +- **프레임워크**: Next.js 15 + Turbopack +- **DB**: PostgreSQL 16 + Prisma (Demo에서는 LocalStorage 대체) +- **스타일**: Vanilla CSS (CSS Variables 디자인 토큰) +- **인증**: NextAuth.js +- **배포**: Docker + docker-compose.yml +- **로컬 실행**: `npm run dev` (포트 3000) +- **Demo 페이지**: `http://localhost:3000/demo` -> 이 문서는 CheckFlow 프로젝트의 전체 개발 내역, 사용자 요구사항, 의사결정 기록, 기술 아키텍처 및 트러블슈팅을 보존하여 향후 작업하는 모든 AI 에이전트와 개발자가 일관되게 작업을 이어갈 수 있도록 작성되었습니다. --- diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..001f008 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,235 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import Link from "next/link"; +import { useI18n } from "@/lib/i18n"; +import { getDemoStore } from "@/lib/mockData"; + +interface AdminUser { + id: string; + name: string; + email: string; + role: "USER" | "ADMIN"; + createdAt: string; + taskCount: number; + active: boolean; +} + +const INITIAL_ADMIN_USERS: AdminUser[] = [ + { + id: "user-1", + name: "Admin Master", + email: "admin@checkflow.local", + role: "ADMIN", + createdAt: "2026-08-01T09:00:00Z", + taskCount: 42, + active: true, + }, + { + id: "user-2", + name: "Demo Explorer", + email: "demo@checkflow.local", + role: "USER", + createdAt: "2026-08-15T14:20:00Z", + taskCount: 18, + active: true, + }, + { + id: "user-3", + name: "Family Member 1", + email: "sarah@home.lan", + role: "USER", + createdAt: "2026-08-18T11:00:00Z", + taskCount: 7, + active: true, + }, +]; + +export default function AdminPage() { + const { t } = useI18n(); + const [users, setUsers] = useState(INITIAL_ADMIN_USERS); + const [search, setSearch] = useState(""); + const [totalTasks, setTotalTasks] = useState(67); + const [totalLists, setTotalLists] = useState(9); + + useEffect(() => { + const store = getDemoStore(); + if (store.tasks) setTotalTasks(store.tasks.length); + if (store.lists) setTotalLists(store.lists.length); + }, []); + + const toggleRole = (id: string) => { + setUsers((prev) => + prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u)) + ); + }; + + const toggleStatus = (id: string) => { + setUsers((prev) => + prev.map((u) => (u.id === id ? { ...u, active: !u.active } : u)) + ); + }; + + const deleteUser = (id: string) => { + if (confirm("Are you sure you want to delete this user and all their tasks?")) { + setUsers((prev) => prev.filter((u) => u.id !== id)); + } + }; + + const filteredUsers = users.filter( + (u) => + u.name.toLowerCase().includes(search.toLowerCase()) || + u.email.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ {/* Top Navbar */} +
+
+
👑
+
+

+ CheckFlow Admin Console +

+

+ Multi-user platform & self-hosted instance management +

+
+
+ +
+ + ← Back to App (Demo) + + + 🚀 Open Main Dashboard + +
+
+ + {/* Stats Cards */} +
+
+
Total Users
+
{users.length}
+
● 100% active instances
+
+ +
+
Total Projects / Lists
+
{totalLists}
+
Across all users
+
+ +
+
Total Tasks & Notes
+
{totalTasks}
+
Recursive subtasks included
+
+ +
+
CalDAV / DAVx⁵ Status
+
Active
+
● Basic Auth Bypass Verified
+
+
+ + {/* User Management Section */} +
+
+

Registered Users & Privacy Isolation

+ setSearch(e.target.value)} + style={{ maxWidth: 300 }} + /> +
+ + {/* Users Table */} +
+ + + + + + + + + + + + + {filteredUsers.map((u) => ( + + + + + + + + + ))} + +
UserRoleJoined DateTasksStatusActions
+
{u.name}
+
{u.email}
+
+ + {u.role} + + + {new Date(u.createdAt).toLocaleDateString()} + + {u.taskCount} tasks + + + {u.active ? "● Active" : "○ Inactive"} + + +
+ + + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index f0afff1..190581d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1553,20 +1553,39 @@ a { color: inherit; text-decoration: none; } box-shadow: var(--shadow-lg); } + /* 모바일: 우측 패널을 바닥에서 위로 슬라이드 업하는 바텀 시트 */ .detail-panel { position: fixed; + left: 0; right: 0; - top: 0; bottom: 0; - z-index: 30; - transform: translateX(100%); - transition: transform var(--dur-slow) var(--ease-out); + top: auto; + height: 92dvh; /* 전체 화면의 92% */ width: 100% !important; + border-left: none; + border-top: 1px solid var(--border); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + z-index: 35; + transform: translateY(100%); + transition: transform var(--dur-slow) var(--ease-out); + box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.18); + overflow: hidden; } .detail-panel.mobile-open { - transform: translateX(0); - box-shadow: var(--shadow-panel); + transform: translateY(0); + } + + /* 모바일 학 스와이프 핸들 */ + .mobile-swipe-handle { + display: block !important; + width: 40px; + height: 5px; + background: var(--border-medium); + border-radius: var(--radius-full); + margin: 8px auto 4px; + cursor: grab; + flex-shrink: 0; } .bottom-nav { @@ -1579,6 +1598,20 @@ a { color: inherit; text-decoration: none; } .task-item { padding: 10px 16px; } .main-header { padding: 0 16px; } + + .mobile-only { + display: block; + } + + /* 바텀시트 배경 오버레이 */ + .bottom-sheet-overlay { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 34; + animation: fadeIn var(--dur-normal) var(--ease-out); + } } /* Bottom nav (mobile only) */ @@ -1680,6 +1713,11 @@ a { color: inherit; text-decoration: none; } display: none; } +/* 바텀시트 배경 오버레이 (모바일 전용) */ +.bottom-sheet-overlay { + display: none; +} + /* ============================================ CUSTOM CONTEXT MENU ============================================ */ diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index d7f7bbe..1cb021b 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -3,15 +3,21 @@ import { useState, useCallback, useEffect } from "react"; import { Sidebar } from "./Sidebar"; import { TaskList, Task, List, User } from "../tasks/TaskList"; import { TaskDetail } from "../tasks/TaskDetail"; +import { CommandPalette } from "../ui/CommandPalette"; import { getDemoStore, saveDemoStore, MockList, MockTask, updateTaskInTree, + moveToTrashInTree, + restoreTaskInTree, deleteTaskInTree, + emptyTrashInTree, addTaskToTree, findTaskInTree, + getAllTrashTasks, + filterTasksByTag, } from "@/lib/mockData"; interface AppShellProps { @@ -22,33 +28,49 @@ interface AppShellProps { export function AppShell({ user, isDemo = false }: AppShellProps) { const [lists, setLists] = useState([]); const [selectedListId, setSelectedListId] = useState(null); + const [selectedTag, setSelectedTag] = useState(null); + const [isTrashActive, setIsTrashActive] = useState(false); const [selectedTask, setSelectedTask] = useState(null); const [tasks, setTasks] = useState([]); const [sidebarOpen, setSidebarOpen] = useState(false); const [showCompleted, setShowCompleted] = useState(false); + const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + // Listen for Ctrl+K event + useEffect(() => { + const handler = () => setCmdPaletteOpen(true); + document.addEventListener("checkflow:openCommandPalette", handler); + return () => document.removeEventListener("checkflow:openCommandPalette", handler); + }, []); + // Demo store loading useEffect(() => { if (isDemo) { const store = getDemoStore(); setLists(store.lists); - if (store.lists.length > 0) { + if (store.lists.length > 0 && !selectedListId && !isTrashActive && !selectedTag) { setSelectedListId(store.lists[0].id); } } - }, [isDemo]); + }, [isDemo, isTrashActive, selectedTag]); // Demo tasks filter & reconstruct hierarchical structure useEffect(() => { - if (isDemo && selectedListId) { + if (isDemo) { const store = getDemoStore(); - const listTasks = (store.tasks as Task[]).filter( - (t) => t.listId === selectedListId && (showCompleted ? true : !t.completed) - ); - setTasks(listTasks); + if (isTrashActive) { + setTasks(getAllTrashTasks(store.tasks) as Task[]); + } else if (selectedTag) { + setTasks(filterTasksByTag(store.tasks, selectedTag) as Task[]); + } else if (selectedListId) { + const listTasks = (store.tasks as Task[]).filter( + (t) => !t.isDeleted && t.listId === selectedListId && (showCompleted ? true : !t.completed) + ); + setTasks(listTasks); + } } - }, [isDemo, selectedListId, showCompleted, refreshKey]); + }, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]); const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); @@ -68,12 +90,29 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { }, []); const handleListSelect = useCallback((id: string) => { + setIsTrashActive(false); + setSelectedTag(null); setSelectedListId(id); setSelectedTask(null); setSidebarOpen(false); }, []); - // Update List Name (Inline edit on header or sidebar) + const handleTagSelect = useCallback((tagName: string | null) => { + setIsTrashActive(false); + setSelectedTag(tagName); + setSelectedTask(null); + setSidebarOpen(false); + }, []); + + const handleTrashSelect = useCallback(() => { + setIsTrashActive(true); + setSelectedTag(null); + setSelectedListId(null); + setSelectedTask(null); + setSidebarOpen(false); + }, []); + + // Update List Name const handleUpdateListName = async (id: string, newName: string) => { const trimmed = newName.trim(); if (!trimmed) return; @@ -101,7 +140,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { } }; - // Demo Handlers with Full N-Depth Recursive Tree Support + // Demo Handlers const handleDemoCreateList = (name: string, color: string) => { const newList: MockList = { id: "demo-list-" + Date.now(), @@ -142,6 +181,8 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { sortOrder: tasks.length, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + deletedAt: null, + isDeleted: false, children: [], tags: [], }; @@ -151,7 +192,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]); - // If currently selected task is the parent, update its children in the detail panel immediately if (selectedTask && selectedTask.id === parentId) { setSelectedTask((prev) => prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev @@ -175,7 +215,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { (updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed) ); - // Keep selectedTask 100% in sync with the tree if (selectedTask) { const latestSelected = findTaskInTree(newTasks, selectedTask.id); if (latestSelected) { @@ -193,18 +232,52 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { handleTaskUpdate(updated); }; + // Move to Trash (Soft Delete) const handleDemoDeleteTask = (id: string) => { const store = getDemoStore(); - const newTasks = deleteTaskInTree(store.tasks, id); + const newTasks = moveToTrashInTree(store.tasks, id); saveDemoStore(store.lists, newTasks); - setTasks((prev) => deleteTaskInTree(prev as MockTask[], id) as Task[]); + setTasks((prev) => prev.filter((t) => t.id !== id)); if (selectedTask?.id === id) { setSelectedTask(null); } refresh(); }; - // Inline update task title from main list + // Restore from Trash + const handleRestoreTask = (id: string) => { + if (isDemo) { + const store = getDemoStore(); + const newTasks = restoreTaskInTree(store.tasks, id); + saveDemoStore(store.lists, newTasks); + setTasks((prev) => prev.filter((t) => t.id !== id)); + refresh(); + } + }; + + // Permanent Delete + const handlePermanentDeleteTask = (id: string) => { + if (isDemo) { + const store = getDemoStore(); + const newTasks = deleteTaskInTree(store.tasks, id); + saveDemoStore(store.lists, newTasks); + setTasks((prev) => prev.filter((t) => t.id !== id)); + refresh(); + } + }; + + // Empty Trash + const handleEmptyTrash = () => { + if (!confirm("Permanently empty all items in trash?")) return; + if (isDemo) { + const store = getDemoStore(); + const newTasks = emptyTrashInTree(store.tasks); + saveDemoStore(store.lists, newTasks); + setTasks([]); + refresh(); + } + }; + const handleUpdateTaskTitle = async (taskId: string, newTitle: string) => { const trimmed = newTitle.trim(); if (!trimmed) return; @@ -238,7 +311,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { return (
- {/* Mobile overlay */} + {/* Sidebar 모바일 오버레이 */} {sidebarOpen && (
)} + {/* 바텀시트 모바일 오버레이 — 태스크 상세 패널이 열릴 때 */} + {selectedTask && ( +
setSelectedTask(null)} + /> + )} + setSidebarOpen(false)} isDemo={isDemo} @@ -262,7 +347,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
setSidebarOpen(true)} onRefresh={refresh} isDemo={isDemo} + isTrashActive={isTrashActive} + selectedTag={selectedTag} + onEmptyTrash={handleEmptyTrash} + onRestoreTask={handleRestoreTask} + onPermanentDeleteTask={handlePermanentDeleteTask} onDemoAddTask={handleDemoAddTask} onDemoToggleTask={handleDemoToggleTask} onUpdateTaskTitle={handleUpdateTaskTitle} @@ -303,6 +393,14 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { /> )} + {/* Global Command Palette (Ctrl+K) */} + setCmdPaletteOpen(false)} + tasks={tasks} + onSelectTask={handleTaskSelect} + /> + {/* Mobile FAB */}
- {/* Top Controls: Language & Theme — Fixed without clipping */} + {/* Top Controls: Search, Language & Theme */}
+ {/* Quick Search Button */} + + {/* Theme 3-Way Toggle Button */} @@ -220,13 +252,17 @@ export function Sidebar({ {/* Nav List */} - {/* User footer with Profile Menu containing Import */} + {/* User footer with Profile Menu containing Settings & Import */}
setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
{initials}
@@ -313,6 +378,19 @@ export function Sidebar({ {/* User Popover Menu */} {userMenuOpen && (
+ {/* Settings Action */} + + {/* Import Action */}
- - - - {t("importTasks")} + 📥 {t("importTasks")}
@@ -337,17 +412,11 @@ export function Sidebar({ className="dropdown-item" onClick={() => { window.location.href = "/login"; }} > - - - - {t("signIn")} + 🚪 {t("signIn")}
) : (
signOut({ callbackUrl: "/login" })}> - - - - {t("signOut")} + 🚪 {t("signOut")}
)}
@@ -365,6 +434,14 @@ export function Sidebar({ /> )} + {/* Settings Modal */} + setShowSettings(false)} + user={user} + isDemo={isDemo} + /> + {/* Import modal */} {showImport && (
setShowImport(false)}> diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx new file mode 100644 index 0000000..0483d01 --- /dev/null +++ b/src/components/settings/SettingsModal.tsx @@ -0,0 +1,258 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { useI18n } from "@/lib/i18n"; +import { useTheme } from "@/app/providers"; +import { getUserSettings, saveUserSettings, UserSettings } from "@/lib/mockData"; + +interface SettingsModalProps { + isOpen: boolean; + onClose: () => void; + user: { id: string; name?: string | null; email?: string | null }; + isDemo?: boolean; +} + +export function SettingsModal({ isOpen, onClose, user, isDemo = false }: SettingsModalProps) { + const { t, lang, setLang } = useI18n(); + const { theme, toggleTheme } = useTheme(); + + const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "sync" | "admin">("profile"); + const [displayName, setDisplayName] = useState(user.name || "Demo User"); + const [email, setEmail] = useState(user.email || "demo@checkflow.local"); + const [password, setPassword] = useState(""); + const [trashRetention, setTrashRetention] = useState(30); + const [savedMsg, setSavedMsg] = useState(""); + + useEffect(() => { + if (isOpen) { + const s = getUserSettings(); + setDisplayName(user.name || s.displayName); + setEmail(user.email || s.email); + setTrashRetention(s.trashRetentionDays ?? 30); + } + }, [isOpen, user]); + + if (!isOpen) return null; + + const handleSave = () => { + const newSettings: UserSettings = { + displayName, + email, + trashRetentionDays: trashRetention, + theme, + language: lang, + }; + saveUserSettings(newSettings); + setSavedMsg("✓ " + (lang === "ko" ? "설정이 저장되었습니다" : lang === "ja" ? "設定が保存されました" : "Settings saved")); + setTimeout(() => { + setSavedMsg(""); + onClose(); + }, 1000); + }; + + const calDavUrl = typeof window !== "undefined" ? `${window.location.origin}/api/dav` : "https://todo.yourdomain.com/api/dav"; + + return ( +
+
e.stopPropagation()} style={{ maxWidth: 640, width: "90%" }}> + {/* Header */} +
+

⚙️ {t("settingsModalTitle") || "Settings"}

+ +
+ + {/* Tab Bar */} +
+ + + + +
+ + {/* Tab 1: Profile */} + {activeTab === "profile" && ( +
+
+ + setDisplayName(e.target.value)} + placeholder="Your Name" + /> +
+
+ + setEmail(e.target.value)} + placeholder="your.email@example.com" + /> +
+
+ + setPassword(e.target.value)} + placeholder="New password (leave blank to keep current)" + /> +
+
+ )} + + {/* Tab 2: Preferences (Trash Retention, Theme, Language) */} + {activeTab === "preferences" && ( +
+ {/* Trash Retention Days */} +
+ +

+ {t("trashRetentionHint") || "Deleted tasks will be permanently removed after the specified period."} +

+ +
+ + {/* Language Selection */} +
+ + +
+ + {/* Theme Toggle */} +
+ +
+ + + +
+
+
+ )} + + {/* Tab 3: CalDAV / CardDAV Integrations */} + {activeTab === "sync" && ( +
+

📱 Galaxy & Mobile Sync via DAVx⁵

+

+ CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV/CardDAV protocols. +

+
+ +
+ + +
+
+
+ Setup Instructions for DAVx⁵ (Android): +
    +
  1. Install DAVx⁵ on your Samsung Galaxy from Google Play or F-Droid.
  2. +
  3. Add account → Login with URL and user name.
  4. +
  5. Base URL: {calDavUrl}
  6. +
  7. User / Password: Your CheckFlow email & password.
  8. +
+
+
+ )} + + {/* Tab 4: Admin Quick Access */} + {activeTab === "admin" && ( +
+

👑 Multi-User Admin Console

+

+ Manage registered users, user roles (User/Admin), system statistics, and storage allocations. +

+ + 🚀 Open Admin Dashboard → + +
+ )} + + {/* Footer */} +
+ {savedMsg && {savedMsg}} +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/tasks/MarkdownNoteEditor.tsx b/src/components/tasks/MarkdownNoteEditor.tsx index 729682d..4ff6436 100644 --- a/src/components/tasks/MarkdownNoteEditor.tsx +++ b/src/components/tasks/MarkdownNoteEditor.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState, useRef, useCallback } from "react"; +import React, { useState, useRef, useCallback, useEffect } from "react"; import { marked } from "marked"; import { useI18n } from "@/lib/i18n"; @@ -13,6 +13,17 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit const { t } = useI18n(); const [mode, setMode] = useState<"edit" | "preview">("preview"); const textareaRef = useRef(null); + const previewRef = useRef(null); + + // Configure marked to open links in new tabs safely + useEffect(() => { + const renderer = new marked.Renderer(); + renderer.link = ({ href, title, text }: { href: string; title?: string | null; text: string }) => { + const titleAttr = title ? ` title="${title}"` : ""; + return `${text}`; + }; + marked.use({ renderer, breaks: true, gfm: true }); + }, []); // Insert markdown syntax at selection or cursor position const insertSyntax = useCallback( @@ -74,14 +85,23 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit onChange(newLines.join("\n")); }; - // Convert markdown to sanitized HTML with interactive checkboxes + // Convert markdown to sanitized HTML with interactive checkboxes and autolinks const renderMarkdownHtml = () => { if (!value || !value.trim()) { return `

${t("notesPlaceholder").split("\n")[0]}

`; } try { - let rawHtml = marked.parse(value, { breaks: true, gfm: true }) as string; + // Auto-detect plain URLs and wrap them in markdown links if not already wrapped + const textWithLinks = value.replace( + /(^|[^"'])(https?:\/\/[^\s<]+)/g, + (match, prefix, url) => { + if (match.includes("](") || match.includes('href="')) return match; + return `${prefix}[${url}](${url})`; + } + ); + + let rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string; // Replace task list checkboxes with interactive ones let cbIdx = 0; @@ -303,10 +323,22 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit /> ) : (
{ const target = e.target as HTMLElement; + + // Handle link clicks cleanly + const link = target.closest("a"); + if (link && link.href) { + e.preventDefault(); + e.stopPropagation(); + window.open(link.href, "_blank", "noopener,noreferrer"); + return; + } + + // Handle interactive checkbox clicks if (target.classList.contains("markdown-checkbox-box")) { const idxStr = target.getAttribute("data-idx"); if (idxStr) { diff --git a/src/components/tasks/TaskDetail.tsx b/src/components/tasks/TaskDetail.tsx index 3a9d5c6..69074aa 100644 --- a/src/components/tasks/TaskDetail.tsx +++ b/src/components/tasks/TaskDetail.tsx @@ -1,8 +1,9 @@ "use client"; import React, { useState, useEffect, useCallback, useRef } from "react"; import { useI18n } from "@/lib/i18n"; -import { Task } from "./TaskList"; +import { Task, Tag } from "./TaskList"; import { MarkdownNoteEditor } from "./MarkdownNoteEditor"; +import { getCustomTags, MockTag } from "@/lib/mockData"; interface Props { task: Task; @@ -33,11 +34,28 @@ export function TaskDetail({ const [note, setNote] = useState(task.note || ""); const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : ""); const [priority, setPriority] = useState(task.priority); + const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []); + const [allAvailableTags, setAllAvailableTags] = useState([]); + const [showTagPicker, setShowTagPicker] = useState(false); + const [newTagName, setNewTagName] = useState(""); + const [newSubtitle, setNewSubtitle] = useState(""); const [subtasks, setSubtasks] = useState(task.children || []); const [showSubtasks, setShowSubtasks] = useState(true); const [saving, setSaving] = useState(false); + // Mobile Bottom-Sheet: swipe down to close + const touchStartY = useRef(null); + const [panelTranslateY, setPanelTranslateY] = useState(0); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const check = () => setIsMobile(window.innerWidth <= 768); + check(); + window.addEventListener("resize", check); + return () => window.removeEventListener("resize", check); + }, []); + const saveTimer = useRef | null>(null); const currentTaskId = useRef(task.id); @@ -59,8 +77,10 @@ export function TaskDetail({ setNote(task.note || ""); setDueDate(task.dueDate ? task.dueDate.split("T")[0] : ""); setPriority(task.priority); + setTags(task.tags || []); setSubtasks(task.children || []); - }, [task.id, task.title, task.note, task.dueDate, task.priority, task.children]); + setAllAvailableTags(getCustomTags()); + }, [task.id, task.title, task.note, task.dueDate, task.priority, task.children, task.tags]); useEffect(() => { return () => { @@ -78,6 +98,7 @@ export function TaskDetail({ ...task, ...data, children: subtasks, + tags, updatedAt: new Date().toISOString(), } as Task; if (onDemoUpdateTask) onDemoUpdateTask(updatedTask); @@ -95,25 +116,26 @@ export function TaskDetail({ if (res.ok) { const updated = await res.json(); if (taskId === currentTaskId.current) { - onUpdate({ ...updated, children: subtasks }); + onUpdate({ ...updated, children: subtasks, tags }); } } } catch (err) { console.error("[TaskDetail] save failed", err); } finally { - if (taskId === currentTaskId.current) setSaving(false); + setSaving(false); } }, - [isDemo, onDemoUpdateTask, onUpdate, subtasks, task] + [isDemo, onDemoUpdateTask, onUpdate, subtasks, tags, task] ); const debounceSave = useCallback( - (overrides: Record) => { + (data: Record) => { if (saveTimer.current) clearTimeout(saveTimer.current); - const taskId = currentTaskId.current; - saveTimer.current = setTimeout(() => save(taskId, overrides), 600); + saveTimer.current = setTimeout(() => { + save(task.id, data); + }, 500); }, - [save] + [save, task.id] ); const handleNoteChange = (newNote: string) => { @@ -121,40 +143,16 @@ export function TaskDetail({ debounceSave({ note: newNote }); }; - const handleToggleCompleted = useCallback( - async (newCompleted: boolean) => { - if (isDemo) { - const updatedTask: Task = { - ...task, - completed: newCompleted, - completedAt: newCompleted ? new Date().toISOString() : null, - children: subtasks, - }; - if (onDemoUpdateTask) onDemoUpdateTask(updatedTask); - onUpdate(updatedTask); - return; - } - - try { - const res = await fetch(`/api/tasks/${task.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ completed: newCompleted }), - }); - if (res.ok) { - const updated = await res.json(); - onUpdate({ ...updated, children: subtasks }); - } - } catch (err) { - console.error("[TaskDetail] toggle failed", err); - } - }, - [isDemo, onDemoUpdateTask, task, subtasks, onUpdate] - ); + const handleToggleComplete = useCallback(async () => { + const nextCompleted = !task.completed; + save(task.id, { + completed: nextCompleted, + completedAt: nextCompleted ? new Date().toISOString() : null, + }); + }, [save, task.id, task.completed]); const handleDelete = useCallback(async () => { if (!confirm(t("deleteTaskConfirm"))) return; - if (isDemo) { if (onDemoDeleteTask) onDemoDeleteTask(task.id); onDelete(); @@ -169,6 +167,32 @@ export function TaskDetail({ } }, [t, isDemo, onDemoDeleteTask, task.id, onDelete]); + // Tag Management + const toggleTag = (tag: MockTag) => { + const exists = tags.some((tItem) => tItem.tag.id === tag.id); + let nextTags: { tag: Tag }[]; + if (exists) { + nextTags = tags.filter((tItem) => tItem.tag.id !== tag.id); + } else { + nextTags = [...tags, { tag }]; + } + setTags(nextTags); + save(task.id, { tags: nextTags }); + }; + + const addCustomTag = () => { + const trimmed = newTagName.trim().replace(/^#/, ""); + if (!trimmed) return; + const newTag: MockTag = { + id: "tag-" + Date.now(), + name: trimmed, + color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)], + }; + setAllAvailableTags((p) => [...p, newTag]); + toggleTag(newTag); + setNewTagName(""); + }; + const addSubtask = useCallback(async () => { const tTitle = newSubtitle.trim(); if (!tTitle) return; @@ -254,13 +278,13 @@ export function TaskDetail({ console.error("[TaskDetail] toggleSubtask failed", err); } }, - [isDemo, subtasks, task, onDemoUpdateTask, onUpdate] + [isDemo, onDemoUpdateTask, onUpdate, subtasks, task] ); const deleteSubtask = useCallback( - async (id: string) => { + async (subId: string) => { if (isDemo) { - const nextSubs = subtasks.filter((s) => s.id !== id); + const nextSubs = subtasks.filter((s) => s.id !== subId); setSubtasks(nextSubs); const updatedParent = { ...task, children: nextSubs }; if (onDemoUpdateTask) onDemoUpdateTask(updatedParent); @@ -269,9 +293,9 @@ export function TaskDetail({ } try { - await fetch(`/api/tasks/${id}`, { method: "DELETE" }); + await fetch(`/api/tasks/${subId}`, { method: "DELETE" }); setSubtasks((prev) => { - const next = prev.filter((s) => s.id !== id); + const next = prev.filter((s) => s.id !== subId); onUpdate({ ...task, children: next }); return next; }); @@ -279,42 +303,91 @@ export function TaskDetail({ console.error("[TaskDetail] deleteSubtask failed", err); } }, - [isDemo, subtasks, task, onDemoUpdateTask, onUpdate] + [isDemo, onDemoUpdateTask, onUpdate, subtasks, task] ); + // Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기 + const handleTouchStart = (e: React.TouchEvent) => { + touchStartY.current = e.touches[0].clientY; + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (touchStartY.current === null) return; + const deltaY = e.touches[0].clientY - touchStartY.current; + if (deltaY > 0) { + setPanelTranslateY(deltaY); + } + }; + + const handleTouchEnd = () => { + if (panelTranslateY > 120) { + onClose(); + } + setPanelTranslateY(0); + touchStartY.current = null; + }; + const completedCount = subtasks.filter((s) => s.completed).length; - const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0; return ( -