From a8574be2fb08c5c65c230cccb92dd1bcb82c1443 Mon Sep 17 00:00:00 2001
From: Wonhee Han <47270724+neruhan01@users.noreply.github.com>
Date: Fri, 21 Aug 2026 10:25:00 +0900
Subject: [PATCH] feat: implement user preference management with persistence,
reactive CSS variables, and layout customization
---
AGENTS.md | 79 +-
next-env.d.ts | 4 +-
package.json | 2 +-
src/app/admin/page.tsx | 32 +-
src/app/api/import/route.ts | 16 +-
src/app/api/tasks/[id]/route.ts | 22 +-
src/app/api/tasks/route.ts | 10 +
src/app/globals.css | 335 ++++++--
src/components/layout/AppShell.tsx | 153 +++-
src/components/layout/Sidebar.tsx | 379 +++++++--
src/components/settings/SettingsModal.tsx | 639 ++++++++++----
src/components/tasks/KanbanView.tsx | 221 +++++
src/components/tasks/MarkdownNoteEditor.tsx | 392 +++------
src/components/tasks/TaskDetail.tsx | 873 +++++++++++---------
src/components/tasks/TaskList.tsx | 286 ++++++-
src/lib/auth.ts | 19 +-
src/lib/i18n/index.tsx | 292 +++++--
src/lib/useUserPrefs.ts | 54 ++
src/lib/userPrefs.ts | 151 ++++
src/middleware.ts | 73 ++
src/proxy.ts | 25 -
src/types/next-auth.d.ts | 3 +
tsconfig.tsbuildinfo | 1 +
23 files changed, 2915 insertions(+), 1146 deletions(-)
create mode 100644 src/components/tasks/KanbanView.tsx
create mode 100644 src/lib/useUserPrefs.ts
create mode 100644 src/lib/userPrefs.ts
create mode 100644 src/middleware.ts
delete mode 100644 src/proxy.ts
create mode 100644 tsconfig.tsbuildinfo
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 (