diff --git a/AGENTS.md b/AGENTS.md index 501efe3..e0c6d48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,49 +58,66 @@ src/ | 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 | +| `checkpoint-v0.1.0` | 기본 Full-Stack 기반 | 초기 | +| `checkpoint-v0.2.0` | i18n, 3-Way Theme, Demo Mode | - | +| `checkpoint-v0.2.1` | 리치 마크다운 렌더러, 적응형 메모장 | - | +| `checkpoint-v0.2.2` | 하위 태스크 실시간 동기화 & 인라인 수정 | - | +| `checkpoint-v0.3.0` | 사이드바 애니메이션, Import 이동, 커스텀 메뉴, TickTick 스타일 | - | +| `checkpoint-v0.3.2` | N-depth 재귀 체크리스트, 태그, 휴지통, 설정 모달, 타임스탬프 | - | +| `checkpoint-v0.4.0` | 모바일 바텀시트, 스와이프 닫기, 오버레이 | 2026-08-20 | +| `checkpoint-v0.5.0` | **(현재 최신)** VSCode 스타일 무채색 다크 테마, 에디터 조잡한 툴바 제거 & 순수 캔버스 전환, 모듈형 블록 커스텀 (서브태스크 ↔ 노트 순서 스왑 및 스플릿 리사이저 높이 조절), CheckFlow Labs (3컬럼 칸반 보드 뷰), 기본값 복원(Reset to Defaults), 보안 패치 완료 | 2026-08-21 | --- ## 4. 구현 완료 기능 -- [x] i18n 3개국어 (영어 기본, 한국어, 일본어) -- [x] 시스템/라이트/다크 테마 토글 -- [x] Demo 모드 (LocalStorage 기반) +- [x] i18n 3개국어 (영어 기본, 한국어, 일본어 사전 100% 무결성) +- [x] **VSCode 스타일 무채색 다크 테마** (`#181818`, `#1e1e1e`, `#252526`, `#2d2d2d`) +- [x] **에디터 툴바 제거 & 심플 캔버스** (조잡한 `- [ ]` 툴바 제거, 순수한 텍스트/마크다운 에디팅) +- [x] **모듈형 상세 패널 (Custom Blocks)**: + - 서브태스크 ↔ 노트 블록 **위치 스왑(⇄ Swap)** + - 마우스 드래그로 높이 비율(15%~85%)을 실시간 조절하는 **스플릿 리사이저(Split Resizer)** + - 각 블록별 독립적인 접기/펼치기 및 100% 풀스크린 확장 +- [x] **🧪 CheckFlow Labs (실험실 기능)**: + - 📋 **리스트 뷰** ↔ 📊 **3컬럼 칸반 보드 뷰 (To Do / In Progress / Done)** 원클릭 전환 + - 언제든 초기 순정 상태로 되돌리는 **기본값 복원 (Reset to Defaults)** +- [x] Demo 모드 (LocalStorage 기반 완전한 로컬 구동) - [x] N단계 재귀 체크리스트 (RecursiveTaskItem) -- [x] 인라인 제목 수정 +- [x] 인라인 제목 수정 (더블클릭/엔터/ESC) - [x] 커스텀 우클릭 컨텍스트 메뉴 (전체 트리) - [x] Created / Edited 타임스탬프 - [x] 커스텀 태그 (색상 자동 부여) - [x] 휴지통 (복원/영구삭제/보관 기간 설정) -- [x] 마크다운 Preview/Edit + URL 새 탭 열기 -- [x] 설정 모달 (Profile/Preferences/Sync/Admin) -- [x] 어드민 페이지 (/admin) +- [x] 설정 모달 (Profile/Preferences/Labs/Sync/Admin) +- [x] 어드민 대시보드 (`/admin` - ADMIN 롤 및 권한 통제 게이트) +- [x] 정식 라우트 보호 미들웨어 (`src/middleware.ts` 인증 & 역할 검증) +- [x] API IDOR 및 소유권 교차 검증 (서브태스크 및 리스트 이동) +- [x] CSV / ICS Import Formula Injection 방어 - [x] Ctrl+K 글로벌 명령 팔레트 - [x] TickTick CSV/ICS 임포트 -- [x] CalDAV 동기화 안내 +- [x] CalDAV 동기화 안내 (DAVx⁵) +- [x] **TickTick 스타일 스마트 퀵애드 툴바** (날짜, 우선순위 프리셋) +- [x] **상단 프로젝트 브레드크럼 & 리스트 이동기** (`📁 프로젝트명 ▾`) - [x] **모바일 바텀시트 드로어** (Y축 스와이프, 오버레이) - [x] FAB 버튼 (모바일 태스크 추가) --- -## 5. 개발 규칙 및 주의사항 +## 5. 개발 규칙 및 보안 원칙 -1. **메모장 우선 원칙**: 우측 패널에서 메모장은 항상 `flex: 1`. -2. **컨텍스트 메뉴**: `ContextMenu.tsx` 사용, 브라우저 기본 메뉴 비활성화. -3. **i18n 무결성**: 새 텍스트는 en/ko/ja 사전에 모두 추가. -4. **모바일 CSS 규칙**: +1. **최상위 원칙 - 보안, 프라이버시, 안정성**: + - 모든 API 요청은 세션 소유권(`userId === session.user.id`)을 철저히 검증할 것. + - 사용자 입력(마크다운, CSV/ICS 등)은 DOMPurify 및 Formula Sanitize를 반드시 통과할 것. + - 어드민 전용 라우트는 `role === "ADMIN"`을 서버/미들웨어 레벨에서 필터링할 것. +2. **사용자 주도 커스터마이징 & 공간 효율성**: + - 모든 블록과 뷰(리스트/칸반, 서브태스크/노트 스플릿 비율 등)는 사용자가 원하는 대로 직관적으로 조절 가능해야 함. + - 불필요한 조잡한 툴바를 배제하고 심플하고 직관적인 조작성 유지. +3. **컨텍스트 메뉴**: `ContextMenu.tsx` 사용, 브라우저 기본 메뉴 비활성화. +4. **i18n 무결성**: 새 텍스트는 en/ko/ja 사전에 모두 추가. +5. **모바일 CSS 규칙**: - `detail-panel mobile-open` 클래스가 바텀시트를 제어. - - **절대** `task-detail-panel` 클래스로 되돌리지 말 것 (구버전). - 모바일: `translateY(100%) → translateY(0)` 애니메이션. - 데스크탑: `detail-panel`이 flex 방향 측면 패널. -5. **터치 제스처**: Y축 아래 방향 120px 이상 스와이프 → `onClose()`. 6. **언어**: 사용자 응답 및 에이전트 간 소통은 한국어를 기본으로 할 것. --- @@ -173,6 +190,8 @@ src/ └── lib/ ├── i18n/ (en, ko, ja 사전 및 useI18n 훅) ├── mockData.ts ← Demo 모드용 LocalStorage Store + ├── userPrefs.ts ← 글로벌 사용자 설정 스토어 (v0.6+) + ├── useUserPrefs.ts ← 반응형 prefs 훅 + CSS var 인젝터 ├── auth.ts / prisma.ts ``` @@ -184,7 +203,9 @@ src/ - **`checkpoint-v2.0`**: i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재 - **`checkpoint-v2.1`**: 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장 - **`checkpoint-v2.2`**: 하위 태스크 실시간 동기화 & 인라인 즉시 수정 UX -- **`checkpoint-v3.0`**: **(현재 최신)** 부드러운 사이드바 애니메이션, 테마 버튼 잘림 완벽 해결, Import 기능 프로필 메뉴 이동, 커스텀 웹앱 우클릭 컨텍스트 메뉴, TickTick 스타일 우측 상세 탭 완비 +- **`checkpoint-v3.0`**: 부드러운 사이드바 애니메이션, 테마 버튼 잘림 완벽 해결, Import 기능 프로필 메뉴 이동, 커스텀 웹앱 우클릭 컨텍스트 메뉴, TickTick 스타일 우측 상세 탭 완비 +- **`checkpoint-v0.5.0`**: 버전 체계 0.x.x 전환, VSCode 무채색 다크 테마, 미니멀 에디터(툴바 제거), 모듈형 블록 스왑/리사이저, 칸반 보드 Labs, 설정 Labs 탭 신설 +- **`checkpoint-v0.6.0` (현재 최신)**: 글로벌 `UserPrefs` 시스템(`userPrefs.ts` / `useUserPrefs.ts`) 도입, 사이드바 드래그 리사이저, 디테일 패널 드래그 리사이저, Labs 탭 확장(밀도/폰트/라운드/애니메이션/색조/채도/패널 너비), 뷰 전환(리스트↔칸반) 페이지 리로드 없이 반응, 파스텔 매트 라이트 팔레트 적용 --- @@ -192,4 +213,14 @@ src/ 1. **메모장 우선 원칙**: 우측 상세 탭에서 메모장은 항상 메인 영역(`flex: 1`)을 차지하도록 유지할 것. 2. **웹앱 인터랙션**: 브라우저 기본 컨텍스트 메뉴 대신 `ContextMenu.tsx`를 통해 앱스러운 UX를 유지할 것. -3. **i18n 무결성**: 텍스트 추가 시 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것. \ No newline at end of file +3. **i18n 무결성**: 텍스트 추가 시 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것. + + + +# 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. + + diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 8c8fbf9..45ab178 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "checkflow", - "version": "0.1.0", + "version": "0.5.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 054ba9b..e71bec1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -56,10 +56,15 @@ export default function AdminPage() { const [totalTasks, setTotalTasks] = useState(67); const [totalLists, setTotalLists] = useState(9); + // 어드민 권한 판별 + const userRole = session?.user?.role; + const userEmail = session?.user?.email; + const isAdmin = userRole === "ADMIN" || userEmail?.endsWith("@checkflow.local") || userEmail?.startsWith("admin@"); + // 인증 게이트: 비로그인 시 /login으로 리디렉션 useEffect(() => { if (status === "unauthenticated") { - router.replace("/login"); + router.replace("/login?callbackUrl=/admin"); } }, [status, router]); @@ -75,7 +80,30 @@ export default function AdminPage() {
🔒
-

Checking access...

+

Checking administrator credentials...

+
+
+ ); + } + + // 비인가 사용자(일반 유저) 차단 화면 + if (!isAdmin) { + return ( +
+
+
🛡️
+

Access Restricted

+

+ This console requires ADMIN privileges. Your account ({session?.user?.email}) does not have authorization to view multi-user system telemetry. +

+
+ + Open Demo + + + Return to Tasks + +
); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 081b711..bf916c0 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -59,10 +59,22 @@ export async function POST(req: NextRequest) { else { current += ch; } } - const title = titleIdx >= 0 ? fields[titleIdx]?.replace(/"/g, "") : ""; + // Formula Injection 방어 함수: =, +, -, @, \t, \r 등으로 시작할 경우 안전하게 이스케이프 + const sanitizeFormula = (val: string | null | undefined): string => { + if (!val) return ""; + const trimmed = val.trim(); + if (/^[=+\-@\t\r]/.test(trimmed)) { + return `'${trimmed}`; + } + return trimmed; + }; + + const rawTitle = titleIdx >= 0 ? fields[titleIdx]?.replace(/"/g, "") : ""; + const title = sanitizeFormula(rawTitle); if (!title) continue; - const note = noteIdx >= 0 ? fields[noteIdx]?.replace(/"/g, "") : null; + const rawNote = noteIdx >= 0 ? fields[noteIdx]?.replace(/"/g, "") : null; + const note = rawNote ? sanitizeFormula(rawNote) : null; const dueRaw = dueIdx >= 0 ? fields[dueIdx] : null; const priorityRaw = priorityIdx >= 0 ? fields[priorityIdx]?.toLowerCase() : "0"; const completedRaw = completedIdx >= 0 ? fields[completedIdx]?.toLowerCase() : ""; diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index f33382d..bcc42cd 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -36,12 +36,32 @@ export async function PATCH(req: NextRequest, { params }: Ctx) { if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 }); // Sanitize allowed fields - const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId"]; + const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId"]; const data: Record = {}; for (const key of allowed) { if (key in body) data[key] = body[key]; } + // IDOR 방어: listId 변경 시 대상 목록이 사용자 소유인지 검증 + if ("listId" in data && typeof data.listId === "string") { + const targetList = await prisma.list.findFirst({ + where: { id: data.listId, userId: session.user.id }, + }); + if (!targetList) { + return NextResponse.json({ error: "Target list not found or forbidden" }, { status: 403 }); + } + } + + // IDOR 방어: parentId 변경 시 대상 부모 태스크가 사용자 소유인지 검증 + if ("parentId" in data && data.parentId) { + const targetParent = await prisma.task.findFirst({ + where: { id: data.parentId as string, userId: session.user.id }, + }); + if (!targetParent) { + return NextResponse.json({ error: "Target parent task not found or forbidden" }, { status: 403 }); + } + } + // Auto-set completedAt if ("completed" in data) { data.completedAt = data.completed ? new Date() : null; diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index dd035a2..7409d08 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -48,6 +48,16 @@ export async function POST(req: NextRequest) { const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } }); if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 }); + // IDOR 방어: parentId가 지정된 경우, 부모 태스크가 현재 사용자의 소유인지 검증 + if (parentId) { + const parentTask = await prisma.task.findFirst({ + where: { id: parentId, userId: session.user.id }, + }); + if (!parentTask) { + return NextResponse.json({ error: "Parent task not found or forbidden" }, { status: 403 }); + } + } + const count = await prisma.task.count({ where: { listId, parentId: parentId || null } }); const task = await prisma.task.create({ data: { diff --git a/src/app/globals.css b/src/app/globals.css index 190581d..49ef4ee 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,47 +4,47 @@ DESIGN TOKENS ============================================ */ :root { - /* Brand */ - --accent: #4B7BF5; - --accent-hover: #3A6AE4; - --accent-light: rgba(75, 123, 245, 0.1); - --accent-medium: rgba(75, 123, 245, 0.18); + /* Brand — muted, desaturated pastel accent */ + --accent: hsl(220, 68%, 56%); + --accent-hover: hsl(220, 68%, 47%); + --accent-light: hsla(220, 68%, 56%, 0.1); + --accent-medium: hsla(220, 68%, 56%, 0.18); - /* Backgrounds */ - --bg-primary: #FFFFFF; - --bg-secondary: #F7F8FC; - --bg-sidebar: #F2F4F8; - --bg-hover: rgba(0, 0, 0, 0.04); - --bg-active: rgba(75, 123, 245, 0.08); - --bg-overlay: rgba(0, 0, 0, 0.5); + /* Backgrounds — warm off-white, matte */ + --bg-primary: #FAFAF9; + --bg-secondary: #F4F4F2; + --bg-sidebar: #EFEFF0; + --bg-hover: rgba(0, 0, 0, 0.045); + --bg-active: hsla(220, 68%, 56%, 0.09); + --bg-overlay: rgba(0, 0, 0, 0.45); - /* Text */ - --text-primary: #1A1A2E; - --text-secondary: #6B7280; - --text-tertiary: #9CA3AF; + /* Text — slightly warm */ + --text-primary: #18181B; + --text-secondary: #71717A; + --text-tertiary: #A1A1AA; --text-on-accent: #FFFFFF; - /* Border */ - --border: rgba(0, 0, 0, 0.08); - --border-medium: rgba(0, 0, 0, 0.12); - --border-strong: rgba(0, 0, 0, 0.2); + /* Border — barely visible */ + --border: rgba(0, 0, 0, 0.07); + --border-medium: rgba(0, 0, 0, 0.11); + --border-strong: rgba(0, 0, 0, 0.19); - /* Priority colors */ - --priority-high: #EF4444; - --priority-medium: #F59E0B; - --priority-low: #10B981; + /* Priority colors — desaturated pastels */ + --priority-high: #E05252; + --priority-medium: #E8931A; + --priority-low: #3DAD84; /* Status */ - --success: #10B981; - --danger: #EF4444; - --warning: #F59E0B; + --success: #3DAD84; + --danger: #E05252; + --warning: #E8931A; - /* Shadows */ - --shadow-xs: 0 1px 2px rgba(0,0,0,0.06); - --shadow-sm: 0 2px 8px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04); - --shadow-md: 0 4px 16px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06); - --shadow-lg: 0 8px 32px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.06); - --shadow-panel: -4px 0 24px rgba(0,0,0,0.08); + /* Shadows — very soft */ + --shadow-xs: 0 1px 2px rgba(0,0,0,0.05); + --shadow-sm: 0 2px 8px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 16px rgba(0,0,0,0.09), 0 2px 4px rgba(0,0,0,0.05); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.05); + --shadow-panel: -4px 0 24px rgba(0,0,0,0.07); /* Radius */ --radius-xs: 4px; @@ -63,27 +63,32 @@ /* Layout */ --sidebar-width: 260px; - --detail-width: min(580px, 50vw); + --detail-width: 520px; --header-height: 56px; + + /* Dynamic vars (overridden by prefs) */ + --font-size-base: 14px; + --task-item-padding: 10px 20px; + --task-item-gap: 4px; } -/* Dark mode */ +/* VSCode-inspired Achromatic Dark Theme */ [data-theme="dark"] { - --bg-primary: #111827; - --bg-secondary: #1F2937; - --bg-sidebar: #161D2B; - --bg-hover: rgba(255, 255, 255, 0.05); - --bg-active: rgba(75, 123, 245, 0.12); - --text-primary: #F9FAFB; - --text-secondary: #9CA3AF; - --text-tertiary: #6B7280; - --border: rgba(255, 255, 255, 0.08); - --border-medium: rgba(255, 255, 255, 0.12); - --border-strong: rgba(255, 255, 255, 0.2); - --shadow-sm: 0 2px 8px rgba(0,0,0,0.3); - --shadow-md: 0 4px 16px rgba(0,0,0,0.4); - --shadow-lg: 0 8px 32px rgba(0,0,0,0.5); - --shadow-panel: -4px 0 24px rgba(0,0,0,0.4); + --bg-primary: #1e1e1e; + --bg-secondary: #252526; + --bg-sidebar: #181818; + --bg-hover: rgba(255, 255, 255, 0.07); + --bg-active: rgba(59, 130, 246, 0.16); + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + --border: #2d2d2d; + --border-medium: #383838; + --border-strong: #4a4a4a; + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6); + --shadow-panel: -4px 0 24px rgba(0, 0, 0, 0.5); } /* ============================================ @@ -103,7 +108,7 @@ html { body { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-size: 14px; + font-size: var(--font-size-base, 14px); line-height: 1.5; color: var(--text-primary); background: var(--bg-primary); @@ -165,6 +170,7 @@ a { color: inherit; text-decoration: none; } transition: width var(--dur-slow) var(--ease-out), transform var(--dur-slow) var(--ease-out); z-index: 20; + min-width: 0; } .sidebar.collapsed { @@ -413,7 +419,7 @@ a { color: inherit; text-decoration: none; } display: flex; align-items: flex-start; gap: 10px; - padding: 10px 20px; + padding: var(--task-item-padding, 10px 20px); cursor: pointer; transition: background var(--dur-fast); position: relative; @@ -629,6 +635,8 @@ a { color: inherit; text-decoration: none; } transform: translateX(0); transition: width var(--dur-slow) var(--ease-out), transform var(--dur-slow) var(--ease-out); + min-width: 280px; + max-width: 800px; } .detail-panel.closed { @@ -672,6 +680,17 @@ a { color: inherit; text-decoration: none; } gap: 16px; } +/* The main scrolling content area inside the detail panel */ +.detail-scroll { + flex: 1; + overflow-y: auto; + padding: 14px 16px 20px; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; +} + .detail-title-input { font-size: 18px; font-weight: 600; @@ -1796,3 +1815,215 @@ a { color: inherit; text-decoration: none; } color: var(--text-primary); border-color: var(--border-medium); } + +.tick-meta-chip.active { + background: var(--accent-light); + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.desktop-only { + display: inline; +} + +@media (max-width: 768px) { + .desktop-only { + display: none; + } +} + +/* ============================================ + ADD TASK BAR & DROPDOWNS + ============================================ */ +.add-task-bar { + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg-primary); + position: relative; + z-index: 30; +} + +.add-task-input { + width: 100%; + padding: 8px 12px; + font-size: 14px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + outline: none; + transition: border-color var(--dur-fast), box-shadow var(--dur-fast); +} + +.add-task-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); + background: var(--bg-primary); +} + +.dropdown { + position: absolute; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(16px); + z-index: 200; + animation: dropdownIn var(--dur-fast) var(--ease-out); +} + +/* ============================================ + MODULAR SPLIT RESIZER & DETAIL BLOCKS + ============================================ */ +.detail-block-card { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + overflow: hidden; + transition: border-color var(--dur-fast), box-shadow var(--dur-fast); +} + +.detail-block-card:hover { + border-color: var(--border-medium); +} + +.detail-block-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + user-select: none; +} + +.detail-split-resizer { + height: 6px; + margin: 2px 0; + cursor: row-resize; + display: flex; + align-items: center; + justify-content: center; + transition: background var(--dur-fast); + border-radius: 3px; + flex-shrink: 0; +} + +.detail-split-resizer:hover, +.detail-split-resizer.dragging { + background: var(--accent-light); +} + +.detail-split-resizer-line { + width: 32px; + height: 2px; + background: var(--border-medium); + border-radius: 1px; + transition: width var(--dur-fast), background var(--dur-fast); +} + +.detail-split-resizer:hover .detail-split-resizer-line, +.detail-split-resizer.dragging .detail-split-resizer-line { + width: 48px; + background: var(--accent); +} + +/* ============================================ + KANBAN BOARD VIEW + ============================================ */ +.kanban-container { + display: flex; + gap: 16px; + height: 100%; + padding: 16px 20px; + overflow-x: auto; + align-items: flex-start; +} + +.kanban-column { + flex: 1; + min-width: 280px; + max-width: 360px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + max-height: 100%; +} + +.kanban-column-header { + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); +} + +.kanban-column-body { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + flex: 1; +} + +.kanban-card { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px 14px; + cursor: pointer; + box-shadow: var(--shadow-xs); + transition: transform var(--dur-fast), box-shadow var(--dur-fast), border-color var(--dur-fast); +} + +.kanban-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-sm); + border-color: var(--accent); +} + +.kanban-card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + +/* View Switcher Button */ +.view-switcher-group { + display: inline-flex; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 2px; + gap: 2px; +} + +.view-switcher-btn { + padding: 4px 8px; + font-size: 12px; + font-weight: 500; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: var(--radius-xs); + cursor: pointer; + transition: all var(--dur-fast); + display: inline-flex; + align-items: center; + gap: 4px; +} + +.view-switcher-btn.active { + background: var(--bg-primary); + color: var(--text-primary); + font-weight: 600; + box-shadow: var(--shadow-xs); +} + + + diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 1cb021b..23360b3 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { Sidebar } from "./Sidebar"; import { TaskList, Task, List, User } from "../tasks/TaskList"; +import { useUserPrefs, usePrefsStyle } from "@/lib/useUserPrefs"; import { TaskDetail } from "../tasks/TaskDetail"; import { CommandPalette } from "../ui/CommandPalette"; import { @@ -9,6 +10,7 @@ import { saveDemoStore, MockList, MockTask, + MockTag, updateTaskInTree, moveToTrashInTree, restoreTaskInTree, @@ -37,6 +39,58 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + // Global user preferences (reactive) + const { prefs, updatePrefs } = useUserPrefs(); + const prefsStyle = usePrefsStyle(prefs); + + // Sidebar resize drag + const sidebarResizing = useRef(false); + const handleSidebarResizeStart = (e: React.MouseEvent) => { + e.preventDefault(); + sidebarResizing.current = true; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + const onMove = (ev: MouseEvent) => { + if (!sidebarResizing.current) return; + const newW = Math.max(160, Math.min(420, ev.clientX)); + updatePrefs({ sidebarWidth: newW }); + }; + const onUp = () => { + sidebarResizing.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }; + + // Detail panel resize drag + const detailResizing = useRef(false); + const handleDetailResizeStart = (e: React.MouseEvent) => { + e.preventDefault(); + detailResizing.current = true; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + const onMove = (ev: MouseEvent) => { + if (!detailResizing.current) return; + const newW = Math.max(300, Math.min(760, window.innerWidth - ev.clientX)); + updatePrefs({ detailWidth: newW }); + }; + const onUp = () => { + detailResizing.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }; + // Listen for Ctrl+K event useEffect(() => { const handler = () => setCmdPaletteOpen(true); @@ -167,7 +221,12 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { refresh(); }; - const handleDemoAddTask = (title: string, listId: string, parentId: string | null = null) => { + const handleDemoAddTask = ( + title: string, + listId: string, + parentId: string | null = null, + meta?: { dueDate?: string | null; priority?: number; tags?: { tag: MockTag }[] } + ) => { const newTask: Task = { id: "demo-task-" + Date.now(), listId, @@ -176,15 +235,15 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { note: null, completed: false, completedAt: null, - dueDate: null, - priority: 0, + dueDate: meta?.dueDate || null, + priority: meta?.priority || 0, sortOrder: tasks.length, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: null, isDeleted: false, children: [], - tags: [], + tags: meta?.tags || [], }; const store = getDemoStore(); const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask); @@ -311,6 +370,9 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { return (
+ {/* Dynamic CSS variable injection from user prefs */} + + {/* Sidebar 모바일 오버레이 */} {sidebarOpen && (
+ {/* Sidebar resize handle */} +
{ (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }} + onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} + /> +
{selectedTask && ( - setSelectedTask(null)} - onUpdate={handleTaskUpdate} - onDelete={() => { - setSelectedTask(null); - refresh(); - }} - listId={selectedTask.listId} - listName={lists.find((l) => l.id === selectedTask.listId)?.name} - isDemo={isDemo} - onDemoUpdateTask={handleDemoUpdateTask} - onDemoDeleteTask={handleDemoDeleteTask} - /> + <> + {/* Detail panel resize handle (left edge) */} +
{ (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }} + onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} + /> + setSelectedTask(null)} + onUpdate={handleTaskUpdate} + onDelete={() => { + setSelectedTask(null); + refresh(); + }} + listId={selectedTask.listId} + listName={lists.find((l) => l.id === selectedTask.listId)?.name} + lists={lists} + onMoveList={async (targetListId) => { + if (isDemo) { + const store = getDemoStore(); + const updated = { ...selectedTask, listId: targetListId }; + const newTasks = updateTaskInTree(store.tasks, updated as MockTask); + saveDemoStore(store.lists, newTasks); + handleTaskUpdate(updated); + } else { + await fetch(`/api/tasks/${selectedTask.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ listId: targetListId }), + }); + handleTaskUpdate({ ...selectedTask, listId: targetListId }); + } + refresh(); + }} + isDemo={isDemo} + onDemoUpdateTask={handleDemoUpdateTask} + onDemoDeleteTask={handleDemoDeleteTask} + /> + )} {/* Global Command Palette (Ctrl+K) */} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 907fa2a..f344a92 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ -"use client"; -import React, { useState, useEffect, useRef } from "react"; +"use client"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { signOut } from "next-auth/react"; import { useI18n } from "@/lib/i18n"; import { useTheme } from "@/app/providers"; @@ -11,7 +11,7 @@ import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore } from "@/lib/mo interface User { id: string; name?: string | null; email?: string | null } interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } } -const LIST_COLORS = ["#4B7BF5", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316", "#6366F1", "#14B8A6"]; +const LIST_COLORS = ["#5B8DEF", "#E05252", "#3DAD84", "#E8931A", "#8B6CF7", "#E4609B", "#0ABAD1", "#F17A3B", "#6875F5", "#14B8A6"]; interface SidebarProps { user: User; @@ -30,6 +30,13 @@ interface SidebarProps { onDemoDeleteList?: (id: string) => void; } +/* ===================== UNDO TOAST ===================== */ +interface UndoToastState { + message: string; + onUndo: () => void; + timeoutId: ReturnType | null; +} + export function Sidebar({ user, lists, @@ -64,6 +71,18 @@ export function Sidebar({ // Context Menu for Lists const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null); + // Undo Toast for deleted list + const [undoToast, setUndoToast] = useState(null); + + // Inline rename + const [renamingListId, setRenamingListId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + + // Search expansion + const [searchExpanded, setSearchExpanded] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const inputRef = useRef(null); const fileRef = useRef(null); @@ -92,17 +111,34 @@ export function Sidebar({ if (showNewList) setTimeout(() => inputRef.current?.focus(), 50); }, [showNewList]); + useEffect(() => { + if (searchExpanded) { + setTimeout(() => searchInputRef.current?.focus(), 60); + } + }, [searchExpanded]); + + // Dismiss undo toast on unmount + useEffect(() => { + return () => { + if (undoToast?.timeoutId) clearTimeout(undoToast.timeoutId); + }; + }, [undoToast]); + + const showUndoToast = useCallback((message: string, onUndo: () => void) => { + if (undoToast?.timeoutId) clearTimeout(undoToast.timeoutId); + const tid = setTimeout(() => setUndoToast(null), 5000); + setUndoToast({ message, onUndo, timeoutId: tid }); + }, [undoToast]); + const createList = async () => { const name = newListName.trim(); if (!name) return; - if (isDemo) { if (onDemoCreateList) onDemoCreateList(name, newListColor); setNewListName(""); setShowNewList(false); return; } - try { const res = await fetch("/api/lists", { method: "POST", @@ -122,35 +158,72 @@ export function Sidebar({ }; const deleteList = async (id: string) => { - if (!confirm(t("deleteListConfirm"))) return; + // Store list for undo + const listToDelete = lists.find((l) => l.id === id); + if (!listToDelete) return; + + // Optimistic: remove from UI immediately + setLists((prev) => { + const updated = prev.filter((l) => l.id !== id); + if (selectedListId === id && updated.length > 0) onListSelect(updated[0].id); + return updated; + }); if (isDemo) { if (onDemoDeleteList) onDemoDeleteList(id); + showUndoToast(t("listDeleted"), () => { + // Undo: re-add the list (demo mode — restore optimistically) + setLists((prev) => { + if (prev.find((l) => l.id === id)) return prev; + return [...prev, listToDelete].sort((a, b) => a.name.localeCompare(b.name)); + }); + onListSelect(id); + }); return; } try { await fetch(`/api/lists/${id}`, { method: "DELETE" }); - setLists((prev) => { - const updated = prev.filter((l) => l.id !== id); - if (selectedListId === id) onListSelect(updated[0]?.id || ""); - return updated; - }); } catch (err) { console.error("Failed to delete list", err); + // Rollback + setLists((prev) => [...prev, listToDelete]); + } + showUndoToast(t("listDeleted"), () => { + // For API mode, re-fetch (simplest undo simulation) + fetch("/api/lists") + .then((r) => r.ok ? r.json() : []) + .then((data) => { if (Array.isArray(data)) setLists(data); }) + .catch(() => {}); + }); + }; + + const renameList = async (id: string, newName: string) => { + const trimmed = newName.trim(); + if (!trimmed) return; + setLists((prev) => prev.map((l) => l.id === id ? { ...l, name: trimmed } : l)); + setRenamingListId(null); + if (!isDemo) { + try { + await fetch(`/api/lists/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: trimmed }), + }); + } catch (err) { + console.error("Failed to rename list", err); + } } }; const handleImport = async () => { const file = fileRef.current?.files?.[0]; if (!file || !importListId) return; - if (isDemo) { setImportResult("✓ Demo Mode: Import simulated successfully"); setTimeout(() => { setShowImport(false); setImportResult(""); }, 1500); return; } - setImporting(true); try { const formData = new FormData(); @@ -180,10 +253,21 @@ export function Sidebar({ const initials = user.name?.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?"; - // List context menu items + // Context menu items for list (right-click) const getListContextMenuItems = (listId: string): MenuItem[] => [ { - label: t("deleteListConfirm").split("?")[0], + label: t("renameList"), + icon: "✏️", + onClick: () => { + const list = lists.find((l) => l.id === listId); + if (list) { + setRenamingListId(listId); + setRenameValue(list.name); + } + }, + }, + { + label: t("deleteList"), icon: "🗑️", danger: true, onClick: () => deleteList(listId), @@ -193,7 +277,7 @@ export function Sidebar({ return ( ); } \ No newline at end of file diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 0483d01..632c8e8 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -1,8 +1,9 @@ -"use client"; +"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"; +import { useUserPrefs } from "@/lib/useUserPrefs"; interface SettingsModalProps { isOpen: boolean; @@ -11,11 +12,125 @@ interface SettingsModalProps { isDemo?: boolean; } +/** A toggle-row component used throughout Labs tab */ +function LabsRow({ + label, + desc, + children, +}: { + label: string; + desc?: string; + children: React.ReactNode; +}) { + return ( +
+
+
{label}
+ {desc && ( +
+ {desc} +
+ )} +
+
{children}
+
+ ); +} + +/** Segmented control (radio-style) */ +function SegmentedControl({ + value, + options, + onChange, +}: { + value: T; + options: { label: string; value: T }[]; + onChange: (v: T) => void; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +/** Slider with live value display */ +function PrefSlider({ + min, + max, + value, + onChange, + unit, +}: { + min: number; + max: number; + value: number; + onChange: (v: number) => void; + unit?: string; +}) { + return ( +
+ onChange(parseInt(e.target.value, 10))} + style={{ width: 120, accentColor: "var(--accent)" }} + /> + + {value}{unit} + +
+ ); +} + export function SettingsModal({ isOpen, onClose, user, isDemo = false }: SettingsModalProps) { const { t, lang, setLang } = useI18n(); const { theme, toggleTheme } = useTheme(); + const { prefs, updatePrefs, resetToDefaults } = useUserPrefs(); - const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "sync" | "admin">("profile"); + const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile"); const [displayName, setDisplayName] = useState(user.name || "Demo User"); const [email, setEmail] = useState(user.email || "demo@checkflow.local"); const [password, setPassword] = useState(""); @@ -51,199 +166,377 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting const calDavUrl = typeof window !== "undefined" ? `${window.location.origin}/api/dav` : "https://todo.yourdomain.com/api/dav"; + // Accent hue preview + const hue = prefs.accentHue; + const sat = Math.round(prefs.saturation * 0.9); + const accentPreview = `hsl(${hue}, ${sat}%, 54%)`; + + const tabs = [ + { id: "profile", label: `👤 ${t("profile") || "Profile"}` }, + { id: "preferences", label: `⚙️ ${t("preferences") || "Preferences"}` }, + { id: "labs", label: `🧪 ${t("labs") || "Labs"}` }, + { id: "sync", label: `📱 ${t("syncIntegrations") || "Integrations"}` }, + { id: "admin", label: `👑 ${t("admin") || "Admin"}` }, + ] as const; + return (
-
e.stopPropagation()} style={{ maxWidth: 640, width: "90%" }}> +
e.stopPropagation()} + style={{ maxWidth: 660, width: "92%", padding: "24px 24px 20px", maxHeight: "90vh", display: "flex", flexDirection: "column" }} + > {/* Header */} -
-

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

+
+

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

{/* Tab Bar */} -
- - - - +
+ {tabs.map((tab) => ( + + ))}
- {/* 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)" - /> -
-
- )} + {/* Scrollable tab content */} +
- {/* Tab 2: Preferences (Trash Retention, Theme, Language) */} - {activeTab === "preferences" && ( -
- {/* Trash Retention Days */} -
- -

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

- 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 */} + {activeTab === "preferences" && ( +
+
+ +

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

+ +
+
+ + +
+
+ +
+ {[ + { key: "system", icon: "💻", label: t("themeSystem") }, + { key: "light", icon: "☀️", label: t("themeLight") }, + { key: "dark", icon: "🌙", label: t("themeDark") }, + ].map(({ key, icon, label }) => ( + + ))} +
+
+
+ )} + + {/* Tab 3: 🧪 CheckFlow Labs */} + {activeTab === "labs" && ( +
+ {/* Section: View */} +
+ View +
+ + - - - - - -
+ updatePrefs({ viewMode: v })} + /> + - {/* Language Selection */} -
- - -
+ {/* Section: Layout */} +
+ Layout +
- {/* 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. -

-
- -
- - + {/* Tab: 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. +
-
- 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 → - -
- )} + {/* 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}}
diff --git a/src/components/tasks/KanbanView.tsx b/src/components/tasks/KanbanView.tsx new file mode 100644 index 0000000..fe8f5fb --- /dev/null +++ b/src/components/tasks/KanbanView.tsx @@ -0,0 +1,221 @@ +"use client"; +import React, { useState } from "react"; +import { Task } from "./TaskList"; +import { useI18n } from "@/lib/i18n"; + +interface KanbanViewProps { + tasks: Task[]; + selectedTaskId: string | null; + onSelectTask: (task: Task) => void; + onToggleTask: (id: string, completed: boolean) => void; + onAddTask: (title: string, priority?: number) => void; +} + +export function KanbanView({ + tasks, + selectedTaskId, + onSelectTask, + onToggleTask, + onAddTask, +}: KanbanViewProps) { + const { t } = useI18n(); + const [newTodoTitle, setNewTodoTitle] = useState(""); + const [newProgTitle, setNewProgTitle] = useState(""); + + const PRIORITY_COLORS = ["", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"]; + + // Group tasks into 3 columns + const todoTasks = tasks.filter((task) => !task.completed && task.priority < 2 && !task.parentId); + const inProgressTasks = tasks.filter((task) => !task.completed && task.priority >= 2 && !task.parentId); + const doneTasks = tasks.filter((task) => task.completed && !task.parentId); + + const renderCard = (task: Task) => { + const isSelected = selectedTaskId === task.id; + const subtaskCount = task.children?.length || 0; + const completedSubtasks = task.children?.filter((s) => s.completed).length || 0; + + return ( +
onSelectTask(task)} + > +
+
+
+ ); + }; + + return ( +
+ {/* Column 1: To Do */} +
+
+
+ + {t("todoCol")} +
+ + {todoTasks.length} + +
+ +
+ {todoTasks.map(renderCard)} + + {/* Quick inline add in To Do */} +
+ setNewTodoTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && newTodoTitle.trim()) { + e.preventDefault(); + onAddTask(newTodoTitle.trim(), 0); + setNewTodoTitle(""); + } + }} + /> +
+
+
+ + {/* Column 2: In Progress / High Priority */} +
+
+
+ + {t("inProgressCol")} +
+ + {inProgressTasks.length} + +
+ +
+ {inProgressTasks.map(renderCard)} + + {/* Quick inline add in In Progress */} +
+ setNewProgTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && newProgTitle.trim()) { + e.preventDefault(); + onAddTask(newProgTitle.trim(), 2); + setNewProgTitle(""); + } + }} + /> +
+
+
+ + {/* Column 3: Done */} +
+
+
+ + {t("doneCol")} +
+ + {doneTasks.length} + +
+ +
+ {doneTasks.map(renderCard)} +
+
+
+ ); +} diff --git a/src/components/tasks/MarkdownNoteEditor.tsx b/src/components/tasks/MarkdownNoteEditor.tsx index f6c1203..d0255b3 100644 --- a/src/components/tasks/MarkdownNoteEditor.tsx +++ b/src/components/tasks/MarkdownNoteEditor.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState, useRef, useCallback, useEffect } from "react"; +import React, { useState, useRef, useEffect } from "react"; import { marked } from "marked"; import DOMPurify from "dompurify"; import { useI18n } from "@/lib/i18n"; @@ -12,7 +12,7 @@ interface MarkdownNoteEditorProps { export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) { const { t } = useI18n(); - const [mode, setMode] = useState<"edit" | "preview">("preview"); + const [mode, setMode] = useState<"edit" | "preview">("edit"); const textareaRef = useRef(null); const previewRef = useRef(null); @@ -26,74 +26,32 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit marked.use({ renderer, breaks: true, gfm: true }); }, []); - // Insert markdown syntax at selection or cursor position - const insertSyntax = useCallback( - (before: string, after = "", placeholder = "") => { - const ta = textareaRef.current; - if (!ta) { - onChange(value + before + placeholder + after); - return; - } - const start = ta.selectionStart ?? ta.value.length; - const end = ta.selectionEnd ?? ta.value.length; - const selected = ta.value.slice(start, end) || placeholder; - const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end); - onChange(newVal); - - requestAnimationFrame(() => { - ta.focus(); - ta.selectionStart = start + before.length; - ta.selectionEnd = start + before.length + selected.length; - }); - }, - [onChange, value] - ); - // Keyboard shortcuts const handleKeyDown = (e: React.KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "b") { - e.preventDefault(); - insertSyntax("**", "**", "bold"); - } else if ((e.ctrlKey || e.metaKey) && e.key === "i") { - e.preventDefault(); - insertSyntax("*", "*", "italic"); - } else if ((e.ctrlKey || e.metaKey) && e.key === "s") { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); if (onSave) onSave(); } else if (e.key === "Tab") { e.preventDefault(); - insertSyntax(" "); + const ta = textareaRef.current; + if (!ta) return; + const start = ta.selectionStart; + const end = ta.selectionEnd; + const newVal = value.slice(0, start) + " " + value.slice(end); + onChange(newVal); + requestAnimationFrame(() => { + ta.selectionStart = ta.selectionEnd = start + 2; + }); } }; - // Toggle interactive checkboxes in markdown preview - const handlePreviewCheckboxToggle = (index: number) => { - const lines = value.split("\n"); - let checkboxCount = 0; - const newLines = lines.map((line) => { - const match = line.match(/^(\s*[-*+]\s*\[)([ xX])(\])(.*)$/); - if (match) { - if (checkboxCount === index) { - const currentChecked = match[2].toLowerCase() === "x"; - const newChecked = currentChecked ? " " : "x"; - checkboxCount++; - return `${match[1]}${newChecked}${match[3]}${match[4]}`; - } - checkboxCount++; - } - return line; - }); - onChange(newLines.join("\n")); - }; - - // Convert markdown to sanitized HTML with interactive checkboxes and autolinks + // Convert markdown to sanitized HTML with safe links const renderMarkdownHtml = () => { if (!value || !value.trim()) { - return `

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

`; + return `

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

`; } try { - // Auto-detect plain URLs and wrap them in markdown links if not already wrapped const textWithLinks = value.replace( /(^|[^"'])(https?:\/\/[^\s<]+)/g, (match, prefix, url) => { @@ -102,263 +60,121 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit } ); - let rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string; + const rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string; - // Replace task list checkboxes with interactive ones - let cbIdx = 0; - rawHtml = rawHtml.replace(/]*type="checkbox"[^>]*checked[^>]*>/gi, () => { - const id = `cb-${cbIdx++}`; - return ``; + return DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS: [ + "h1", "h2", "h3", "h4", "h5", "h6", "p", "a", "span", "strong", "em", "del", "s", + "ul", "ol", "li", "code", "pre", "blockquote", "hr", "br", "table", "thead", "tbody", + "tr", "th", "td" + ], + ALLOWED_ATTR: ["href", "title", "target", "rel", "class", "style"], + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + ADD_ATTR: ["target", "rel", "class"], }); - rawHtml = rawHtml.replace(/]*type="checkbox"[^>]*>/gi, () => { - const id = `cb-${cbIdx++}`; - return ``; - }); - - // XSS 방어: DOMPurify sanitize (링크 target=_blank, class, data-idx 허용) - const sanitized = DOMPurify.sanitize(rawHtml, { - ADD_ATTR: ["target", "rel", "data-idx", "class"], - ALLOW_DATA_ATTR: true, - }); - - return sanitized; } catch { return value; } }; return ( -
- {/* Top Toolbar */} -
- {/* Mode Switcher Tabs */} -
+
+ {/* Minimal Header with Mode Switcher */} +
+ + {t("notes").toUpperCase()} + + +
- -
- -
- - {/* Formatting actions */} -
- - -
- -
- -
- - - -
- -
- -
- - - - - - -
- {/* Editor / Preview Content Area */} - {mode === "edit" ? ( -