feat: initial commit

This commit is contained in:
2026-08-21 15:22:17 +09:00
commit c65fff4dfd
94 changed files with 20244 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Project Current Status & Rules
- **Objective:** Maintain, review, and enhance the CheckFlow self-hosted TickTick-style task management web application according to specifications in `AGENTS.md`, ensuring full feature integrity, security compliance, clean code quality, and responsive UX across all devices.
- **Core Architecture & Source Structure:**
- `src/app/`: Next.js App Router root layouts, providers, auth/demo/admin views, and REST/CalDAV API endpoints (`/api/tasks`, `/api/lists`, `/api/import`, `/api/dav/[...path]`).
- `src/components/layout/`: `AppShell.tsx` (3-panel main layout, resizer coordination, mobile drawer overlay), `Sidebar.tsx` (draggable resizer, lists tree, tag & trash navigation, profile & import popover).
- `src/components/tasks/`:
- `TaskList.tsx`: Recursive n-depth hierarchical checklist, inline title editing, context menu, smart quick-add toolbar.
- `TaskDetail.tsx`: Right-side detail panel, modular block swap (Subtasks ⇄ Markdown Note), mouse drag split resizer, mobile bottom-sheet with swipe-down dismissal.
- `MarkdownNoteEditor.tsx`: Distraction-free text/markdown note canvas with DOMPurify sanitization & preview rendering.
- `KanbanView.tsx`: CheckFlow Labs 3-column Kanban board view (`To Do` / `In Progress` / `Done`).
- `src/components/settings/`: `SettingsModal.tsx` (Profile, Preferences, Labs, Integrations/DAVx⁵, Admin tabs).
- `src/lib/`: `userPrefs.ts` (global user customization state store), `useUserPrefs.ts` (reactive preferences hook & CSS variable injector), `i18n/` (EN/KO/JA localization dictionary), `mockData.ts` (LocalStorage demo store), `auth.ts`, `prisma.ts`.
- `src/middleware.ts`: Route-level auth and admin role verification guard.
- **Completed So Far:**
- Conducted full audit of codebase against all specification items in `AGENTS.md`.
- Verified 100% implementation of core features: n-depth recursive checklist, real-time subtask synchronization, modular blocks & split resizer, 3-way theme (VSCode dark / pastel light / system), global `UserPrefs` customization, Labs Kanban view, and mobile bottom sheet.
- Enhanced CalDAV / DAVx⁵ synchronization endpoint (`src/app/api/dav/[...path]/route.ts`) with full RFC 4791 support: `OPTIONS`, `PROPFIND` (principal & calendar collection discovery), `REPORT` (calendar-query/multiget), `PUT` (task upsert from VTODO), and `DELETE`.
- Upgraded Settings Modal "Integrations (CalDAV)" tab with multi-platform interactive guides (Android/DAVx⁵, Apple Reminders, Thunderbird), one-click URL copy feedback, ICS feed direct download, and live endpoint test ping.
- Implemented full-fidelity standard Task Export pair (`/api/export`) supporting RFC 4180 / TickTick-compatible CSV with UTF-8 BOM, and RFC 5545 iCalendar (`.ics` VTODO) format.
- Added Export modal in Sidebar user popover menu supporting format selection, target list filtering, completed tasks inclusion toggle, and seamless Demo mode client-side Blob generation.
- Added localized i18n strings for Export across English, Korean, and Japanese.
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
- **Next Steps (Todo):**
- Enhance keyboard accessibility & global shortcuts (shortcuts for switching list/kanban view, quick task navigation, Command Palette bindings).
- Add optional automatic synchronization background polling / webhook integration if requested.
- Plan next feature iterations or custom integrations as requested by user.
- **Caveats & Absolute Rules:**
- **Security & Multi-user Privacy**: All API endpoints must strictly verify session ownership (`userId === session.user.id`) and list ownership before creating/updating/deleting tasks or lists.
- **Sanitization**: Never render raw HTML without DOMPurify; always sanitize CSV/ICS user inputs against formula injection (`sanitizeFormula`).
- **Admin Gatekeeping**: Admin routes must strictly require `role === "ADMIN"` both on server/middleware and client.
- **User Customization Priority**: Any new UI preferences (sizes, positions, order) must be persisted through `userPrefs.ts` and respect the "Reset to Defaults" Labs option.
- **i18n Consistency**: Any newly added UI string must be translated across all 3 language dictionaries (`en`, `ko`, `ja`) in `src/lib/i18n/translations.ts` and `src/lib/i18n/index.tsx`.
- **Mobile Bottom-Sheet Convention**: Maintain touch gestures (`mobile-open` class, Y-axis swipe threshold > 120px) for the detail panel on mobile screens.
+11
View File
@@ -0,0 +1,11 @@
# Database password (change in production!)
POSTGRES_PASSWORD=changeme
# App URL (set to your domain or server IP)
NEXTAUTH_URL=http://localhost:3000
# Auth secret — generate with: openssl rand -base64 32
NEXTAUTH_SECRET=change-this-to-a-random-string
# Port to expose (default 3000)
PORT=3000
+18
View File
@@ -0,0 +1,18 @@
# deps
node_modules/
.pnp
.pnp.js
# next
.next/
out/
# env
.env
.env.local
.env.production
# misc
.DS_Store
*.pem
npm-debug.log*
+179
View File
@@ -0,0 +1,179 @@
# CheckFlow — Agent Handbook & Project History
> 이 문서는 CheckFlow 프로젝트의 전체 개발 내역, 사용자 요구사항, 의사결정 기록, 기술 아키텍처를 보존하여 향후 작업하는 모든 AI 에이전트와 개발자가 일관되게 작업을 이어갈 수 있도록 작성되었습니다.
> **다음 에이전트에게**: 반드시 이 파일을 먼저 읽고, 작업 완료 후 업데이트하라.
---
## 1. 프로젝트 개요 & 핵심 요구사항
사용자의 핵심 요구사항:
1. **TickTick 스타일 To-Do 리스트 독립 웹앱**:
- 체크리스트는 **메인 - 하위(Sub-task)** N단계 계층 구조를 갖추며, 양쪽 패널 간 1:1 실시간 동기화.
- **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 작성한 마크다운이 실제 웹 문서처럼 미려한 GUI 형태로 렌더링(Preview & 안전한 새 탭 링크 지원).
- **인라인 즉시 수정 UX**: 상단 프로젝트 제목 및 태스크 제목을 더블클릭/클릭으로 즉시 수정 가능 (Enter 저장, ESC 취소).
2. **셀프호스팅 & 프라이버시 중심 멀티유저**:
- 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼.
- Docker 배포 지원 (PostgreSQL 16 포함, Dockerfile & docker-compose.yml 완비).
3. **외부 플랫폼 연동 & Import/Export**:
- TickTick 등 외부 플랫폼과의 호환을 위한 **CSV 및 ICS(iCalendar) 파일 Import/Export 완벽 지원** (하단 프로필 메뉴 내 배치).
- Formula Injection 방어(`sanitizeFormula`) 및 Excel 호환 UTF-8 BOM 지원.
- Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV/CardDAV (`/api/dav`) 동기화 지원.
4. **PWA (Progressive Web App)**:
- 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원 (`manifest.json`, `sw.js`).
5. **모바일 바텀시트 드로어**:
- 스마트폰에서 우측 상세 탭이 **바텀 시트**로 아래서 위로 슬라이드업.
- 아래로 120px 이상 스와이프하면 패널 닫힘.
- 오버레이 클릭으로도 패널 닫힘.
6. **디자인 시스템 & 반응형 커스터마이징**:
- VSCode 무채색 다크 테마 및 파스텔 매트 라이트 팔레트 지원.
- 사이드바 및 우측 디테일 패널 마우스 드래그 리사이저.
- 글로벌 `UserPrefs` 시스템(`userPrefs.ts` / `useUserPrefs.ts`) 기반 UI 밀도, 폰트 크기, 모서리 라운드, 테마 색조/채도, 애니메이션 속도 커스터마이징 지원.
---
## 2. 주요 컴포넌트 구조
```
src/
├── app/
│ ├── layout.tsx / page.tsx / providers.tsx
│ ├── demo/page.tsx ← DB 연결 없이 LocalStorage 기반으로 구동되는 완전한 데모 페이지
│ ├── login/page.tsx / register/page.tsx
│ ├── admin/page.tsx ← 어드민 대시보드 (ADMIN 역할 및 통제 게이트)
│ └── api/ (auth, lists, tasks, import, dav)
├── components/
│ ├── layout/
│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너 (실시간 하위 태스크 & 인라인 동기화, 모바일 오버레이)
│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어 셀렉터, 테마 토글, 유저 프로필 팝오버(Import 내장)
│ ├── tasks/
│ │ ├── TaskList.tsx ← 중앙 체크리스트 (N-depth 재귀 트리, 인라인 제목 수정, 우클릭 메뉴)
│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (모듈형 블록 스왑, 스플릿 리사이저, 바텀시트 터치)
│ │ ├── MarkdownNoteEditor.tsx ← 👁️ Preview / ✏️ Edit 탭, 심플 캔버스, DOMPurify XSS 방어
│ │ └── KanbanView.tsx ← CheckFlow Labs 3컬럼 칸반 보드 (To Do / In Progress / Done)
│ ├── settings/
│ │ └── SettingsModal.tsx ← Profile / Preferences / Labs / Sync / Admin 5탭 모달
│ └── ui/
│ ├── LanguageSelector.tsx ← 글래스모피즘 언어 팝오버
│ ├── ContextMenu.tsx ← 커스텀 우클릭 컨텍스트 메뉴
│ └── CommandPalette.tsx ← Ctrl+K 글로벌 명령 팔레트
└── lib/
├── i18n/ (en, ko, ja 사전 및 useI18n 훅)
├── mockData.ts ← Demo LocalStorage Store (trash, tags, settings)
├── userPrefs.ts ← 글로벌 사용자 설정 스토어 (v0.6+)
├── useUserPrefs.ts ← 반응형 prefs 훅 + CSS var 인젝터
├── auth.ts / prisma.ts
```
---
## 3. 롤백 포인트 (Git Tags & Checkpoints)
| Tag / Checkpoint | 내용 | 일자 |
|---|---|---|
| `checkpoint-v0.1.0` (`checkpoint-v1.0`) | 기본 Full-Stack 기반 시점 | 초기 |
| `checkpoint-v0.2.0` (`checkpoint-v2.0`) | i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재 | - |
| `checkpoint-v0.2.1` (`checkpoint-v2.1`) | 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장 | - |
| `checkpoint-v0.2.2` (`checkpoint-v2.2`) | 하위 태스크 실시간 동기화 & 인라인 즉시 수정 UX | - |
| `checkpoint-v0.3.0` (`checkpoint-v3.0`) | 사이드바 애니메이션, Import 기능 프로필 메뉴 이동, 커스텀 우클릭 컨텍스트 메뉴 | - |
| `checkpoint-v0.3.2` | N-depth 재귀 체크리스트, 태그, 휴지통, 설정 모달, 타임스탬프 | - |
| `checkpoint-v0.4.0` | 모바일 바텀시트 드로어, Y축 스와이프 닫기, 모바일 백드롭 오버레이 | 2026-08-20 |
| `checkpoint-v0.5.0` | VSCode 무채색 다크 테마, 에디터 조잡한 툴바 제거 & 순수 캔버스 전환, 모듈형 블록 커스텀 (서브태스크 ↔ 노트 순서 스왑 및 스플릿 리사이저 높이 조절), CheckFlow Labs (3컬럼 칸반 보드 뷰), 기본값 복원(Reset to Defaults), 보안 패치 완료 | 2026-08-21 |
| `checkpoint-v0.6.0` | 글로벌 `UserPrefs` 시스템(`userPrefs.ts` / `useUserPrefs.ts`) 도입, 사이드바 드래그 리사이저, 디테일 패널 드래그 리사이저, Labs 탭 확장(밀도/폰트/라운드/애니메이션/색조/채도/패널 너비), 뷰 전환(리스트↔칸반) 실시간 반응, 파스텔 매트 라이트 팔레트 적용 | 2026-08-21 |
| `checkpoint-v0.6.1` | CalDAV / DAVx⁵ 양방향 동기화 프로토콜 고도화(OPTIONS, PROPFIND, REPORT, PUT, DELETE), 설정 모달 내 플랫폼별(Android DAVx⁵ / Apple Reminders / Thunderbird) 인터랙티브 가이드 및 실시간 엔드포인트 테스트 핑, 다국어 i18n 동기화 | 2026-08-21 |
| `checkpoint-v0.6.2` | **(현재 최신)** 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
---
## 4. 구현 완료 기능 요약
- [x] i18n 3개국어 (영어 기본, 한국어, 일본어 사전 100% 무결성)
- [x] **VSCode 스타일 무채색 다크 테마** (`#181818`, `#1e1e1e`, `#252526`, `#2d2d2d`) 및 파스텔 라이트 테마
- [x] **에디터 툴바 제거 & 심플 캔버스** (조잡한 `- [ ]` 툴바 제거, 순수한 텍스트/마크다운 에디팅)
- [x] **모듈형 상세 패널 (Custom Blocks)**:
- 서브태스크 ↔ 노트 블록 **위치 스왑(⇄ Swap)**
- 마우스 드래그로 높이 비율(15%~85%)을 실시간 조절하는 **스플릿 리사이저(Split Resizer)**
- 각 블록별 독립적인 접기/펼치기
- [x] **🧪 CheckFlow Labs (실험실 기능)**:
- 📋 **리스트 뷰** ↔ 📊 **3컬럼 칸반 보드 뷰 (To Do / In Progress / Done)** 원클릭 전환
- 언제든 초기 순정 상태로 되돌리는 **기본값 복원 (Reset to Defaults)**
- [x] **글로벌 UserPrefs 커스터마이징 (v0.6.0)**:
- 사이드바 너비(160~420px) 및 디테일 패널 너비(300~760px) 드래그 리사이징
- UI 밀도 (Compact / Default / Comfortable)
- 폰트 크기, 라운드 스타일, 애니메이션 속도, 액센트 색조(Hue) & 채도(Saturation) 슬라이더
- [x] Demo 모드 (LocalStorage 기반 완전한 로컬 구동)
- [x] N단계 재귀 체크리스트 (`RecursiveTaskItem`)
- [x] 인라인 제목 수정 (더블클릭/엔터/ESC)
- [x] 커스텀 우클릭 컨텍스트 메뉴 (`ContextMenu.tsx`, 브라우저 기본 메뉴 비활성화)
- [x] Created / Edited 타임스탬프
- [x] 커스텀 태그 (색상 자동 부여)
- [x] 휴지통 (복원/영구삭제/보관 기간 설정)
- [x] 설정 모달 (Profile / Preferences / Labs / Sync / Admin 5탭)
- [x] 어드민 대시보드 (`/admin` - ADMIN 롤 및 권한 통제 게이트)
- [x] 정식 라우트 보호 미들웨어 (`src/middleware.ts` 인증 & 역할 검증)
- [x] API IDOR 및 소유권 교차 검증 (서브태스크 및 리스트 이동)
- [x] CSV / ICS Import Formula Injection 방어 (`sanitizeFormula`)
- [x] Ctrl+K 글로벌 명령 팔레트 (`CommandPalette.tsx`)
- [x] **TickTick CSV/ICS 임포트 및 내보내기 (Import & Export)**:
- 표준 RFC 4180 CSV (TickTick 포맷) 및 RFC 5545 iCalendar (`.ics` VTODO) 완벽 내보내기 (`/api/export` 및 클라이언트 사이드 데모 Blob 다운로드)
- 목록별 필터링 및 완료 태스크 포함 여부 선택 모달
- [x] **CalDAV 양방향 동기화 및 가이드 고도화**:
- RFC 4791 표준 준수: `OPTIONS`, `PROPFIND` (Principal & Collection 탐색), `REPORT` (VTODO 쿼리), `PUT` (태스크 업서트), `DELETE`
- 설정 모달 내 플랫폼별 인터랙티브 가이드 (Samsung Galaxy / DAVx⁵, Apple 미리알림, Thunderbird)
- 실시간 엔드포인트 응답 상태 테스트 및 ICS 피드 다운로드 기능
- [x] **TickTick 스타일 스마트 퀵애드 툴바** (날짜, 우선순위 프리셋)
- [x] **상단 프로젝트 브레드크럼 & 리스트 이동기** (`📁 프로젝트명 ▾`)
- [x] **모바일 바텀시트 드로어** (Y축 120px 스와이프 닫기, 백드롭 오버레이)
- [x] FAB 버튼 (모바일 태스크 추가)
- [x] PWA 매니페스트 및 서비스워커 지원 (`manifest.json`, `sw.js`)
---
## 5. 개발 규칙 및 보안 원칙
1. **최상위 원칙 - 보안, 프라이버시, 안정성**:
- 모든 API 요청은 세션 소유권(`userId === session.user.id`)을 철저히 검증할 것.
- 사용자 입력(마크다운, CSV/ICS 등)은 DOMPurify 및 Formula Sanitize를 반드시 통과할 것.
- 어드민 전용 라우트는 `role === "ADMIN"`을 서버/미들웨어 레벨에서 필터링할 것.
2. **사용자 주도 커스터마이징 & 공간 효율성**:
- 모든 블록과 뷰(리스트/칸반, 서브태스크/노트 스플릿 비율, 패널 너비 등)는 `userPrefs.ts`를 통해 사용자가 직관적으로 조절 가능해야 함.
- 불필요한 조잡한 툴바를 배제하고 심플하고 직관적인 조작성 유지.
3. **컨텍스트 메뉴**: 브라우저 기본 메뉴 대신 `ContextMenu.tsx`를 사용하여 네이티브 웹앱 UX 유지.
4. **i18n 무결성**: 새 텍스트 추가 시 `src/lib/i18n/translations.ts`의 en, ko, ja 사전에 모두 추가할 것.
5. **모바일 CSS 규칙**:
- `detail-panel mobile-open` 클래스가 바텀시트를 제어.
- 모바일: `translateY(100%) → translateY(0)` 애니메이션.
- 데스크탑: `detail-panel`이 flex 방향 측면 패널로 동작.
6. **언어**: 사용자 응답 및 에이전트 간 소통은 한국어를 기본으로 할 것.
---
## 6. 모델 변경 이력
| 시점 | 모델 |
|---|---|
| 초기 ~ Checkpoint v2.x | Claude Sonnet 4.6 |
| Checkpoint v2.x ~ v3.x | Gemini 2.5 Flash |
| Checkpoint v4.0 ~ 현재 | Gemini / Cline |
---
## 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`
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+29
View File
@@ -0,0 +1,29 @@
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+117
View File
@@ -0,0 +1,117 @@
# CheckFlow
> 셀프호스팅 TickTick-like Todo 앱 — 계층형 체크리스트 + 메모장 + CardDAV + PWA
![CheckFlow](public/icons/icon.svg)
## 주요 기능
-**계층형 체크리스트** — 메인 태스크 + 하위 태스크
- 📝 **태스크 메모장** — 넓은 노트 영역 (Markdown 지원)
- 👥 **멀티유저** — 각 사용자 데이터 완전 격리
- 📱 **PWA** — Android 홈 화면 추가, 오프라인 지원
- 📲 **CardDAV** — DAVx⁵ 앱으로 Galaxy 동기화
- 📥 **TickTick Import** — CSV/ICS 내보내기 파일 import
- 🌙 **다크모드** — 시스템/수동 전환
- 🐳 **Docker** — 원클릭 배포
## 빠른 시작
### 1. 환경 변수 설정
```bash
cp .env.example .env
# .env 파일에서 NEXTAUTH_SECRET 및 POSTGRES_PASSWORD 변경
```
### 2. Docker로 실행
```bash
docker compose up -d
```
앱이 시작되면:
```bash
# DB 마이그레이션 (최초 1회)
docker compose exec app npx prisma migrate deploy
```
`http://localhost:3000` 접속 후 계정 생성.
---
## 개발 환경 실행
### 요구사항
- Node.js 20+
- PostgreSQL (또는 Docker)
```bash
# 의존성 설치
npm install
# DB 설정
cp .env.example .env.local
# .env.local의 DATABASE_URL을 DB에 맞게 수정
# DB 마이그레이션
npx prisma migrate dev
# 개발 서버 시작
npm run dev
```
`http://localhost:3000`
---
## DAVx⁵로 Galaxy 연동
1. Play Store에서 **[DAVx⁵](https://play.google.com/store/apps/details?id=at.bitfire.davdroid)** 설치 (무료)
2. DAVx⁵ 앱 → **+** → Login with URL and user name
- **URL**: `http://your-server:3000/api/dav`
- **Username**: CheckFlow 이메일
- **Password**: CheckFlow 비밀번호
3. Task 목록 동기화 → Samsung Reminder 또는 Tasks 앱에서 확인
---
## TickTick에서 가져오기
1. TickTick 앱 → Settings → Export
2. **Export as CSV** 또는 **Export as iCalendar** 선택
3. CheckFlow → 사이드바 → **Import Tasks**
4. 파일 선택 후 대상 목록 지정 → Import
---
## NPM (Nginx Proxy Manager) 연동
Docker compose가 기본으로 `3000` 포트에서 실행됩니다.
NPM에서 Proxy Host 추가:
- **Destination**: `http://checkflow-app:3000`
- SSL 인증서 설정
---
## 환경 변수
| 변수 | 설명 | 기본값 |
|------|------|--------|
| `DATABASE_URL` | PostgreSQL 연결 문자열 | — |
| `NEXTAUTH_URL` | 외부 접근 URL | `http://localhost:3000` |
| `NEXTAUTH_SECRET` | JWT 시크릿 (32자 이상 랜덤) | — |
| `POSTGRES_PASSWORD` | DB 비밀번호 | `changeme` |
| `PORT` | 노출 포트 | `3000` |
---
## 기술 스택
- **Frontend**: Next.js 16 (App Router) + TypeScript + Vanilla CSS
- **Backend**: Next.js API Routes
- **DB**: PostgreSQL + Prisma ORM
- **Auth**: NextAuth.js (JWT)
- **PWA**: Service Worker + Web App Manifest
- **CardDAV**: 커스텀 iCalendar VTODO 구현
- **Container**: Docker + docker-compose
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0785)https://js.stripe.com/v3/controller-with-preconnect-5db5e83329f7b3486c3557ee2790d9cb.html#__shared_params__[version]=v3&__shared_params__[light_experiment_assignments]=%7B%22token%22%3A%22feb38f29-c9a3-42da-acfd-c19172cfb719%22%2C%22assignments%22%3A%7B%22api_fastpath_habanero_v1%22%3A%22control%22%7D%7D&apiKey=pk_live_J95N9bhJGECTDWXInMXdcspN&stripeJsId=feb38f29-c9a3-42da-acfd-c19172cfb719&stripeObjId=sobj-f89fa765-6e66-47f7-83fe-a378314d43b5&firstStripeInstanceCreatedLatency=25&controllerCount=1&isCheckout=false&stripeJsLoadTime=1787194929312.7&manualBrowserDeprecationRollout=false&mids[guid]=NA&mids[muid]=NA&mids[sid]=NA&referrer=https%3A%2F%2Fticktick.com%2Fwebapp%23p%2F6a6c58fb8f087e90bd8da7a4%2Ftasks%2F6a6c625491d8cd0680e74606&controllerId=__privateStripeController7581 -->
<html lang="ko"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><link rel="preconnect" href="https://api.stripe.com/" crossorigin=""><link rel="preconnect" href="https://merchant-ui-api.stripe.com/" crossorigin=""><meta http-equiv="origin-trial" content="AtD0WrnMwAPI4nWWCvreE+vpgPVz45SO/1fG1IZRNpBsdWZOZN6SKr0ynC11KuzrvT903WrEU+N9Ik/RpiCRTAEAAABbeyJvcmlnaW4iOiJodHRwczovL3N0cmlwZS5jb206NDQzIiwiZmVhdHVyZSI6IlRwY2QiLCJleHBpcnkiOjE3MzUzNDM5OTksImlzU3ViZG9tYWluIjp0cnVlfQ=="><script defer="defer" src="./shared-7577eac54e88cecc1d82b6c392bfe2cf.js.다운로드"></script><script defer="defer" src="./controller-with-preconnect-f5313d6dc0cd5231b1bbefb30935e946.js.다운로드"></script></head><body></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0295)https://m.stripe.network/inner.html#url=https%3A%2F%2Fticktick.com%2Fwebapp%23p%2F6a6c58fb8f087e90bd8da7a4%2Ftasks%2F6a6c625491d8cd0680e74606&title=%EC%9B%8C%ED%99%80%20-%20TickTick&referrer=https%3A%2F%2Fticktick.com%2Fwebapp&muid=NA&sid=NA&version=6&preview=false&__shared_params__[version]=v3 -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title>StripeM-Inner</title></head><body><script>!function(){var e=document.createElement("script");e.defer=!0,e.src="out-4.5.45.js",e.onload=function(){var e;window.StripeM&&(e=window.location.hash,/ping=false/.test(e)||(e=(e=e.match(/version=(4|6)/))?e[1]:"4",window.StripeM.p({t:!0,v:e})),e=function(e){if(window.opener||window.parent||window)try{var i=((t=JSON.parse(e.data)).message||t).action,t=t.message?t.message.payload:t;switch(i){case"ping":window.StripeM.p({t:!0,o:{muid:t.muid,sid:t.sid,referrer:t.referrer,url:t.url,title:t.title,v2:t.v2},v:t.version||"4"});break;case"track":if(!t.source||!t.data)return;window.StripeM.b({muid:t.muid,sid:t.sid,url:t.url,source:t.source,data:t.data},t.version||"4")}}catch(e){}},window.addEventListener?window.addEventListener("message",e,!1):window.attachEvent("onMessage",e))},document.body.appendChild(e)}()</script><script defer="" src="./out-4.5.45.js.다운로드"></script></body></html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(){"use strict";var e="https://m.stripe.network",n=window.location.hash,t=/preview=true/.test(n)?"inner-preview.html":"inner.html",o=document.createElement("iframe");o.src="".concat(e,"/").concat(t).concat(n);var i=function(n){if(n.origin===e){var t=window.opener||window.parent||window;if(!t)return;t.postMessage(n.data,"*")}else o.contentWindow.postMessage(n.data,"*")};window.addEventListener?window.addEventListener("message",i,!1):window.attachEvent("onMessage",i),document.body&&document.body.appendChild(o)}();
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<!-- saved from url=(0330)https://js.stripe.com/v3/m-outer-3437aaddcdf6922d623e172c2d6f9278.html#url=https%3A%2F%2Fticktick.com%2Fwebapp%23p%2F6a6c58fb8f087e90bd8da7a4%2Ftasks%2F6a6c625491d8cd0680e74606&title=%EC%9B%8C%ED%99%80%20-%20TickTick&referrer=https%3A%2F%2Fticktick.com%2Fwebapp&muid=NA&sid=NA&version=6&preview=false&__shared_params__[version]=v3 -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><script defer="defer" src="./m-outer-15a2b40a058ddff1cffdb63779fe3de1.js.다운로드"></script></head><body><iframe src="./inner.html"></iframe></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+49
View File
@@ -0,0 +1,49 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
container_name: checkflow-db
restart: unless-stopped
environment:
POSTGRES_DB: checkflow
POSTGRES_USER: checkflow
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U checkflow"]
interval: 10s
timeout: 5s
retries: 5
migrate:
build: .
container_name: checkflow-migrate
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://checkflow:${POSTGRES_PASSWORD:-changeme}@postgres:5432/checkflow
command: npx prisma migrate deploy
restart: "no"
app:
build: .
container_name: checkflow-app
restart: unless-stopped
depends_on:
migrate:
condition: service_completed_successfully
ports:
- "${PORT:-3000}:3000"
environment:
DATABASE_URL: postgresql://checkflow:${POSTGRES_PASSWORD:-changeme}@postgres:5432/checkflow
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-this-secret-in-production}
NODE_ENV: production
volumes:
- ./prisma:/app/prisma
volumes:
postgres_data:
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
"react-hooks/set-state-in-effect": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/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.
+33
View File
@@ -0,0 +1,33 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
async headers() {
return [
{
source: "/manifest.json",
headers: [{ key: "Content-Type", value: "application/manifest+json" }],
},
{
// 모든 라우트에 보안 헤더 적용
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-XSS-Protection", value: "1; mode=block" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
],
},
];
},
};
export default nextConfig;
+8080
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "checkflow",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"dev:sqlite": "set DATABASE_URL=file:./prisma/dev.db && next dev"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.19.3",
"@types/dompurify": "^3.2.0",
"bcryptjs": "^3.0.3",
"csv-parse": "^7.0.2",
"dompurify": "^3.4.14",
"marked": "^18.0.10",
"next": "16.3.1",
"next-auth": "^4.24.15",
"prisma": "^6.19.3",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-markdown": "^10.1.0",
"uuid": "^14.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.
+88
View File
@@ -0,0 +1,88 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
avatarColor String @default("#4B7BF5")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lists List[]
tasks Task[]
tags Tag[]
sessions Session[]
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model List {
id String @id @default(cuid())
userId String
name String
color String @default("#4B7BF5")
icon String @default("list")
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
@@index([userId])
}
model Task {
id String @id @default(cuid())
userId String
listId String
parentId String?
title String
note String?
completed Boolean @default(false)
completedAt DateTime?
dueDate DateTime?
priority Int @default(0)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
parent Task? @relation("TaskChildren", fields: [parentId], references: [id], onDelete: Cascade)
children Task[] @relation("TaskChildren")
tags TaskTag[]
@@index([userId])
@@index([listId])
@@index([parentId])
}
model Tag {
id String @id @default(cuid())
userId String
name String
color String @default("#6B6B6B")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks TaskTag[]
@@unique([userId, name])
@@index([userId])
}
model TaskTag {
taskId String
tagId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([taskId, tagId])
}
@@ -0,0 +1,88 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"avatarColor" TEXT NOT NULL DEFAULT '#4B7BF5',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "List" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT '#4B7BF5',
"icon" TEXT NOT NULL DEFAULT 'list',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "List_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"listId" TEXT NOT NULL,
"parentId" TEXT,
"title" TEXT NOT NULL,
"note" TEXT,
"completed" BOOLEAN NOT NULL DEFAULT false,
"completedAt" TIMESTAMP(3),
"dueDate" TIMESTAMP(3),
"priority" INTEGER NOT NULL DEFAULT 0,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT '#6B6B6B',
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TaskTag" (
"taskId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
CONSTRAINT "TaskTag_pkey" PRIMARY KEY ("taskId","tagId")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
CREATE UNIQUE INDEX "Tag_userId_name_key" ON "Tag"("userId", "name");
CREATE INDEX "List_userId_idx" ON "List"("userId");
CREATE INDEX "Task_userId_idx" ON "Task"("userId");
CREATE INDEX "Task_listId_idx" ON "Task"("listId");
CREATE INDEX "Task_parentId_idx" ON "Task"("parentId");
CREATE INDEX "Tag_userId_idx" ON "Tag"("userId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "List" ADD CONSTRAINT "List_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Task" ADD CONSTRAINT "Task_listId_fkey" FOREIGN KEY ("listId") REFERENCES "List"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Task" ADD CONSTRAINT "Task_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TaskTag" ADD CONSTRAINT "TaskTag_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TaskTag" ADD CONSTRAINT "TaskTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+2
View File
@@ -0,0 +1,2 @@
# Please do not edit this file manually
provider = "postgresql"
+101
View File
@@ -0,0 +1,101 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
avatarColor String @default("#4B7BF5")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lists List[]
tasks Task[]
tags Tag[]
sessions Session[]
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model List {
id String @id @default(cuid())
userId String
name String
color String @default("#4B7BF5")
icon String @default("list")
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
@@index([userId])
}
model Task {
id String @id @default(cuid())
userId String
listId String
parentId String?
title String
note String? @db.Text
completed Boolean @default(false)
completedAt DateTime?
dueDate DateTime?
priority Int @default(0)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
parent Task? @relation("TaskChildren", fields: [parentId], references: [id], onDelete: Cascade)
children Task[] @relation("TaskChildren")
tags TaskTag[]
@@index([userId])
@@index([listId])
@@index([parentId])
}
model Tag {
id String @id @default(cuid())
userId String
name String
color String @default("#6B6B6B")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks TaskTag[]
@@unique([userId, name])
@@index([userId])
}
model TaskTag {
taskId String
tagId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([taskId, tagId])
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#4B7BF5"/><text x="50" y="67" font-family="Arial" font-size="55" font-weight="bold" fill="white" text-anchor="middle">&#x2713;</text></svg>

After

Width:  |  Height:  |  Size: 247 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#4B7BF5"/><text x="50" y="67" font-family="Arial" font-size="55" font-weight="bold" fill="white" text-anchor="middle">&#x2713;</text></svg>

After

Width:  |  Height:  |  Size: 247 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#4B7BF5"/><text x="50" y="67" font-family="Arial" font-size="55" font-weight="bold" fill="white" text-anchor="middle">&#x2713;</text></svg>

After

Width:  |  Height:  |  Size: 247 B

+1
View File
@@ -0,0 +1 @@
{"name":"CheckFlow","short_name":"CheckFlow","description":"Clean, fast self-hosted todo app","start_url":"/","display":"standalone","background_color":"#FFFFFF","theme_color":"#4B7BF5","orientation":"any","icons":[{"src":"/icons/icon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}],"categories":["productivity","utilities"],"lang":"ko"}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+34
View File
@@ -0,0 +1,34 @@
const CACHE_NAME = "checkflow-v1";
const STATIC_ASSETS = ["/", "/login", "/register", "/manifest.json", "/icons/icon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Network-first for API
if (url.pathname.startsWith("/api")) {
event.respondWith(fetch(request).catch(() => new Response(JSON.stringify({ error: "Offline" }), { headers: { "Content-Type": "application/json" } })));
return;
}
// Cache-first for static
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request))
);
});
+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+5
View File
@@ -0,0 +1,5 @@
[CRITICAL INSTRUCTION]
You are operating under strict context window pressure, and older chat history may be truncated.
Therefore, before starting a new task or making critical decisions, always read the `.cline-context.md` file in the project root to verify the latest status and rules.
Whenever a sub-task is completed or significant structural changes occur, you MUST update the 'Next Steps (Todo)' and 'Project Current Status' sections in `.cline-context.md` to preserve your memory.
Keep the content of `.cline-context.md` clear, concise, and written in English.
+291
View File
@@ -0,0 +1,291 @@
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
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 router = useRouter();
const { data: session, status } = useSession();
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
const [search, setSearch] = useState("");
const [totalTasks] = useState(() => {
if (typeof window !== "undefined") {
const store = getDemoStore();
return store.tasks ? store.tasks.length : 67;
}
return 67;
});
const [totalLists] = useState(() => {
if (typeof window !== "undefined") {
const store = getDemoStore();
return store.lists ? store.lists.length : 9;
}
return 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?callbackUrl=/admin");
}
}, [status, router]);
// 로딩 중 스피너
if (status === "loading" || status === "unauthenticated") {
return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
<p style={{ color: "var(--text-secondary)" }}>Checking administrator credentials...</p>
</div>
</div>
);
}
// 비인가 사용자(일반 유저) 차단 화면
if (!isAdmin) {
return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)", color: "var(--text-primary)", padding: 24 }}>
<div style={{ maxWidth: 440, width: "100%", background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: 32, textAlign: "center" }}>
<div style={{ fontSize: 44, marginBottom: 16 }}>🛡</div>
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>Access Restricted</h2>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.6, margin: "0 0 24px" }}>
This console requires <strong>ADMIN</strong> privileges. Your account (<code>{session?.user?.email}</code>) does not have authorization to view multi-user system telemetry.
</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
<Link href="/demo" className="btn btn-ghost">
Open Demo
</Link>
<Link href="/" className="btn btn-primary">
Return to Tasks
</Link>
</div>
</div>
</div>
);
}
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 (
<div style={{ minHeight: "100vh", background: "var(--bg-primary)", color: "var(--text-primary)", padding: "24px 32px" }}>
{/* Top Navbar */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid var(--border)", paddingBottom: 16, marginBottom: 24 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div className="sidebar-logo">👑</div>
<div>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
CheckFlow Admin Console
</h1>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: 0 }}>
Multi-user platform & self-hosted instance management
</p>
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<Link href="/demo" className="btn btn-ghost btn-sm">
Back to App (Demo)
</Link>
<Link href="/" className="btn btn-primary btn-sm">
🚀 Open Main Dashboard
</Link>
</div>
</div>
{/* Stats Cards */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16, marginBottom: 28 }}>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Users</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "var(--accent)", marginTop: 4 }}>{users.length}</div>
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}> 100% active instances</div>
</div>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Projects / Lists</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "#10B981", marginTop: 4 }}>{totalLists}</div>
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Across all users</div>
</div>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Tasks & Notes</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "#8B5CF6", marginTop: 4 }}>{totalTasks}</div>
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Recursive subtasks included</div>
</div>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>CalDAV / DAVx Status</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "#F59E0B", marginTop: 4 }}>Active</div>
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}> Basic Auth Bypass Verified</div>
</div>
</div>
{/* User Management Section */}
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-md)", padding: 20 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16, flexWrap: "wrap", gap: 12 }}>
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>Registered Users & Privacy Isolation</h2>
<input
className="form-input"
placeholder="Search users by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ maxWidth: 300 }}
/>
</div>
{/* Users Table */}
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left", fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-tertiary)" }}>
<th style={{ padding: "10px 12px" }}>User</th>
<th style={{ padding: "10px 12px" }}>Role</th>
<th style={{ padding: "10px 12px" }}>Joined Date</th>
<th style={{ padding: "10px 12px" }}>Tasks</th>
<th style={{ padding: "10px 12px" }}>Status</th>
<th style={{ padding: "10px 12px", textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u) => (
<tr key={u.id} style={{ borderBottom: "1px solid var(--border)" }}>
<td style={{ padding: "12px" }}>
<div style={{ fontWeight: 600 }}>{u.name}</div>
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{u.email}</div>
</td>
<td style={{ padding: "12px" }}>
<span
style={{
padding: "3px 8px",
borderRadius: "var(--radius-sm)",
fontSize: 11,
fontWeight: 700,
background: u.role === "ADMIN" ? "rgba(75, 123, 245, 0.15)" : "var(--bg-primary)",
color: u.role === "ADMIN" ? "var(--accent)" : "var(--text-secondary)",
border: "1px solid var(--border)",
}}
>
{u.role}
</span>
</td>
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
{new Date(u.createdAt).toLocaleDateString()}
</td>
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
{u.taskCount} tasks
</td>
<td style={{ padding: "12px" }}>
<span style={{ color: u.active ? "var(--success)" : "var(--danger)", fontWeight: 600, fontSize: 12 }}>
{u.active ? "● Active" : "○ Inactive"}
</span>
</td>
<td style={{ padding: "12px", textAlign: "right" }}>
<div style={{ display: "inline-flex", gap: 6 }}>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => toggleRole(u.id)}
title="Change role"
style={{ fontSize: 11, padding: "3px 8px" }}
>
{u.role === "ADMIN" ? "Demote" : "Promote Admin"}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => toggleStatus(u.id)}
title="Toggle active status"
style={{ fontSize: 11, padding: "3px 8px" }}
>
{u.active ? "Deactivate" : "Activate"}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => deleteUser(u.id)}
title="Delete user"
style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { name, email, password } = body;
if (!name || !email || !password) {
return NextResponse.json({ error: "All fields required" }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "Email already in use" }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { name, email, passwordHash },
});
// Create default list
await prisma.list.create({
data: {
userId: user.id,
name: "My Tasks",
color: "#4B7BF5",
icon: "inbox",
sortOrder: 0,
},
});
return NextResponse.json({ id: user.id, email: user.email, name: user.name }, { status: 201 });
} catch (err) {
console.error("[register]", err);
return NextResponse.json(
{ error: "Server error. Make sure the database is running." },
{ status: 500 }
);
}
}
+397
View File
@@ -0,0 +1,397 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
// Helper: Basic Authentication
async function authenticate(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader?.startsWith("Basic ")) return null;
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
const sepIdx = decoded.indexOf(":");
if (sepIdx === -1) return null;
const email = decoded.substring(0, sepIdx);
const password = decoded.substring(sepIdx + 1);
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
const valid = await bcrypt.compare(password, user.passwordHash);
return valid ? user : null;
}
function taskToVTodo(task: {
id: string;
title: string;
note: string | null;
completed: boolean;
completedAt: Date | null;
dueDate: Date | null;
priority: number;
createdAt: Date;
updatedAt: Date;
}) {
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
const vtodo = [
"BEGIN:VTODO",
`UID:${task.id}@checkflow`,
`DTSTAMP:${now}`,
`CREATED:${task.createdAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
`LAST-MODIFIED:${task.updatedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
`SUMMARY:${task.title.replace(/\n/g, "\\n")}`,
`STATUS:${task.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
`PRIORITY:${priorityMap[task.priority] ?? 0}`,
];
if (task.note) vtodo.push(`DESCRIPTION:${task.note.replace(/\n/g, "\\n")}`);
if (task.dueDate) vtodo.push(`DUE:${task.dueDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
if (task.completedAt) vtodo.push(`COMPLETED:${task.completedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
vtodo.push("END:VTODO");
return vtodo.join("\r\n");
}
function parseVTodo(vcardBody: string): Partial<{
uid: string;
title: string;
note: string;
completed: boolean;
dueDate: Date | null;
priority: number;
}> {
const lines = vcardBody.split(/\r?\n/);
const result: Partial<{
uid: string;
title: string;
note: string;
completed: boolean;
dueDate: Date | null;
priority: number;
}> = {};
for (const line of lines) {
if (line.startsWith("UID:")) {
result.uid = line.substring(4).replace(/@checkflow$/, "").trim();
} else if (line.startsWith("SUMMARY:")) {
result.title = line.substring(8).replace(/\\n/g, "\n").trim();
} else if (line.startsWith("DESCRIPTION:")) {
result.note = line.substring(12).replace(/\\n/g, "\n").trim();
} else if (line.startsWith("STATUS:")) {
result.completed = line.substring(7).trim().toUpperCase() === "COMPLETED";
} else if (line.startsWith("DUE:")) {
const val = line.substring(4).trim();
try {
// Parse basic ISO or iCal timestamp (YYYYMMDDTHHMMSSZ or YYYYMMDD)
if (val.length === 8) {
const y = parseInt(val.substring(0, 4), 10);
const m = parseInt(val.substring(4, 6), 10) - 1;
const d = parseInt(val.substring(6, 8), 10);
result.dueDate = new Date(Date.UTC(y, m, d));
} else {
result.dueDate = new Date(val);
}
} catch {
// ignore parse error
}
} else if (line.startsWith("PRIORITY:")) {
const p = parseInt(line.substring(9).trim(), 10);
if (p === 1) result.priority = 3; // High
else if (p === 5) result.priority = 2; // Medium
else if (p === 9) result.priority = 1; // Low
else result.priority = 0;
}
}
return result;
}
// OPTIONS for CORS & CalDAV capability discovery
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
Allow: "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, REPORT",
DAV: "1, 2, 3, calendar-access, extended-mkcol",
"MS-Author-Via": "DAV",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, REPORT",
"Access-Control-Allow-Headers": "Authorization, Content-Type, Depth, Prefer, If-Match, If-None-Match",
},
});
}
// GET: Direct ICS / VTODO download
export async function GET(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
const tasks = await prisma.task.findMany({
where: { userId: user.id, parentId: null },
orderBy: { sortOrder: "asc" },
});
const icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//CheckFlow//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
...tasks.map(taskToVTodo),
"END:VCALENDAR",
].join("\r\n");
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'attachment; filename="checkflow-tasks.ics"',
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
// PROPFIND: Standard CalDAV Discovery for DAVx5 / Apple Reminders
export async function PROPFIND(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
const host = req.headers.get("host") || "localhost:3000";
const protocol = req.headers.get("x-forwarded-proto") || "http";
const baseUrl = `${protocol}://${host}/api/dav`;
const principalUrl = `${baseUrl}/principals/${encodeURIComponent(user.email)}`;
const calendarHome = `${baseUrl}/calendars/${encodeURIComponent(user.email)}/`;
const tasksUrl = `${calendarHome}tasks/`;
const xmlResponse = `<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<!-- Base Principal & Calendar Home -->
<D:response>
<D:href>${baseUrl}/</D:href>
<D:propstat>
<D:prop>
<D:current-user-principal><D:href>${principalUrl}</D:href></D:current-user-principal>
<D:resourcetype><D:collection/></D:resourcetype>
<C:calendar-home-set><D:href>${calendarHome}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<!-- User Principal -->
<D:response>
<D:href>${principalUrl}</D:href>
<D:propstat>
<D:prop>
<D:displayname>${user.name || user.email}</D:displayname>
<D:resourcetype><D:principal/></D:resourcetype>
<C:calendar-home-set><D:href>${calendarHome}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<!-- Tasks Collection (VTODO) -->
<D:response>
<D:href>${tasksUrl}</D:href>
<D:propstat>
<D:prop>
<D:displayname>CheckFlow Tasks</D:displayname>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<C:supported-calendar-component-set>
<C:comp name="VTODO"/>
</C:supported-calendar-component-set>
<CS:getctag>"${Date.now()}"</CS:getctag>
<D:sync-token>data:sync:${Date.now()}</D:sync-token>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`;
return new NextResponse(xmlResponse, {
status: 207,
headers: {
"Content-Type": "application/xml; charset=utf-8",
DAV: "1, 2, 3, calendar-access",
},
});
}
// REPORT: Query calendar items
export async function REPORT(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
const host = req.headers.get("host") || "localhost:3000";
const protocol = req.headers.get("x-forwarded-proto") || "http";
const tasksUrl = `${protocol}://${host}/api/dav/calendars/${encodeURIComponent(user.email)}/tasks/`;
const tasks = await prisma.task.findMany({
where: { userId: user.id, parentId: null },
orderBy: { sortOrder: "asc" },
});
const responsesXml = tasks
.map((task) => {
const vcal = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//CheckFlow//EN",
taskToVTodo(task),
"END:VCALENDAR",
].join("\r\n");
return ` <D:response>
<D:href>${tasksUrl}${task.id}.ics</D:href>
<D:propstat>
<D:prop>
<D:getetag>"${new Date(task.updatedAt).getTime()}"</D:getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav"><![CDATA[${vcal}]]></C:calendar-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`;
})
.join("\n");
const xmlResponse = `<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
${responsesXml}
</D:multistatus>`;
return new NextResponse(xmlResponse, {
status: 207,
headers: {
"Content-Type": "application/xml; charset=utf-8",
DAV: "1, 2, 3, calendar-access",
},
});
}
// PUT: Create or update task via CalDAV sync
export async function PUT(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
const body = await req.text();
const parsed = parseVTodo(body);
if (!parsed.title && !parsed.uid) {
return new NextResponse("Bad Request: Missing task data", { status: 400 });
}
// Find existing task by UID or create new one in user's default/first list
let task = null;
if (parsed.uid) {
task = await prisma.task.findFirst({
where: { id: parsed.uid, userId: user.id },
});
}
if (task) {
// Update existing task
task = await prisma.task.update({
where: { id: task.id },
data: {
...(parsed.title !== undefined && { title: parsed.title }),
...(parsed.note !== undefined && { note: parsed.note }),
...(parsed.completed !== undefined && {
completed: parsed.completed,
completedAt: parsed.completed ? new Date() : null,
}),
...(parsed.dueDate !== undefined && { dueDate: parsed.dueDate }),
...(parsed.priority !== undefined && { priority: parsed.priority }),
},
});
return new NextResponse(null, {
status: 204,
headers: {
ETag: `"${new Date(task.updatedAt).getTime()}"`,
},
});
} else {
// Create new task in the user's primary list
let list = await prisma.list.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "asc" },
});
if (!list) {
list = await prisma.list.create({
data: {
name: "Inbox",
userId: user.id,
},
});
}
const newTask = await prisma.task.create({
data: {
...(parsed.uid && { id: parsed.uid }),
title: parsed.title || "Untitled Task",
note: parsed.note || null,
completed: parsed.completed || false,
completedAt: parsed.completed ? new Date() : null,
dueDate: parsed.dueDate || null,
priority: parsed.priority ?? 0,
listId: list.id,
userId: user.id,
},
});
return new NextResponse(null, {
status: 201,
headers: {
ETag: `"${new Date(newTask.updatedAt).getTime()}"`,
},
});
}
}
// DELETE: Remove task via CalDAV sync
export async function DELETE(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
// Extract ID from path URL
const pathname = req.nextUrl.pathname;
const match = pathname.match(/\/([a-zA-Z0-9_-]+)\.ics$/);
const taskId = match ? match[1] : null;
if (!taskId) {
return new NextResponse("Not Found", { status: 404 });
}
const task = await prisma.task.findFirst({
where: { id: taskId, userId: user.id },
});
if (!task) {
return new NextResponse("Not Found", { status: 404 });
}
await prisma.task.delete({
where: { id: task.id },
});
return new NextResponse(null, { status: 204 });
}
+176
View File
@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
function escapeCsvField(field: string | null | undefined): string {
if (field === null || field === undefined) return '""';
const str = String(field);
// Sanitize formula injection
const sanitized = /^[=+\-@\t\r]/.test(str.trim()) ? `'${str.trim()}` : str;
return `"${sanitized.replace(/"/g, '""')}"`;
}
function taskToVTodo(
task: {
id: string;
title: string;
note: string | null;
completed: boolean;
completedAt: Date | null;
dueDate: Date | null;
priority: number;
createdAt: Date;
updatedAt: Date;
list?: { name: string } | null;
}
) {
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
const vtodo = [
"BEGIN:VTODO",
`UID:${task.id}@checkflow`,
`DTSTAMP:${now}`,
`CREATED:${task.createdAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
`LAST-MODIFIED:${task.updatedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
`SUMMARY:${task.title.replace(/\n/g, "\\n")}`,
`STATUS:${task.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
`PRIORITY:${priorityMap[task.priority] ?? 0}`,
];
if (task.list?.name) {
vtodo.push(`CATEGORIES:${task.list.name.replace(/\n/g, "\\n")}`);
}
if (task.note) {
vtodo.push(`DESCRIPTION:${task.note.replace(/\n/g, "\\n")}`);
}
if (task.dueDate) {
vtodo.push(`DUE:${task.dueDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
}
if (task.completedAt) {
vtodo.push(`COMPLETED:${task.completedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
}
vtodo.push("END:VTODO");
return vtodo.join("\r\n");
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = req.nextUrl;
const format = (searchParams.get("format") || "csv").toLowerCase();
const listId = searchParams.get("listId");
const includeCompleted = searchParams.get("includeCompleted") !== "false";
const whereClause: {
userId: string;
parentId: null;
listId?: string;
completed?: boolean;
} = {
userId: session.user.id,
parentId: null,
};
if (listId && listId !== "all") {
whereClause.listId = listId;
}
if (!includeCompleted) {
whereClause.completed = false;
}
const tasks = await prisma.task.findMany({
where: whereClause,
include: {
list: {
select: { name: true },
},
},
orderBy: [
{ listId: "asc" },
{ sortOrder: "asc" },
{ createdAt: "desc" },
],
});
const timestamp = new Date().toISOString().split("T")[0];
if (format === "ics") {
const icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//CheckFlow Tasks Export//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
...tasks.map(taskToVTodo),
"END:VCALENDAR",
].join("\r\n");
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="checkflow-export-${timestamp}.ics"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
// Standard TickTick & RFC 4180 Compatible CSV
const priorityLabels: Record<number, string> = { 0: "None", 1: "Low", 2: "Medium", 3: "High" };
const headers = [
"Folder Name",
"List Name",
"Title",
"Tags",
"Content",
"Is Check list",
"Start Date",
"Due Date",
"Reminder",
"Repeat",
"Priority",
"Status",
"Created Time",
"Completed Time",
"Order",
"Timezone",
];
const csvRows = [headers.map((h) => `"${h}"`).join(",")];
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i];
const row = [
escapeCsvField(""), // Folder Name
escapeCsvField(t.list?.name || "Inbox"), // List Name
escapeCsvField(t.title), // Title
escapeCsvField(""), // Tags
escapeCsvField(t.note || ""), // Content / Note
escapeCsvField("0"), // Is Check list
escapeCsvField(""), // Start Date
escapeCsvField(t.dueDate ? t.dueDate.toISOString() : ""), // Due Date
escapeCsvField(""), // Reminder
escapeCsvField(""), // Repeat
escapeCsvField(priorityLabels[t.priority] || "None"), // Priority
escapeCsvField(t.completed ? "Completed" : "Normal"), // Status
escapeCsvField(t.createdAt.toISOString()), // Created Time
escapeCsvField(t.completedAt ? t.completedAt.toISOString() : ""), // Completed Time
escapeCsvField(String(t.sortOrder ?? i)), // Order
escapeCsvField(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"), // Timezone
];
csvRows.push(row.join(","));
}
const csvContent = "\uFEFF" + csvRows.join("\r\n"); // UTF-8 BOM for Excel compatibility
return new NextResponse(csvContent, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="checkflow-export-${timestamp}.csv"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
+154
View File
@@ -0,0 +1,154 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// TickTick CSV import
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const formData = await req.formData();
const file = formData.get("file") as File | null;
const listId = formData.get("listId") as string | null;
if (!file || !listId) {
return NextResponse.json({ error: "file and listId required" }, { status: 400 });
}
// 파일 크기 제한: 5MB
const MAX_SIZE = 5 * 1024 * 1024;
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: "File too large. Maximum size is 5MB." }, { status: 413 });
}
// 허용 확장자 검증
const ext = file.name.split(".").pop()?.toLowerCase();
if (!["csv", "ics"].includes(ext ?? "")) {
return NextResponse.json({ error: "Unsupported file format. Use CSV or ICS." }, { status: 400 });
}
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
const text = await file.text();
let imported = 0;
if (ext === "csv") {
const lines = text.split("\n");
const header = lines[0].split(",").map((h) => h.trim().replace(/"/g, ""));
const titleIdx = header.findIndex((h) => h.toLowerCase().includes("title") || h.toLowerCase().includes("content"));
const noteIdx = header.findIndex((h) => h.toLowerCase().includes("note") || h.toLowerCase().includes("description"));
const dueIdx = header.findIndex((h) => h.toLowerCase().includes("due"));
const priorityIdx = header.findIndex((h) => h.toLowerCase().includes("priority"));
const completedIdx = header.findIndex((h) => h.toLowerCase().includes("status") || h.toLowerCase().includes("completed"));
const priorityMap: Record<string, number> = { high: 3, medium: 2, low: 1, none: 0, "3": 3, "2": 2, "1": 1, "0": 0 };
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Handle quoted CSV fields
const fields: string[] = [];
let inQuotes = false;
let current = "";
for (const ch of line + ",") {
if (ch === '"') { inQuotes = !inQuotes; }
else if (ch === "," && !inQuotes) { fields.push(current.trim()); current = ""; }
else { current += ch; }
}
// 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 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() : "";
let dueDate: Date | null = null;
if (dueRaw) {
const d = new Date(dueRaw);
if (!isNaN(d.getTime())) dueDate = d;
}
const priority = priorityMap[priorityRaw || "0"] ?? 0;
const completed = completedRaw === "completed" || completedRaw === "true" || completedRaw === "1";
await prisma.task.create({
data: {
userId: session.user.id,
listId,
title,
note: note || null,
dueDate,
priority,
completed,
completedAt: completed ? new Date() : null,
sortOrder: imported,
},
});
imported++;
}
} else if (ext === "ics") {
// Parse ICS / iCalendar VTODO
const todos = text.split("BEGIN:VTODO").slice(1);
for (const todo of todos) {
const get = (key: string) => {
const match = todo.match(new RegExp(`^${key}[^:]*:(.*)$`, "m"));
return match ? match[1].trim().replace(/\\n/g, "\n") : null;
};
const title = get("SUMMARY");
if (!title) continue;
const note = get("DESCRIPTION");
const dueDateRaw = get("DUE") || get("DTSTART");
let dueDate: Date | null = null;
if (dueDateRaw) {
const d = new Date(dueDateRaw.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3"));
if (!isNaN(d.getTime())) dueDate = d;
}
const statusRaw = get("STATUS");
const completed = statusRaw === "COMPLETED";
const priorityRaw = get("PRIORITY");
let priority = 0;
if (priorityRaw) {
const p = parseInt(priorityRaw);
if (p >= 1 && p <= 3) priority = 4 - p; // ICS: 1=high, ours: 3=high
else if (p >= 4 && p <= 6) priority = 2;
else if (p >= 7) priority = 1;
}
await prisma.task.create({
data: {
userId: session.user.id,
listId,
title,
note: note || null,
dueDate,
priority,
completed,
completedAt: completed ? new Date() : null,
sortOrder: imported,
},
});
imported++;
}
} else {
return NextResponse.json({ error: "Unsupported file format. Use CSV or ICS." }, { status: 400 });
}
return NextResponse.json({ imported });
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(req: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
const list = await prisma.list.findFirst({ where: { id, userId: session.user.id } });
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
const allowed = ["name", "color", "icon", "sortOrder"];
const data: Record<string, unknown> = {};
for (const key of allowed) {
if (key in body) data[key] = body[key];
}
const updated = await prisma.list.update({ where: { id }, data });
return NextResponse.json(updated);
} catch (err) {
console.error("[lists/id:PATCH]", err);
return NextResponse.json({ error: "Failed to update list" }, { status: 500 });
}
}
export async function DELETE(_: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const list = await prisma.list.findFirst({ where: { id, userId: session.user.id } });
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
await prisma.list.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[lists/id:DELETE]", err);
return NextResponse.json({ error: "Failed to delete list" }, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const lists = await prisma.list.findMany({
where: { userId: session.user.id },
orderBy: { sortOrder: "asc" },
include: {
_count: { select: { tasks: { where: { completed: false, parentId: null } } } },
},
});
return NextResponse.json(lists);
} catch (err) {
console.error("[lists:GET]", err);
return NextResponse.json({ error: "Failed to fetch lists" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
const { name, color, icon } = body;
if (!name?.trim()) return NextResponse.json({ error: "Name required" }, { status: 400 });
const count = await prisma.list.count({ where: { userId: session.user.id } });
const list = await prisma.list.create({
data: {
userId: session.user.id,
name: name.trim(),
color: color || "#4B7BF5",
icon: icon || "list",
sortOrder: count,
},
});
return NextResponse.json(list, { status: 201 });
} catch (err) {
console.error("[lists:POST]", err);
return NextResponse.json({ error: "Failed to create list" }, { status: 500 });
}
}
+101
View File
@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const task = await prisma.task.findFirst({
where: { id, userId: session.user.id },
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
});
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(task);
} catch (err) {
console.error("[tasks/id:GET]", err);
return NextResponse.json({ error: "Failed to fetch task" }, { status: 500 });
}
}
export async function PATCH(req: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Sanitize allowed fields
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId"];
const data: Record<string, unknown> = {};
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;
}
// Convert dueDate string to Date
if ("dueDate" in data) {
data.dueDate = data.dueDate ? new Date(data.dueDate as string) : null;
}
const updated = await prisma.task.update({
where: { id },
data,
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
});
return NextResponse.json(updated);
} catch (err) {
console.error("[tasks/id:PATCH]", err);
return NextResponse.json({ error: "Failed to update task" }, { status: 500 });
}
}
export async function DELETE(_: NextRequest, { params }: Ctx) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
await prisma.task.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[tasks/id:DELETE]", err);
return NextResponse.json({ error: "Failed to delete task" }, { status: 500 });
}
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const listId = searchParams.get("listId");
const showCompleted = searchParams.get("showCompleted") === "true";
const where: Record<string, unknown> = {
userId: session.user.id,
parentId: null,
...(listId ? { listId } : {}),
...(showCompleted ? {} : { completed: false }),
};
const tasks = await prisma.task.findMany({
where,
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
include: {
children: { orderBy: [{ sortOrder: "asc" }] },
tags: { include: { tag: true } },
},
});
return NextResponse.json(tasks);
} catch (err) {
console.error("[tasks:GET]", err);
return NextResponse.json({ error: "Failed to fetch tasks" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
const { title, listId, parentId, dueDate, priority, note } = body;
if (!title || !listId) return NextResponse.json({ error: "title and listId required" }, { status: 400 });
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: {
userId: session.user.id,
listId,
parentId: parentId || null,
title: title.trim(),
note: note?.trim() || null,
dueDate: dueDate ? new Date(dueDate) : null,
priority: Number(priority) || 0,
sortOrder: count,
},
include: { children: true, tags: { include: { tag: true } } },
});
return NextResponse.json(task, { status: 201 });
} catch (err) {
console.error("[tasks:POST]", err);
return NextResponse.json({ error: "Failed to create task" }, { status: 500 });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { AppShell } from "@/components/layout/AppShell";
export default function DemoPage() {
const demoUser = {
id: "demo-user",
name: "Demo Explorer",
email: "demo@checkflow.local",
};
return <AppShell user={demoUser} isDemo={true} />;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+2029
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
const inter = Inter({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700", "800"],
display: "swap",
});
export const metadata: Metadata = {
title: "CheckFlow — Your Personal Todo",
description: "A clean, fast, self-hosted todo app with hierarchical tasks and rich notes.",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "CheckFlow",
},
};
export const viewport: Viewport = {
themeColor: "#4B7BF5",
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={inter.className} suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
export default function LoginPage() {
const router = useRouter();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await signIn("credentials", { email, password, redirect: false });
setLoading(false);
if (res?.error) {
setError("Invalid email or password. (Make sure DB is running)");
} else {
router.push("/");
}
} catch {
setError("Login failed. Check server status.");
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-card">
{/* Top bar: Lang & Theme switcher */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
<div className="auth-logo" style={{ marginBottom: 0 }}>
<div className="auth-logo-icon"></div>
<span className="auth-logo-name">{t("appName")}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<LanguageSelector />
<button
className="icon-btn"
onClick={toggleTheme}
title={`${t("theme")}: ${theme}`}
style={{ width: 28, height: 28 }}
>
{theme === "system" ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
</svg>
) : theme === "light" ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
</div>
</div>
<h1 className="auth-title">{t("welcomeBack")}</h1>
<p className="auth-subtitle">{t("signInSubtitle")}</p>
{/* Demo Mode Action Banner */}
<div
style={{
background: "var(--accent-light)",
border: "1px dashed var(--accent)",
borderRadius: "var(--radius-md)",
padding: "12px 14px",
marginBottom: 20,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--accent)" }}> {t("demoBadge")}</span>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>No PostgreSQL needed</span>
</div>
<p style={{ fontSize: 12, color: "var(--text-secondary)", lineHeight: 1.4 }}>
Explore full 3-panel UI, sub-tasks, and markdown notes directly in browser storage.
</p>
<Link
href="/demo"
className="btn btn-primary btn-sm"
style={{ marginTop: 4, width: "100%", textAlign: "center" }}
id="try-demo-btn"
>
🚀 {t("tryDemoMode")}
</Link>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">{t("email")}</label>
<input
id="email"
type="email"
className="form-input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label">{t("password")}</label>
<input
id="password"
type="password"
className="form-input"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
<button id="login-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
{loading ? t("signingIn") : t("signIn")}
</button>
</form>
<p className="auth-footer">
{t("dontHaveAccount")}{" "}
<Link href="/register" className="auth-link">{t("createAccount")}</Link>
</p>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/lib/auth";
import { AppShell } from "@/components/layout/AppShell";
export default async function HomePage() {
const session = await getServerSession(authOptions);
if (!session) redirect("/login");
return <AppShell user={session.user} />;
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { SessionProvider } from "next-auth/react";
import React, { createContext, useContext, useEffect, useState } from "react";
import { I18nProvider } from "@/lib/i18n";
export type ThemeMode = "system" | "light" | "dark";
interface ThemeContextType {
theme: ThemeMode;
resolvedTheme: "light" | "dark";
setTheme: (mode: ThemeMode) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType>({
theme: "system",
resolvedTheme: "light",
setTheme: () => {},
toggleTheme: () => {},
});
export const useTheme = () => useContext(ThemeContext);
function ThemeManager({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
}
return "system";
});
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(() => {
if (typeof window !== "undefined") {
const saved = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
if (saved === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return saved;
}
return "light";
});
const applyTheme = (mode: ThemeMode) => {
let effective: "light" | "dark" = "light";
if (mode === "system") {
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
effective = isDark ? "dark" : "light";
} else {
effective = mode;
}
setResolvedTheme(effective);
document.documentElement.setAttribute("data-theme", effective);
};
useEffect(() => {
const saved = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
applyTheme(saved);
// Listen for system theme changes if set to system
const media = window.matchMedia("(prefers-color-scheme: dark)");
const listener = (e: MediaQueryListEvent) => {
const current = (localStorage.getItem("checkflow_theme") as ThemeMode) || "system";
if (current === "system") {
const effective = e.matches ? "dark" : "light";
setResolvedTheme(effective);
document.documentElement.setAttribute("data-theme", effective);
}
};
media.addEventListener("change", listener);
// Register Service Worker
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(console.error);
}
return () => media.removeEventListener("change", listener);
}, []);
const setTheme = (mode: ThemeMode) => {
setThemeState(mode);
localStorage.setItem("checkflow_theme", mode);
applyTheme(mode);
};
// Cycles through: System -> Light -> Dark -> System
const toggleTheme = () => {
const sequence: ThemeMode[] = ["system", "light", "dark"];
const nextIndex = (sequence.indexOf(theme) + 1) % sequence.length;
setTheme(sequence[nextIndex]);
};
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<I18nProvider>
<ThemeManager>{children}</ThemeManager>
</I18nProvider>
</SessionProvider>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
export default function RegisterPage() {
const router = useRouter();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
if (!res.ok) {
let msg = "Registration failed.";
try {
const data = await res.json();
msg = data.error || msg;
} catch {
msg = "Database connection failed. Please ensure PostgreSQL is running or use Demo Mode.";
}
setError(msg);
setLoading(false);
return;
}
await signIn("credentials", { email, password, redirect: false });
router.push("/");
} catch {
setError("Network or server error. Check database status or try Demo Mode.");
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-card">
{/* Top bar: Lang & Theme switcher */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
<div className="auth-logo" style={{ marginBottom: 0 }}>
<div className="auth-logo-icon"></div>
<span className="auth-logo-name">{t("appName")}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<LanguageSelector />
<button
className="icon-btn"
onClick={toggleTheme}
title={`${t("theme")}: ${theme}`}
style={{ width: 28, height: 28 }}
>
{theme === "system" ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
</svg>
) : theme === "light" ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
</div>
</div>
<h1 className="auth-title">{t("createAccount")}</h1>
<p className="auth-subtitle">{t("createAccountSubtitle")}</p>
{/* Demo Mode Action Banner */}
<div
style={{
background: "var(--accent-light)",
border: "1px dashed var(--accent)",
borderRadius: "var(--radius-md)",
padding: "12px 14px",
marginBottom: 20,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--accent)" }}> {t("demoBadge")}</span>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>No PostgreSQL needed</span>
</div>
<p style={{ fontSize: 12, color: "var(--text-secondary)", lineHeight: 1.4 }}>
Explore full 3-panel UI, sub-tasks, and markdown notes directly in browser storage.
</p>
<Link
href="/demo"
className="btn btn-primary btn-sm"
style={{ marginTop: 4, width: "100%", textAlign: "center" }}
id="try-demo-btn"
>
🚀 {t("tryDemoMode")}
</Link>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">{t("displayName")}</label>
<input
id="name"
type="text"
className="form-input"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label">{t("email")}</label>
<input
id="reg-email"
type="email"
className="form-input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">{t("password")}</label>
<input
id="reg-password"
type="password"
className="form-input"
placeholder={t("min8Chars")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
{error && <p className="form-error" style={{ marginBottom: "12px" }}>{error}</p>}
<button id="register-btn" type="submit" className="btn btn-primary w-full" disabled={loading} style={{ height: "44px" }}>
{loading ? t("creatingBtn") : t("createBtn")}
</button>
</form>
<p className="auth-footer">
{t("alreadyHaveAccount")}{" "}
<Link href="/login" className="auth-link">{t("signIn")}</Link>
</p>
</div>
</div>
);
}
+542
View File
@@ -0,0 +1,542 @@
"use client";
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 {
getDemoStore,
saveDemoStore,
MockList,
MockTask,
MockTag,
updateTaskInTree,
moveToTrashInTree,
restoreTaskInTree,
deleteTaskInTree,
emptyTrashInTree,
addTaskToTree,
findTaskInTree,
getAllTrashTasks,
filterTasksByTag,
} from "@/lib/mockData";
interface AppShellProps {
user: User;
isDemo?: boolean;
}
export function AppShell({ user, isDemo = false }: AppShellProps) {
const [lists, setLists] = useState<List[]>(() => {
if (isDemo && typeof window !== "undefined") {
return getDemoStore().lists as List[];
}
return [];
});
const [selectedListId, setSelectedListId] = useState<string | null>(() => {
if (isDemo && typeof window !== "undefined") {
const storeLists = getDemoStore().lists;
return storeLists.length > 0 ? storeLists[0].id : null;
}
return null;
});
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [isTrashActive, setIsTrashActive] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [tasks, setTasks] = useState<Task[]>(() => {
if (isDemo && typeof window !== "undefined") {
const store = getDemoStore();
const firstListId = store.lists[0]?.id;
return (store.tasks as Task[]).filter((t) => !t.isDeleted && t.listId === firstListId && !t.completed);
}
return [];
});
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
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);
document.addEventListener("checkflow:openCommandPalette", handler);
return () => document.removeEventListener("checkflow:openCommandPalette", handler);
}, []);
// Demo tasks filter & reconstruct hierarchical structure
useEffect(() => {
if (isDemo) {
const store = getDemoStore();
setLists(store.lists);
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, isTrashActive, selectedTag, showCompleted, refreshKey]);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const handleTaskSelect = useCallback((task: Task | null) => {
setSelectedTask(task);
}, []);
// Update a task in full tree (supports unlimited N-depth nesting)
const handleTaskUpdate = useCallback((updated: Task) => {
setTasks((prev) => updateTaskInTree(prev as MockTask[], updated as MockTask) as Task[]);
setSelectedTask((prev) => {
if (prev && prev.id === updated.id) {
return { ...updated, children: updated.children || prev.children || [] };
}
return prev;
});
}, []);
const handleListSelect = useCallback((id: string) => {
setIsTrashActive(false);
setSelectedTag(null);
setSelectedListId(id);
setSelectedTask(null);
setSidebarOpen(false);
}, []);
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;
if (isDemo) {
const store = getDemoStore();
const newLists = store.lists.map((l) => (l.id === id ? { ...l, name: trimmed } : l));
saveDemoStore(newLists, store.tasks);
setLists(newLists);
return;
}
try {
const res = await fetch(`/api/lists/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (res.ok) {
const updated = await res.json();
setLists((prev) => prev.map((l) => (l.id === id ? { ...l, name: updated.name } : l)));
}
} catch (err) {
console.error("Failed to update list name", err);
}
};
// Demo Handlers
const handleDemoCreateList = (name: string, color: string) => {
const newList: MockList = {
id: "demo-list-" + Date.now(),
name,
color,
icon: "list",
};
const store = getDemoStore();
const newLists = [...store.lists, newList];
saveDemoStore(newLists, store.tasks);
setLists(newLists);
setSelectedListId(newList.id);
};
const handleDemoDeleteList = (id: string) => {
const store = getDemoStore();
const newLists = store.lists.filter((l) => l.id !== id);
const newTasks = store.tasks.filter((t) => t.listId !== id);
saveDemoStore(newLists, newTasks);
setLists(newLists);
if (selectedListId === id) {
setSelectedListId(newLists[0]?.id || null);
}
refresh();
};
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,
parentId: parentId || null,
title,
note: null,
completed: false,
completedAt: null,
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: meta?.tags || [],
};
const store = getDemoStore();
const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask);
saveDemoStore(store.lists, newTasks);
setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]);
if (selectedTask && selectedTask.id === parentId) {
setSelectedTask((prev) =>
prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev
);
}
refresh();
};
const handleDemoToggleTask = (id: string, completed: boolean) => {
const store = getDemoStore();
const target = findTaskInTree(store.tasks, id);
if (target) {
const updated = {
...target,
completed,
completedAt: completed ? new Date().toISOString() : null,
};
const newTasks = updateTaskInTree(store.tasks, updated);
saveDemoStore(store.lists, newTasks);
setTasks((prev) =>
(updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed)
);
if (selectedTask) {
const latestSelected = findTaskInTree(newTasks, selectedTask.id);
if (latestSelected) {
setSelectedTask(latestSelected as Task);
}
}
}
refresh();
};
const handleDemoUpdateTask = (updated: Task) => {
const store = getDemoStore();
const newTasks = updateTaskInTree(store.tasks, updated as MockTask);
saveDemoStore(store.lists, newTasks);
handleTaskUpdate(updated);
};
// Move to Trash (Soft Delete)
const handleDemoDeleteTask = (id: string) => {
const store = getDemoStore();
const newTasks = moveToTrashInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id));
if (selectedTask?.id === id) {
setSelectedTask(null);
}
refresh();
};
// 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;
if (isDemo) {
const store = getDemoStore();
const target = findTaskInTree(store.tasks, taskId);
if (target) {
const updated = { ...target, title: trimmed };
const newTasks = updateTaskInTree(store.tasks, updated);
saveDemoStore(store.lists, newTasks);
handleTaskUpdate(updated as Task);
}
return;
}
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: trimmed }),
});
if (res.ok) {
const updated = await res.json();
handleTaskUpdate(updated);
}
} catch (err) {
console.error("Failed to update task title", err);
}
};
return (
<div className="app-layout">
{/* Dynamic CSS variable injection from user prefs */}
<style>{prefsStyle}</style>
{/* Sidebar 모바일 오버레이 */}
{sidebarOpen && (
<div
className="modal-overlay"
style={{ zIndex: 25 }}
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 바텀시트 모바일 오버레이 — 태스크 상세 패널이 열릴 때 */}
{selectedTask && (
<div
className="bottom-sheet-overlay"
onClick={() => setSelectedTask(null)}
/>
)}
<Sidebar
user={user}
lists={lists}
setLists={setLists}
selectedListId={selectedListId}
onListSelect={handleListSelect}
selectedTag={selectedTag}
onTagSelect={handleTagSelect}
isTrashActive={isTrashActive}
onTrashSelect={handleTrashSelect}
mobileOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
isDemo={isDemo}
onDemoCreateList={handleDemoCreateList}
onDemoDeleteList={handleDemoDeleteList}
/>
{/* Sidebar resize handle */}
<div
className="sidebar-resize-handle"
onMouseDown={handleSidebarResizeStart}
title="Drag to resize sidebar"
style={{
width: 4,
cursor: "col-resize",
background: "transparent",
flexShrink: 0,
transition: "background var(--dur-fast)",
position: "relative",
zIndex: 10,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
/>
<div className="main-content">
<TaskList
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
user={user}
listId={selectedListId}
lists={lists}
tasks={tasks}
setTasks={setTasks}
selectedTaskId={selectedTask?.id ?? null}
onTaskSelect={handleTaskSelect}
showCompleted={showCompleted}
onToggleCompleted={() => setShowCompleted((p) => !p)}
onMenuOpen={() => setSidebarOpen(true)}
onRefresh={refresh}
isDemo={isDemo}
isTrashActive={isTrashActive}
selectedTag={selectedTag}
onEmptyTrash={handleEmptyTrash}
onRestoreTask={handleRestoreTask}
onPermanentDeleteTask={handlePermanentDeleteTask}
onDemoAddTask={handleDemoAddTask}
onDemoToggleTask={handleDemoToggleTask}
onUpdateTaskTitle={handleUpdateTaskTitle}
onUpdateListName={handleUpdateListName}
onDeleteTask={isDemo ? handleDemoDeleteTask : async (id: string) => {
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
refresh();
}}
/>
</div>
{selectedTask && (
<>
{/* Detail panel resize handle (left edge) */}
<div
onMouseDown={handleDetailResizeStart}
title="Drag to resize detail panel"
style={{
width: 4,
cursor: "col-resize",
background: "transparent",
flexShrink: 0,
zIndex: 10,
transition: "background var(--dur-fast)",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
/>
<TaskDetail
task={selectedTask}
onClose={() => 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) */}
<CommandPalette
isOpen={cmdPaletteOpen}
onClose={() => setCmdPaletteOpen(false)}
tasks={tasks}
onSelectTask={handleTaskSelect}
/>
{/* Mobile FAB */}
<button
className="fab"
id="mobile-add-task"
aria-label="Add task"
onClick={() => {
document.dispatchEvent(new CustomEvent("checkflow:addTask"));
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
);
}
+951
View File
@@ -0,0 +1,951 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
import { SettingsModal } from "@/components/settings/SettingsModal";
import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore } from "@/lib/mockData";
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 = ["#5B8DEF", "#E05252", "#3DAD84", "#E8931A", "#8B6CF7", "#E4609B", "#0ABAD1", "#F17A3B", "#6875F5", "#14B8A6"];
interface SidebarProps {
user: User;
lists: List[];
setLists: React.Dispatch<React.SetStateAction<List[]>>;
selectedListId: string | null;
onListSelect: (id: string) => void;
selectedTag: string | null;
onTagSelect: (tag: string | null) => void;
isTrashActive: boolean;
onTrashSelect: () => void;
mobileOpen: boolean;
onClose: () => void;
isDemo?: boolean;
onDemoCreateList?: (name: string, color: string) => void;
onDemoDeleteList?: (id: string) => void;
}
/* ===================== UNDO TOAST ===================== */
interface UndoToastState {
message: string;
onUndo: () => void;
timeoutId: ReturnType<typeof setTimeout> | null;
}
export function Sidebar({
user,
lists,
setLists,
selectedListId,
onListSelect,
selectedTag,
onTagSelect,
isTrashActive,
onTrashSelect,
mobileOpen,
onClose: _onClose,
isDemo = false,
onDemoCreateList,
onDemoDeleteList,
}: SidebarProps) {
const router = useRouter();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
const [showNewList, setShowNewList] = useState(false);
const [newListName, setNewListName] = useState("");
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [showImport, setShowImport] = useState(false);
const [showExport, setShowExport] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [importListId, setImportListId] = useState("");
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState("");
const [exportFormat, setExportFormat] = useState<"csv" | "ics">("csv");
const [exportListId, setExportListId] = useState<string>("all");
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
const [exporting, setExporting] = useState(false);
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
const [trashCount, setTrashCount] = useState(() => {
if (typeof window !== "undefined") {
const store = getDemoStore();
return getAllTrashTasks(store.tasks).length;
}
return 0;
});
// 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<UndoToastState | null>(null);
// Inline rename
const [renamingListId, setRenamingListId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// Search expansion
const [searchExpanded, setSearchExpanded] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setTags(getCustomTags());
const store = getDemoStore();
const trashed = getAllTrashTasks(store.tasks);
setTrashCount(trashed.length);
}, [lists]);
useEffect(() => {
if (!isDemo) {
fetch("/api/lists")
.then((r) => (r.ok ? r.json() : []))
.then((data) => {
if (Array.isArray(data)) {
setLists(data);
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
}
})
.catch((err) => console.error("Failed to load lists", err));
}
}, [isDemo, onListSelect, selectedListId, setLists]);
useEffect(() => {
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, color: newListColor }),
});
if (res.ok) {
const list = await res.json();
setLists((prev) => [...prev, list]);
setNewListName("");
setShowNewList(false);
onListSelect(list.id);
}
} catch (err) {
console.error("Failed to create list", err);
}
};
const deleteList = async (id: string) => {
// 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" });
} 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 handleExport = () => {
setExporting(true);
const timestamp = new Date().toISOString().split("T")[0];
if (isDemo) {
try {
const store = getDemoStore();
let targetTasks = store.tasks.filter((t) => !t.deletedAt && !t.parentId);
if (exportListId !== "all") {
targetTasks = targetTasks.filter((t) => t.listId === exportListId);
}
if (!exportIncludeCompleted) {
targetTasks = targetTasks.filter((t) => !t.completed);
}
let content = "";
let mimeType = "text/csv;charset=utf-8;";
let filename = `checkflow-demo-export-${timestamp}.csv`;
if (exportFormat === "ics") {
mimeType = "text/calendar;charset=utf-8;";
filename = `checkflow-demo-export-${timestamp}.ics`;
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const vtodos = targetTasks.map((t) => {
const listObj = store.lists.find((l) => l.id === t.listId);
const lines = [
"BEGIN:VTODO",
`UID:${t.id}@checkflow`,
`DTSTAMP:${now}`,
`CREATED:${t.createdAt ? new Date(t.createdAt).toISOString().replace(/[-:]/g, "").split(".")[0] + "Z" : now}`,
`SUMMARY:${t.title.replace(/\n/g, "\\n")}`,
`STATUS:${t.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
`PRIORITY:${priorityMap[t.priority] ?? 0}`,
];
if (listObj?.name) lines.push(`CATEGORIES:${listObj.name.replace(/\n/g, "\\n")}`);
if (t.note) lines.push(`DESCRIPTION:${t.note.replace(/\n/g, "\\n")}`);
if (t.dueDate) lines.push(`DUE:${new Date(t.dueDate).toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
if (t.completedAt) lines.push(`COMPLETED:${new Date(t.completedAt).toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
lines.push("END:VTODO");
return lines.join("\r\n");
});
content = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//Demo Tasks Export//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
...vtodos,
"END:VCALENDAR",
].join("\r\n");
} else {
// CSV Export
const escapeCsv = (val: string | null | undefined) => {
if (!val) return '""';
const s = String(val).replace(/"/g, '""');
return `"${s}"`;
};
const priorityLabels: Record<number, string> = { 0: "None", 1: "Low", 2: "Medium", 3: "High" };
const headers = [
"Folder Name",
"List Name",
"Title",
"Tags",
"Content",
"Is Check list",
"Start Date",
"Due Date",
"Reminder",
"Repeat",
"Priority",
"Status",
"Created Time",
"Completed Time",
"Order",
"Timezone",
];
const rows = [headers.map((h) => `"${h}"`).join(",")];
targetTasks.forEach((t, i) => {
const listObj = store.lists.find((l) => l.id === t.listId);
rows.push([
escapeCsv(""),
escapeCsv(listObj?.name || "Inbox"),
escapeCsv(t.title),
escapeCsv(""),
escapeCsv(t.note || ""),
escapeCsv("0"),
escapeCsv(""),
escapeCsv(t.dueDate ? new Date(t.dueDate).toISOString() : ""),
escapeCsv(""),
escapeCsv(""),
escapeCsv(priorityLabels[t.priority] || "None"),
escapeCsv(t.completed ? "Completed" : "Normal"),
escapeCsv(t.createdAt ? new Date(t.createdAt).toISOString() : new Date().toISOString()),
escapeCsv(t.completedAt ? new Date(t.completedAt).toISOString() : ""),
escapeCsv(String(i)),
escapeCsv(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"),
].join(","));
});
content = "\uFEFF" + rows.join("\r\n");
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setShowExport(false);
} catch (e) {
console.error("Demo export error", e);
} finally {
setExporting(false);
}
return;
}
// Authenticated API Export
const url = `/api/export?format=${exportFormat}&listId=${exportListId}&includeCompleted=${exportIncludeCompleted}`;
const a = document.createElement("a");
a.href = url;
a.download = `checkflow-export-${timestamp}.${exportFormat}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setExporting(false);
setShowExport(false);
};
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();
formData.append("file", file);
formData.append("listId", importListId);
const res = await fetch("/api/import", { method: "POST", body: formData });
const data = await res.json().catch(() => ({}));
if (res.ok) {
setImportResult(`✓ Imported ${data.imported} tasks`);
if (fileRef.current) fileRef.current.value = "";
const listsRes = await fetch("/api/lists");
if (listsRes.ok) {
const updated = await listsRes.json();
if (Array.isArray(updated)) setLists(updated);
}
setTimeout(() => { setShowImport(false); setImportResult(""); }, 2000);
} else {
setImportResult(`Error: ${data.error || "Import failed"}`);
}
} catch (err) {
console.error("[Sidebar] import failed", err);
setImportResult("Error: Network error");
} finally {
setImporting(false);
}
};
const initials = user.name?.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
// Context menu items for list (right-click)
const getListContextMenuItems = (listId: string): MenuItem[] => [
{
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),
},
];
return (
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
{/* Header with Logo & Controls */}
<div className="sidebar-header" style={{ padding: "12px 12px 8px", gap: 6 }}>
<div className="sidebar-logo"></div>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
<span className="sidebar-title" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{t("appName")}
</span>
{isDemo && (
<span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}>
{t("demoBadge")}
</span>
)}
</div>
{/* Top Controls: Hover-expand Search, Language & Theme */}
<div style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>
{/* Hover-expand Search */}
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
transition: "all var(--dur-normal) var(--ease-out)",
}}
onMouseEnter={() => setSearchExpanded(true)}
onMouseLeave={() => {
if (!searchQuery) setSearchExpanded(false);
}}
>
{searchExpanded ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "var(--bg-primary)",
border: "1.5px solid var(--accent)",
borderRadius: "var(--radius-sm)",
padding: "3px 8px",
animation: "expandSearch var(--dur-normal) var(--ease-out)",
width: 180,
boxShadow: "0 0 0 3px var(--accent-light)",
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "Escape") {
document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"));
setSearchExpanded(false);
setSearchQuery("");
}
}}
placeholder={t("searchPlaceholder")}
style={{
background: "transparent",
border: "none",
outline: "none",
fontSize: 12,
color: "var(--text-primary)",
width: "100%",
}}
/>
</div>
) : (
<button
className="icon-btn"
id="quick-search-btn"
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
title={t("searchPlaceholder")}
style={{ width: 28, height: 28, flexShrink: 0 }}
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
)}
</div>
<LanguageSelector />
{/* Theme 3-Way Toggle Button */}
<button
className="icon-btn"
id="theme-toggle"
onClick={toggleTheme}
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
style={{ width: 28, height: 28, flexShrink: 0 }}
type="button"
>
{theme === "system" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
</svg>
) : theme === "light" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
</div>
</div>
{/* Nav List */}
<nav className="sidebar-nav">
{/* Lists Section */}
<div className="sidebar-section">
<div className="sidebar-section-label">{t("lists")}</div>
{lists.map((list) => (
<div
key={list.id}
className={`sidebar-item${!isTrashActive && !selectedTag && selectedListId === list.id ? " active" : ""}`}
onClick={() => {
if (renamingListId === list.id) return;
onTagSelect(null);
onListSelect(list.id);
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
}}
id={`list-item-${list.id}`}
style={{ overflow: "visible" }}
>
<div className="list-dot" style={{ background: list.color, flexShrink: 0 }} />
{renamingListId === list.id ? (
<input
className="form-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") renameList(list.id, renameValue);
if (e.key === "Escape") setRenamingListId(null);
}}
onBlur={() => renameList(list.id, renameValue)}
autoFocus
onClick={(e) => e.stopPropagation()}
style={{ flex: 1, fontSize: 12.5, padding: "2px 6px", height: 24 }}
/>
) : (
<span className="item-label">{list.name}</span>
)}
<span className="item-count">{list._count?.tasks || ""}</span>
<button
className="icon-btn"
style={{ width: 20, height: 20, opacity: 0, transition: "opacity var(--dur-fast)", flexShrink: 0 }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.opacity = "1"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.opacity = "0"; }}
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
}}
title="More options"
type="button"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" />
</svg>
</button>
</div>
))}
</div>
{/* Custom Tags Section */}
{tags.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-label">🏷 {t("tags") || "Tags"}</div>
{tags.map((tag) => (
<div
key={tag.id}
className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`}
onClick={() => onTagSelect(selectedTag === tag.name ? null : tag.name)}
>
<span style={{ color: tag.color, fontSize: 13, fontWeight: 700 }}>#</span>
<span className="item-label">{tag.name}</span>
</div>
))}
</div>
)}
{/* Trash Smart List */}
<div className="sidebar-section">
<div
className={`sidebar-item${isTrashActive ? " active" : ""}`}
id="trash-menu-btn"
onClick={onTrashSelect}
style={{ color: isTrashActive ? "var(--danger)" : "var(--text-secondary)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.7 }}>
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
</svg>
<span className="item-label">{t("trash") || "Trash"}</span>
{trashCount > 0 && <span className="item-count" style={{ color: "var(--danger)" }}>{trashCount}</span>}
</div>
</div>
{/* New list form */}
{showNewList ? (
<div style={{ padding: "4px 10px" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 5, padding: "6px 4px 8px" }}>
{LIST_COLORS.map((c) => (
<div
key={c}
className={`color-swatch${newListColor === c ? " selected" : ""}`}
style={{ background: c, color: c }}
onClick={() => setNewListColor(c)}
/>
))}
</div>
<input
ref={inputRef}
className="form-input"
placeholder={t("listNamePlaceholder")}
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") createList();
if (e.key === "Escape") setShowNewList(false);
}}
style={{ marginBottom: 6 }}
/>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList} type="button">
{t("create")}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)} type="button">
{t("cancel")}
</button>
</div>
</div>
) : (
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)} type="button">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t("newList")}
</button>
)}
</nav>
{/* User footer */}
<div className="sidebar-footer">
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
<div className="user-avatar" style={{ background: "var(--accent)" }}>{initials}</div>
<div className="user-info">
<div className="user-name">{user.name || (isDemo ? "Demo User" : "User")}</div>
<div className="user-email">{user.email || (isDemo ? "demo@checkflow.local" : "")}</div>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.4 }}>
<polyline points="6 9 12 15 18 9" />
</svg>
{/* User Popover Menu */}
{userMenuOpen && (
<div className="dropdown" style={{ bottom: "calc(100% + 6px)", left: 0, right: 0 }}>
<div
className="dropdown-item"
id="sidebar-settings-menu-item"
onClick={(e) => {
e.stopPropagation();
setShowSettings(true);
setUserMenuOpen(false);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
{t("settingsModalTitle") || "Settings"}
</div>
<div
className="dropdown-item"
id="sidebar-import-menu-item"
onClick={(e) => {
e.stopPropagation();
setShowImport(true);
setImportListId(lists[0]?.id || "");
setUserMenuOpen(false);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
</svg>
{t("importTasks")}
</div>
<div
className="dropdown-item"
id="sidebar-export-menu-item"
onClick={(e) => {
e.stopPropagation();
setShowExport(true);
setUserMenuOpen(false);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
{t("exportTasks")}
</div>
<div className="dropdown-divider" />
{isDemo ? (
<div className="dropdown-item" onClick={() => { router.push("/login"); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
</svg>
{t("signIn")}
</div>
) : (
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
</svg>
{t("signOut")}
</div>
)}
</div>
)}
</div>
</div>
{/* Context Menu on List */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={getListContextMenuItems(contextMenu.listId)}
onClose={() => setContextMenu(null)}
/>
)}
{/* Settings Modal */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
user={user}
isDemo={isDemo}
/>
{/* Import modal */}
{showImport && (
<div className="modal-overlay" onClick={() => setShowImport(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">{t("importModalTitle")}</h2>
<div className="form-group">
<label className="form-label">{t("targetList")}</label>
<select className="form-input" value={importListId} onChange={(e) => setImportListId(e.target.value)}>
{lists.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">{t("fileSelectLabel")}</label>
<input ref={fileRef} type="file" accept=".csv,.ics" className="form-input" />
</div>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 12 }}>
{t("tickTickExportHint")}
</p>
{importResult && (
<p style={{ fontSize: 13, color: importResult.startsWith("✓") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>
{importResult}
</p>
)}
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setShowImport(false)} type="button">{t("cancel")}</button>
<button id="import-submit-btn" className="btn btn-primary" onClick={handleImport} disabled={importing} type="button">
{importing ? t("importing") : t("importBtn")}
</button>
</div>
</div>
</div>
)}
{/* Export modal */}
{showExport && (
<div className="modal-overlay" onClick={() => setShowExport(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
<h2 className="modal-title">📤 {t("exportModalTitle")}</h2>
<div className="form-group">
<label className="form-label">{t("exportFormat")}</label>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
className={`btn btn-sm ${exportFormat === "csv" ? "btn-primary" : "btn-ghost"}`}
onClick={() => setExportFormat("csv")}
style={{ flex: 1 }}
>
📄 CSV (TickTick/Excel)
</button>
<button
type="button"
className={`btn btn-sm ${exportFormat === "ics" ? "btn-primary" : "btn-ghost"}`}
onClick={() => setExportFormat("ics")}
style={{ flex: 1 }}
>
📅 ICS (iCalendar VTODO)
</button>
</div>
</div>
<div className="form-group">
<label className="form-label">{t("exportScope")}</label>
<select className="form-input" value={exportListId} onChange={(e) => setExportListId(e.target.value)}>
<option value="all">{t("allLists")}</option>
{lists.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
<div className="form-group" style={{ marginBottom: 16 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 13, color: "var(--text-primary)" }}>
<input
type="checkbox"
checked={exportIncludeCompleted}
onChange={(e) => setExportIncludeCompleted(e.target.checked)}
style={{ accentColor: "var(--accent)" }}
/>
<span>{t("includeCompleted")}</span>
</label>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setShowExport(false)} type="button">
{t("cancel")}
</button>
<button
id="export-submit-btn"
className="btn btn-primary"
onClick={handleExport}
disabled={exporting}
type="button"
>
{exporting ? "..." : `📥 ${t("exportBtn")}`}
</button>
</div>
</div>
</div>
)}
{/* Undo Toast */}
{undoToast && (
<div
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
zIndex: 300,
display: "flex",
alignItems: "center",
gap: 10,
background: "var(--text-primary)",
color: "var(--bg-primary)",
padding: "10px 16px",
borderRadius: "var(--radius-full)",
fontSize: 13,
fontWeight: 500,
boxShadow: "var(--shadow-lg)",
animation: "toastIn var(--dur-normal) var(--ease-out)",
whiteSpace: "nowrap",
}}
>
<span>{undoToast.message}</span>
<button
type="button"
onClick={() => {
undoToast.onUndo();
if (undoToast.timeoutId) clearTimeout(undoToast.timeoutId);
setUndoToast(null);
}}
style={{
background: "var(--accent)",
color: "#fff",
border: "none",
borderRadius: "var(--radius-sm)",
padding: "3px 10px",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
}}
>
{t("undoDelete")}
</button>
<button
type="button"
onClick={() => {
if (undoToast.timeoutId) clearTimeout(undoToast.timeoutId);
setUndoToast(null);
}}
style={{
background: "transparent",
color: "inherit",
border: "none",
opacity: 0.6,
cursor: "pointer",
fontSize: 14,
padding: "0 2px",
}}
>
</button>
</div>
)}
</aside>
);
}
+690
View File
@@ -0,0 +1,690 @@
"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;
onClose: () => void;
user: { id: string; name?: string | null; email?: string | null };
isDemo?: boolean;
}
/** A toggle-row component used throughout Labs tab */
function LabsRow({
label,
desc,
children,
}: {
label: string;
desc?: string;
children: React.ReactNode;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 14px",
background: "var(--bg-secondary)",
borderRadius: "var(--radius-md)",
border: "1px solid var(--border)",
marginBottom: 8,
gap: 16,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>{label}</div>
{desc && (
<div style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 2, lineHeight: 1.4 }}>
{desc}
</div>
)}
</div>
<div style={{ flexShrink: 0 }}>{children}</div>
</div>
);
}
/** Segmented control (radio-style) */
function SegmentedControl<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { label: string; value: T }[];
onChange: (v: T) => void;
}) {
return (
<div
style={{
display: "inline-flex",
background: "var(--bg-primary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-sm)",
padding: 2,
gap: 2,
}}
>
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
style={{
padding: "4px 10px",
fontSize: 12,
fontWeight: value === opt.value ? 700 : 500,
borderRadius: "var(--radius-xs)",
background: value === opt.value ? "var(--accent)" : "transparent",
color: value === opt.value ? "#fff" : "var(--text-secondary)",
border: "none",
cursor: "pointer",
transition: "all var(--dur-fast)",
}}
>
{opt.label}
</button>
))}
</div>
);
}
/** 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 (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
style={{ width: 120, accentColor: "var(--accent)" }}
/>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-secondary)", minWidth: 40 }}>
{value}{unit}
</span>
</div>
);
}
export function SettingsModal({ isOpen, onClose, user, isDemo: _isDemo = false }: SettingsModalProps) {
const { t, lang, setLang } = useI18n();
const { theme, toggleTheme } = useTheme();
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile");
const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "Demo User"));
const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "demo@checkflow.local"));
const [password, setPassword] = useState("");
const [trashRetention, setTrashRetention] = useState(() => (typeof window !== "undefined" ? (getUserSettings().trashRetentionDays ?? 30) : 30));
const [savedMsg, setSavedMsg] = useState("");
const [syncPlatform, setSyncPlatform] = useState<"android" | "apple" | "thunderbird">("android");
const [copiedCalDav, setCopiedCalDav] = useState(false);
const [testSyncStatus, setTestSyncStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
useEffect(() => {
if (isOpen) {
const s = getUserSettings();
setDisplayName(user.name || s.displayName);
setEmail(user.email || s.email);
setTrashRetention(s.trashRetentionDays ?? 30);
}
}, [isOpen, user]);
const handleTestConnection = async () => {
setTestSyncStatus("testing");
try {
const res = await fetch("/api/dav", { method: "OPTIONS" });
if (res.ok || res.status === 401 || res.status === 207) {
setTestSyncStatus("success");
} else {
setTestSyncStatus("error");
}
} catch {
setTestSyncStatus("error");
}
setTimeout(() => {
setTestSyncStatus("idle");
}, 4000);
};
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";
// 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 (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal settings-modal"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: 660, width: "92%", padding: "24px 24px 20px", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<h2 className="modal-title" style={{ marginBottom: 0, fontSize: 17 }}> {t("settingsModalTitle") || "Settings"}</h2>
<button className="icon-btn" onClick={onClose} aria-label="Close" type="button"></button>
</div>
{/* Tab Bar */}
<div
style={{
display: "flex",
gap: 2,
borderBottom: "1px solid var(--border)",
marginBottom: 16,
paddingBottom: 0,
overflowX: "auto",
}}
>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
style={{
padding: "7px 14px",
fontSize: 12.5,
fontWeight: activeTab === tab.id ? 700 : 500,
background: "transparent",
color: activeTab === tab.id ? "var(--accent)" : "var(--text-secondary)",
border: "none",
borderBottom: activeTab === tab.id ? "2px solid var(--accent)" : "2px solid transparent",
borderRadius: 0,
cursor: "pointer",
whiteSpace: "nowrap",
transition: "all var(--dur-fast)",
}}
>
{tab.label}
</button>
))}
</div>
{/* Scrollable tab content */}
<div style={{ flex: 1, overflowY: "auto", paddingRight: 2 }}>
{/* Tab 1: Profile */}
{activeTab === "profile" && (
<div className="settings-tab-content">
<div className="form-group">
<label className="form-label">{t("displayName")}</label>
<input className="form-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your Name" />
</div>
<div className="form-group">
<label className="form-label">{t("email")}</label>
<input className="form-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your.email@example.com" />
</div>
<div className="form-group">
<label className="form-label">{t("password")} (Change)</label>
<input className="form-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="New password (leave blank to keep current)" />
</div>
</div>
)}
{/* Tab 2: Preferences */}
{activeTab === "preferences" && (
<div className="settings-tab-content">
<div className="form-group">
<label className="form-label" style={{ fontWeight: 600 }}>
🗑 {t("trashRetention") || "Trash Auto-Delete Retention Period"}
</label>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 8 }}>
{t("trashRetentionHint") || "Deleted tasks will be permanently removed after the specified period."}
</p>
<select className="form-input" value={trashRetention} onChange={(e) => setTrashRetention(parseInt(e.target.value, 10))}>
<option value={7}>{t("days7") || "7 Days"}</option>
<option value={14}>{t("days14") || "14 Days"}</option>
<option value={30}>{t("days30") || "30 Days (Recommended)"}</option>
<option value={0}>{t("neverDelete") || "Never Auto-Delete (Manual empty only)"}</option>
</select>
</div>
<div className="form-group">
<label className="form-label">{t("language")}</label>
<select className="form-input" value={lang} onChange={(e) => setLang(e.target.value as "en" | "ko" | "ja")}>
<option value="en">English (Default)</option>
<option value="ko"> (Korean)</option>
<option value="ja"> (Japanese)</option>
</select>
</div>
<div className="form-group">
<label className="form-label">{t("theme")}</label>
<div style={{ display: "flex", gap: 8 }}>
{[
{ key: "system", icon: "💻", label: t("themeSystem") },
{ key: "light", icon: "☀️", label: t("themeLight") },
{ key: "dark", icon: "🌙", label: t("themeDark") },
].map(({ key, icon, label }) => (
<button
key={key}
type="button"
className={`btn btn-sm ${theme === key ? "btn-primary" : "btn-ghost"}`}
onClick={() => { if (theme !== key) toggleTheme(); }}
>
{icon} {label}
</button>
))}
</div>
</div>
</div>
)}
{/* Tab 3: 🧪 CheckFlow Labs */}
{activeTab === "labs" && (
<div className="settings-tab-content">
{/* Section: View */}
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}>
View
</div>
<LabsRow
label="📊 Kanban Board"
desc="Switch between list view and 3-column Kanban board (To Do / In Progress / Done)."
>
<SegmentedControl
value={prefs.viewMode}
options={[
{ label: "List", value: "list" },
{ label: "Kanban", value: "kanban" },
]}
onChange={(v) => updatePrefs({ viewMode: v })}
/>
</LabsRow>
{/* Section: Layout */}
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
Layout
</div>
<LabsRow
label="📐 Content Density"
desc="Controls the vertical spacing of task items."
>
<SegmentedControl
value={prefs.density}
options={[
{ label: "Compact", value: "compact" },
{ label: "Default", value: "default" },
{ label: "Airy", value: "comfortable" },
]}
onChange={(v) => updatePrefs({ density: v })}
/>
</LabsRow>
<LabsRow
label="↔️ Sidebar Width"
desc={`Drag the sidebar edge or adjust here. (${prefs.sidebarWidth}px)`}
>
<PrefSlider
min={160}
max={420}
value={prefs.sidebarWidth}
onChange={(v) => updatePrefs({ sidebarWidth: v })}
unit="px"
/>
</LabsRow>
<LabsRow
label="↔️ Detail Panel Width"
desc={`Drag the panel edge or adjust here. (${prefs.detailWidth}px)`}
>
<PrefSlider
min={300}
max={760}
value={prefs.detailWidth}
onChange={(v) => updatePrefs({ detailWidth: v })}
unit="px"
/>
</LabsRow>
{/* Section: Appearance */}
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
Appearance
</div>
<LabsRow
label="🔤 Font Size"
desc="Base font size across the app."
>
<SegmentedControl
value={prefs.fontSize}
options={[
{ label: "S", value: "small" },
{ label: "M", value: "default" },
{ label: "L", value: "large" },
]}
onChange={(v) => updatePrefs({ fontSize: v })}
/>
</LabsRow>
<LabsRow
label="🎨 Accent Color"
desc="Choose the hue of your accent color. Saturation controls vibrancy."
>
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
{/* Hue ring preview */}
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", justifyContent: "flex-end" }}>
{[210, 250, 340, 10, 45, 145, 185].map((h) => (
<div
key={h}
onClick={() => updatePrefs({ accentHue: h })}
title={`Hue ${h}°`}
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: `hsl(${h}, ${sat}%, 54%)`,
cursor: "pointer",
border: prefs.accentHue === h ? "2.5px solid var(--text-primary)" : "2px solid transparent",
outline: prefs.accentHue === h ? `3px solid ${accentPreview}` : "none",
outlineOffset: 1,
transition: "transform var(--dur-fast)",
transform: prefs.accentHue === h ? "scale(1.2)" : "scale(1)",
}}
/>
))}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Saturation</span>
<PrefSlider
min={20}
max={100}
value={prefs.saturation}
onChange={(v) => updatePrefs({ saturation: v })}
unit="%"
/>
</div>
{/* Custom hue input */}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Hue °</span>
<input
type="range"
min={0}
max={360}
value={prefs.accentHue}
onChange={(e) => updatePrefs({ accentHue: parseInt(e.target.value, 10) })}
style={{ width: 100, accentColor: accentPreview }}
/>
<div style={{ width: 18, height: 18, borderRadius: "50%", background: accentPreview, flexShrink: 0 }} />
</div>
</div>
</LabsRow>
<LabsRow
label="⬛ Border Roundness"
desc="Controls the roundness of cards, buttons, and UI elements."
>
<SegmentedControl
value={prefs.roundness}
options={[
{ label: "Sharp", value: "sharp" },
{ label: "Default", value: "default" },
{ label: "Round", value: "round" },
]}
onChange={(v) => updatePrefs({ roundness: v })}
/>
</LabsRow>
<LabsRow
label="⚡ Animation Speed"
desc="Controls the speed of transitions and hover effects."
>
<SegmentedControl
value={prefs.animationSpeed}
options={[
{ label: "Off", value: "none" },
{ label: "Fast", value: "fast" },
{ label: "Default", value: "default" },
{ label: "Slow", value: "slow" },
]}
onChange={(v) => updatePrefs({ animationSpeed: v })}
/>
</LabsRow>
{/* Reset to Defaults */}
<div style={{ marginTop: 20, borderTop: "1px solid var(--border)", paddingTop: 16 }}>
<LabsRow
label="🔄 Reset to Defaults"
desc="Restores all layout, appearance, and view settings to their factory defaults."
>
<button
type="button"
className="btn btn-sm"
style={{ color: "var(--danger)", border: "1px solid var(--danger)", background: "transparent" }}
onClick={() => {
if (confirm(t("resetConfirm") || "Reset all customizations to defaults?")) {
resetToDefaults();
}
}}
>
{t("resetDefaults") || "Reset"}
</button>
</LabsRow>
</div>
</div>
)}
{/* Tab: CalDAV / External Sync */}
{activeTab === "sync" && (
<div className="settings-tab-content">
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>
📱 {t("syncTitle") || "Galaxy & External Sync (CalDAV)"}
</h3>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 14 }}>
{t("syncDesc") || "CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV."}
</p>
{/* Endpoint bar & Action buttons */}
<div className="form-group" style={{ marginBottom: 14 }}>
<label className="form-label" style={{ fontWeight: 600 }}>{t("syncBaseUrl") || "CalDAV Server Base URL"}</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
className="form-input"
readOnly
value={calDavUrl}
style={{ fontFamily: "monospace", fontSize: 12.5, background: "var(--bg-secondary)", flex: 1 }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
navigator.clipboard.writeText(calDavUrl);
setCopiedCalDav(true);
setTimeout(() => setCopiedCalDav(false), 2000);
}}
style={{ minWidth: 80, fontWeight: 600 }}
>
{copiedCalDav ? `${t("syncCopied") || "Copied!"}` : `📋 ${t("syncCopyUrl") || "Copy"}`}
</button>
</div>
</div>
{/* Actions row: Test & Download */}
<div style={{ display: "flex", gap: 10, marginBottom: 16, flexWrap: "wrap", alignItems: "center" }}>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={handleTestConnection}
disabled={testSyncStatus === "testing"}
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
>
{testSyncStatus === "testing" ? `${t("syncTesting") || "Testing..."}` : `🔍 ${t("syncTestConnection") || "Test Endpoint"}`}
</button>
<a
href="/api/dav"
target="_blank"
rel="noreferrer"
className="btn btn-sm btn-ghost"
style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none" }}
>
📥 {t("syncDownloadIcs") || "Download .ICS Feed"}
</a>
{testSyncStatus === "success" && (
<span style={{ fontSize: 12, color: "var(--success)", fontWeight: 600 }}>
{t("syncTestSuccess") || "✓ CalDAV endpoint responded successfully"}
</span>
)}
{testSyncStatus === "error" && (
<span style={{ fontSize: 12, color: "var(--danger)", fontWeight: 600 }}>
Endpoint test failed
</span>
)}
</div>
{/* Client setup guides segmented selector */}
<div style={{ marginBottom: 10 }}>
<SegmentedControl
value={syncPlatform}
options={[
{ label: `🤖 ${t("syncTabAndroid") || "Galaxy / DAVx⁵"}`, value: "android" },
{ label: `🍎 ${t("syncTabApple") || "Apple Reminders"}`, value: "apple" },
{ label: `💻 ${t("syncTabThunderbird") || "Thunderbird"}`, value: "thunderbird" },
]}
onChange={(v) => setSyncPlatform(v)}
/>
</div>
{/* Step by step cards */}
<div
style={{
background: "var(--bg-secondary)",
padding: "14px 16px",
borderRadius: "var(--radius-md)",
border: "1px solid var(--border)",
fontSize: 12.5,
color: "var(--text-primary)",
lineHeight: 1.6,
}}
>
{syncPlatform === "android" && (
<div>
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
📱 Samsung Galaxy & Android (DAVx + Reminder / OpenTasks)
</div>
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<li>{t("syncAndroidStep1")}</li>
<li>{t("syncAndroidStep2")}</li>
<li>{t("syncAndroidStep3")}</li>
<li>{t("syncAndroidStep4")}</li>
</ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> In DAVx account settings, set Sync Interval to <strong>15 minutes</strong> for battery efficiency and near real-time sync.
</div>
</div>
)}
{syncPlatform === "apple" && (
<div>
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
🍎 Apple Reminders & Calendar (iOS / iPadOS / macOS)
</div>
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<li>{t("syncAppleStep1")}</li>
<li>{t("syncAppleStep2")}</li>
<li>{t("syncAppleStep3")}</li>
<li>{t("syncAppleStep4")}</li>
</ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> If using HTTPS behind a reverse proxy (Nginx/Caddy), ensure valid SSL certificates are trusted by Apple devices.
</div>
</div>
)}
{syncPlatform === "thunderbird" && (
<div>
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
💻 Mozilla Thunderbird (Windows / Mac / Linux)
</div>
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<li>{t("syncThunderbirdStep1")}</li>
<li>{t("syncThunderbirdStep2")}</li>
<li>{t("syncThunderbirdStep3")}</li>
<li>{t("syncThunderbirdStep4")}</li>
</ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> Thunderbird Tasks view will display CheckFlow priority tags, due dates, and completion status.
</div>
</div>
)}
</div>
</div>
)}
{/* Tab 4: Admin Quick Access */}
{activeTab === "admin" && (
<div className="settings-tab-content">
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>👑 Multi-User Admin Console</h3>
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 14 }}>
Manage registered users, user roles (User/Admin), system statistics, and storage allocations.
</p>
<a href="/admin" className="btn btn-primary" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
🚀 Open Admin Dashboard
</a>
</div>
)}
</div>
{/* Footer */}
<div
className="modal-footer"
style={{ marginTop: 16, paddingTop: 12, borderTop: "1px solid var(--border)", display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
{savedMsg && <span style={{ fontSize: 13, color: "var(--success)", fontWeight: 600 }}>{savedMsg}</span>}
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
<button className="btn btn-ghost" onClick={onClose} type="button">{t("cancel")}</button>
<button className="btn btn-primary" onClick={handleSave} type="button">
{t("save") || "Save Changes"}
</button>
</div>
</div>
</div>
</div>
);
}
+221
View File
@@ -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 (
<div
key={task.id}
className={`kanban-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTask(task)}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<button
type="button"
className={`task-check-btn${task.completed ? " checked" : ""}`}
style={{ width: 17, height: 17, marginTop: 2, flexShrink: 0 }}
onClick={(e) => {
e.stopPropagation();
onToggleTask(task.id, !task.completed);
}}
aria-label="Toggle task completion"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13.5,
fontWeight: 600,
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
textDecoration: task.completed ? "line-through" : "none",
wordBreak: "break-word",
lineHeight: 1.4,
}}
>
{task.title}
</div>
{/* Badges row: Priority, Due Date, Subtask progress */}
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
{task.priority > 0 && (
<span
style={{
fontSize: 10,
fontWeight: 700,
padding: "2px 6px",
borderRadius: "var(--radius-sm)",
background: `${PRIORITY_COLORS[task.priority]}15`,
color: PRIORITY_COLORS[task.priority],
display: "inline-flex",
alignItems: "center",
gap: 3,
}}
>
🚩 P{task.priority}
</span>
)}
{task.dueDate && (
<span
style={{
fontSize: 10,
fontWeight: 500,
padding: "2px 6px",
borderRadius: "var(--radius-sm)",
background: "var(--bg-hover)",
color: "var(--text-secondary)",
}}
>
📅 {new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</span>
)}
{subtaskCount > 0 && (
<span
style={{
fontSize: 10,
fontWeight: 600,
padding: "2px 6px",
borderRadius: "var(--radius-sm)",
background: "var(--bg-hover)",
color: "var(--text-tertiary)",
}}
>
{completedSubtasks}/{subtaskCount}
</span>
)}
{task.note && (
<span style={{ fontSize: 10, color: "var(--text-tertiary)" }} title="Has note">
📝
</span>
)}
</div>
</div>
</div>
</div>
);
};
return (
<div className="kanban-container">
{/* Column 1: To Do */}
<div className="kanban-column">
<div className="kanban-column-header">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--text-tertiary)" }} />
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-primary)" }}>{t("todoCol")}</span>
</div>
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", background: "var(--bg-hover)", padding: "2px 8px", borderRadius: "var(--radius-full)" }}>
{todoTasks.length}
</span>
</div>
<div className="kanban-column-body">
{todoTasks.map(renderCard)}
{/* Quick inline add in To Do */}
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
<input
className="form-input"
placeholder={t("addTaskPlaceholder")}
style={{ fontSize: 12, padding: "6px 10px", flex: 1 }}
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newTodoTitle.trim()) {
e.preventDefault();
onAddTask(newTodoTitle.trim(), 0);
setNewTodoTitle("");
}
}}
/>
</div>
</div>
</div>
{/* Column 2: In Progress / High Priority */}
<div className="kanban-column">
<div className="kanban-column-header">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)" }} />
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-primary)" }}>{t("inProgressCol")}</span>
</div>
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--accent)", background: "var(--accent-light)", padding: "2px 8px", borderRadius: "var(--radius-full)" }}>
{inProgressTasks.length}
</span>
</div>
<div className="kanban-column-body">
{inProgressTasks.map(renderCard)}
{/* Quick inline add in In Progress */}
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
<input
className="form-input"
placeholder={t("addTaskPlaceholder")}
style={{ fontSize: 12, padding: "6px 10px", flex: 1 }}
value={newProgTitle}
onChange={(e) => setNewProgTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newProgTitle.trim()) {
e.preventDefault();
onAddTask(newProgTitle.trim(), 2);
setNewProgTitle("");
}
}}
/>
</div>
</div>
</div>
{/* Column 3: Done */}
<div className="kanban-column">
<div className="kanban-column-header">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--success)" }} />
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-primary)" }}>{t("doneCol")}</span>
</div>
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--success)", background: "rgba(16, 185, 129, 0.12)", padding: "2px 8px", borderRadius: "var(--radius-full)" }}>
{doneTasks.length}
</span>
</div>
<div className="kanban-column-body">
{doneTasks.map(renderCard)}
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useI18n } from "@/lib/i18n";
interface MarkdownNoteEditorProps {
value: string;
onChange: (newValue: string) => void;
onSave?: () => void;
}
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
const { t } = useI18n();
const [mode, setMode] = useState<"edit" | "preview">("edit");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLDivElement>(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 `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr} class="markdown-link">${text}</a>`;
};
marked.use({ renderer, breaks: true, gfm: true });
}, []);
// Keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (onSave) onSave();
} else if (e.key === "Tab") {
e.preventDefault();
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;
});
}
};
// Convert markdown to sanitized HTML with safe links
const renderMarkdownHtml = () => {
if (!value || !value.trim()) {
return `<p style="color: var(--text-tertiary); font-style: italic; padding: 8px 0;">${t("notesPlaceholder").split("\n")[0]}</p>`;
}
try {
const textWithLinks = value.replace(
/(^|[^"'])(https?:\/\/[^\s<]+)/g,
(match, prefix, url) => {
if (match.includes("](") || match.includes('href="')) return match;
return `${prefix}[${url}](${url})`;
}
);
const rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string;
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"],
});
} catch {
return value;
}
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
minHeight: 0,
position: "relative",
}}
>
{/* Minimal Header with Mode Switcher */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "4px 8px 8px 8px",
}}
>
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}>
{t("notes").toUpperCase()}
</span>
<div className="view-switcher-group" style={{ padding: 2 }}>
<button
type="button"
className={`view-switcher-btn${mode === "edit" ? " active" : ""}`}
style={{ fontSize: 11, padding: "2px 8px" }}
onClick={() => {
setMode("edit");
setTimeout(() => textareaRef.current?.focus(), 50);
}}
>
Edit
</button>
<button
type="button"
className={`view-switcher-btn${mode === "preview" ? " active" : ""}`}
style={{ fontSize: 11, padding: "2px 8px" }}
onClick={() => setMode("preview")}
>
👁 Preview
</button>
</div>
</div>
{/* Editor / Preview Area */}
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", position: "relative" }}>
{mode === "edit" ? (
<textarea
ref={textareaRef}
className="note-editor-textarea"
style={{
flex: 1,
width: "100%",
height: "100%",
minHeight: 120,
padding: "8px 10px",
background: "transparent",
border: "none",
outline: "none",
color: "var(--text-primary)",
fontSize: 13.5,
lineHeight: 1.6,
resize: "none",
fontFamily: "inherit",
}}
placeholder={t("notesPlaceholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (onSave) onSave();
}}
/>
) : (
<div
ref={previewRef}
className="note-editor-preview"
style={{
flex: 1,
width: "100%",
height: "100%",
padding: "8px 10px",
overflowY: "auto",
color: "var(--text-primary)",
fontSize: 13.5,
lineHeight: 1.6,
}}
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
onClick={() => {
setMode("edit");
setTimeout(() => textareaRef.current?.focus(), 50);
}}
/>
)}
</div>
</div>
);
}
+852
View File
@@ -0,0 +1,852 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useI18n } from "@/lib/i18n";
import { Task, Tag } from "./TaskList";
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
import { getCustomTags, MockTag } from "@/lib/mockData";
import { useUserPrefs } from "@/lib/useUserPrefs";
interface Props {
task: Task;
listId: string;
listName?: string;
lists?: { id: string; name: string; color: string }[];
onMoveList?: (targetListId: string) => void;
onClose: () => void;
onUpdate: (t: Task) => void;
onDelete: () => void;
isDemo?: boolean;
onDemoUpdateTask?: (updated: Task) => void;
onDemoDeleteTask?: (id: string) => void;
}
export function TaskDetail({
task,
listId,
listName,
lists = [],
onMoveList,
onClose,
onUpdate,
onDelete,
isDemo = false,
onDemoUpdateTask,
onDemoDeleteTask,
}: Props) {
const { t, lang } = useI18n();
const [title, setTitle] = useState(task.title);
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<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
const [showTagPicker, setShowTagPicker] = useState(false);
const [showListPicker, setShowListPicker] = useState(false);
const [newTagName, setNewTagName] = useState("");
// Modular Blocks Customization via global prefs
const { prefs, updatePrefs } = useUserPrefs();
const blockOrder = prefs.detailBlockOrder;
const splitRatio = prefs.detailSplitRatio;
const setBlockOrder = useCallback((next: ("subtasks" | "note")[]) => {
updatePrefs({ detailBlockOrder: next });
}, [updatePrefs]);
const setSplitRatio = useCallback((r: number) => {
updatePrefs({ detailSplitRatio: r });
}, [updatePrefs]);
const [subtasksCollapsed, setSubtasksCollapsed] = useState(false);
const [noteCollapsed, setNoteCollapsed] = useState(false);
const [isDraggingSplit, setIsDraggingSplit] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [newSubtitle, setNewSubtitle] = useState("");
const [subtasks, setSubtasks] = useState(task.children || []);
const [saving, setSaving] = useState(false);
// Mobile Bottom-Sheet: swipe down to close
const touchStartY = useRef<number | null>(null);
const touchStartX = useRef<number | null>(null);
const gestureDirection = useRef<"vertical" | "horizontal" | null>(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<ReturnType<typeof setTimeout> | null>(null);
const currentTaskId = useRef(task.id);
const priorityMap = [
{ label: t("priorityNone"), color: "var(--text-tertiary)", icon: "" },
{ label: t("priorityLow"), color: "var(--priority-low)", icon: "▼" },
{ label: t("priorityMedium"), color: "var(--priority-medium)", icon: "▶" },
{ label: t("priorityHigh"), color: "var(--priority-high)", icon: "▲" },
];
// Toggle block order — prefs store handles persistence
const toggleBlockOrder = () => {
const next = blockOrder[0] === "subtasks"
? ["note", "subtasks"] as ("subtasks" | "note")[]
: ["subtasks", "note"] as ("subtasks" | "note")[];
setBlockOrder(next);
};
const handleSplitMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsDraggingSplit(true);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingSplit || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
let ratio = (relativeY / rect.height) * 100;
ratio = Math.max(15, Math.min(85, ratio));
setSplitRatio(ratio);
};
const handleMouseUp = () => {
if (isDraggingSplit) {
setIsDraggingSplit(false);
}
};
if (isDraggingSplit) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isDraggingSplit, setSplitRatio]);
// Sync state when switching task
useEffect(() => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
currentTaskId.current = task.id;
setTitle(task.title);
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, task.tags]);
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
const save = useCallback(
async (taskId: string, data: Record<string, unknown>) => {
if (taskId !== currentTaskId.current) return;
setSaving(true);
if (isDemo) {
const updatedTask: Task = {
...task,
...data,
children: subtasks,
tags,
updatedAt: new Date().toISOString(),
} as Task;
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
onUpdate(updatedTask);
setSaving(false);
return;
}
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const updated = await res.json();
if (taskId === currentTaskId.current) {
onUpdate({ ...updated, children: subtasks, tags });
}
}
} catch (err) {
console.error("[TaskDetail] save failed", err);
} finally {
setSaving(false);
}
},
[isDemo, onDemoUpdateTask, onUpdate, subtasks, tags, task]
);
const debounceSave = useCallback(
(data: Record<string, unknown>) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
save(task.id, data);
}, 500);
},
[save, task.id]
);
const handleNoteChange = (newNote: string) => {
setNote(newNote);
debounceSave({ note: newNote });
};
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();
return;
}
try {
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
onDelete();
} catch (err) {
console.error("[TaskDetail] delete failed", err);
}
}, [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;
if (isDemo) {
const newSub: Task = {
id: "demo-sub-" + Date.now(),
listId,
parentId: task.id,
title: tTitle,
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 0,
sortOrder: subtasks.length,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [],
tags: [],
};
const nextSubs = [...subtasks, newSub];
setSubtasks(nextSubs);
setNewSubtitle("");
const updatedParent = { ...task, children: nextSubs };
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
onUpdate(updatedParent);
return;
}
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: tTitle, listId, parentId: task.id }),
});
if (res.ok) {
const sub = await res.json();
setSubtasks((prev) => {
const next = [...prev, sub];
onUpdate({ ...task, children: next });
return next;
});
setNewSubtitle("");
}
} catch (err) {
console.error("[TaskDetail] addSubtask failed", err);
}
}, [newSubtitle, isDemo, listId, task, subtasks, onDemoUpdateTask, onUpdate]);
const toggleSubtask = async (sub: Task) => {
const nextCompleted = !sub.completed;
const nextSubs = subtasks.map((s) => (s.id === sub.id ? { ...s, completed: nextCompleted } : s));
setSubtasks(nextSubs);
const updatedParent = { ...task, children: nextSubs };
if (isDemo && onDemoUpdateTask) {
onDemoUpdateTask(updatedParent);
}
onUpdate(updatedParent);
if (!isDemo) {
try {
await fetch(`/api/tasks/${sub.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: nextCompleted }),
});
} catch (err) {
console.error("[TaskDetail] toggleSubtask failed", err);
}
}
};
const deleteSubtask = async (subId: string) => {
const nextSubs = subtasks.filter((s) => s.id !== subId);
setSubtasks(nextSubs);
const updatedParent = { ...task, children: nextSubs };
if (isDemo && onDemoUpdateTask) {
onDemoUpdateTask(updatedParent);
}
onUpdate(updatedParent);
if (!isDemo) {
try {
await fetch(`/api/tasks/${subId}`, { method: "DELETE" });
} catch (err) {
console.error("[TaskDetail] deleteSubtask failed", err);
}
}
};
const completedCount = subtasks.filter((s) => s.completed).length;
// Render Subtasks Block
const renderSubtasksBlock = () => {
return (
<div
className="detail-block-card"
style={{
flex: subtasksCollapsed ? "0 0 auto" : `0 0 ${noteCollapsed ? "100%" : `${splitRatio}%`}`,
minHeight: subtasksCollapsed ? 38 : 120,
display: "flex",
flexDirection: "column",
}}
>
<div className="detail-block-header">
<div
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
onClick={() => setSubtasksCollapsed((p) => !p)}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{ transform: subtasksCollapsed ? "rotate(0deg)" : "rotate(90deg)", transition: "transform 0.15s" }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-primary)" }}>
{t("subtasks").toUpperCase()}
</span>
{subtasks.length > 0 && (
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", background: "var(--bg-hover)", padding: "1px 6px", borderRadius: "var(--radius-full)" }}>
{completedCount}/{subtasks.length}
</span>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
type="button"
className="icon-btn"
style={{ width: 22, height: 22, fontSize: 11 }}
onClick={toggleBlockOrder}
title={t("swapBlocks")}
>
</button>
</div>
</div>
{!subtasksCollapsed && (
<div style={{ padding: "8px 12px", flex: 1, display: "flex", flexDirection: "column", gap: 6, overflowY: "auto" }}>
{/* Progress Bar */}
{subtasks.length > 0 && (
<div style={{ height: 3, background: "var(--bg-hover)", borderRadius: 2, marginBottom: 4, overflow: "hidden" }}>
<div
style={{
height: "100%",
width: `${(completedCount / subtasks.length) * 100}%`,
background: "var(--accent)",
borderRadius: 2,
transition: "width var(--dur-normal) var(--ease-out)",
}}
/>
</div>
)}
{/* List of subtasks */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{subtasks.map((sub) => (
<div
key={sub.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 10px",
borderRadius: "var(--radius-sm)",
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
}}
>
<button
className={`task-check-btn${sub.completed ? " checked" : ""}`}
style={{ width: 16, height: 16, flexShrink: 0 }}
onClick={() => toggleSubtask(sub)}
aria-label="Toggle subtask"
type="button"
/>
<span
style={{
flex: 1,
fontSize: 13,
textDecoration: sub.completed ? "line-through" : "none",
color: sub.completed ? "var(--text-tertiary)" : "var(--text-primary)",
fontWeight: 500,
}}
>
{sub.title}
</span>
<button
className="icon-btn"
style={{ width: 20, height: 20, opacity: 0.4 }}
onClick={() => deleteSubtask(sub.id)}
title="Delete subtask"
type="button"
>
</button>
</div>
))}
</div>
{/* Quick Add Subtask Input */}
<div style={{ display: "flex", gap: 6, marginTop: "auto", paddingTop: 4 }}>
<input
className="form-input"
placeholder={t("addSubtaskPlaceholder")}
style={{ flex: 1, fontSize: 12.5, padding: "5px 10px" }}
value={newSubtitle}
onChange={(e) => setNewSubtitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addSubtask();
}
}}
/>
{newSubtitle.trim() && (
<button className="btn btn-primary btn-sm" onClick={addSubtask} type="button">
{t("add")}
</button>
)}
</div>
</div>
)}
</div>
);
};
// Render Note Block
const renderNoteBlock = () => {
return (
<div
className="detail-block-card"
style={{
flex: noteCollapsed ? "0 0 auto" : `0 0 ${subtasksCollapsed ? "100%" : `${100 - splitRatio}%`}`,
minHeight: noteCollapsed ? 38 : 120,
display: "flex",
flexDirection: "column",
}}
>
<div className="detail-block-header">
<div
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
onClick={() => setNoteCollapsed((p) => !p)}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{ transform: noteCollapsed ? "rotate(0deg)" : "rotate(90deg)", transition: "transform 0.15s" }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-primary)" }}>
📝 {t("notes").toUpperCase()}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
type="button"
className="icon-btn"
style={{ width: 22, height: 22, fontSize: 11 }}
onClick={toggleBlockOrder}
title={t("swapBlocks")}
>
</button>
</div>
</div>
{!noteCollapsed && (
<div style={{ flex: 1, padding: 8, minHeight: 0, display: "flex", flexDirection: "column" }}>
<MarkdownNoteEditor
value={note}
onChange={handleNoteChange}
onSave={() => save(task.id, { note })}
/>
</div>
)}
</div>
);
};
return (
<aside
className="detail-panel mobile-open"
style={{
transform: isMobile ? `translateY(${panelTranslateY}px)` : "none",
transition: panelTranslateY === 0 ? "transform 0.25s var(--ease-out)" : "none",
}}
>
{/* Mobile swipe indicator */}
<div
className="mobile-swipe-handle mobile-only"
style={{
width: "100%",
padding: "10px 0 4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
cursor: "grab",
}}
onTouchStart={(e) => {
touchStartY.current = e.touches[0].clientY;
touchStartX.current = e.touches[0].clientX;
gestureDirection.current = null;
}}
onTouchMove={(e) => {
if (touchStartY.current === null) return;
const deltaY = e.touches[0].clientY - touchStartY.current;
if (deltaY > 0) setPanelTranslateY(deltaY);
}}
onTouchEnd={() => {
if (panelTranslateY > 120) {
onClose();
}
setPanelTranslateY(0);
touchStartY.current = null;
}}
>
<div style={{ width: 36, height: 4, borderRadius: 2, background: "var(--border-strong)" }} />
</div>
{/* Top Header */}
<div className="detail-header">
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 0, position: "relative" }}>
{/* Breadcrumb / Project Move selector */}
<div style={{ position: "relative" }}>
<button
type="button"
className="tick-meta-chip"
onClick={() => setShowListPicker((p) => !p)}
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--accent)",
background: "var(--accent-light)",
borderColor: "transparent",
}}
title={t("moveToList")}
>
📁 {listName || t("tasks")}
</button>
{showListPicker && lists.length > 0 && (
<div
className="dropdown"
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 160, padding: 4, zIndex: 110 }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ padding: "4px 8px", fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)" }}>
{t("moveToList")}
</div>
{lists.map((l) => (
<div
key={l.id}
className="context-menu-item"
style={{
padding: "6px 10px",
fontSize: 12.5,
cursor: "pointer",
background: l.id === listId ? "var(--bg-active)" : "transparent",
}}
onClick={() => {
if (onMoveList && l.id !== listId) {
onMoveList(l.id);
}
setShowListPicker(false);
}}
>
📁 {l.name}
</div>
))}
</div>
)}
</div>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
{saving ? `${t("saving")}` : `${t("autoSaved")}`}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
className="icon-btn"
onClick={handleDelete}
title={t("deleteTaskConfirm").split("?")[0]}
style={{ color: "var(--danger)" }}
type="button"
>
🗑
</button>
<button className="icon-btn" onClick={onClose} title="Close" type="button">
</button>
</div>
</div>
{/* Main Body */}
<div className="detail-scroll" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 10 }}>
{/* Title and Complete Button */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
className={`task-check-btn${task.completed ? " checked" : ""}`}
onClick={handleToggleComplete}
aria-label="Toggle completion"
type="button"
/>
<input
className="detail-title-input"
value={title}
onChange={(e) => {
setTitle(e.target.value);
debounceSave({ title: e.target.value });
}}
placeholder={t("taskTitlePlaceholder")}
style={{ flex: 1, fontSize: 16, fontWeight: 600 }}
/>
</div>
{/* Metadata Chips: Due Date, Priority, Tags */}
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
{/* Due date chip */}
<input
type="date"
className="tick-meta-chip"
style={{ fontSize: 11.5, padding: "3px 8px", cursor: "pointer", border: "1px solid var(--border)" }}
value={dueDate}
onChange={(e) => {
const val = e.target.value || null;
setDueDate(e.target.value);
save(task.id, { dueDate: val });
}}
/>
{/* Priority selector */}
<select
className="tick-meta-chip"
style={{
fontSize: 11.5,
padding: "3px 8px",
cursor: "pointer",
border: "1px solid var(--border)",
color: priority > 0 ? priorityMap[priority].color : "inherit",
fontWeight: priority > 0 ? 700 : 500,
}}
value={priority}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
setPriority(val);
save(task.id, { priority: val });
}}
>
{priorityMap.map((p, idx) => (
<option key={idx} value={idx}>
{p.icon ? `${p.icon} ` : ""}
{p.label}
</option>
))}
</select>
{/* Tag Selector */}
<div style={{ position: "relative" }}>
<button
type="button"
className="tick-meta-chip"
onClick={() => setShowTagPicker((p) => !p)}
style={{ fontSize: 11.5, padding: "3px 8px" }}
>
🏷 {tags.length > 0 ? `${tags.length} tags` : t("selectTag")}
</button>
{showTagPicker && (
<div
className="dropdown"
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 180, padding: 8, zIndex: 110 }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", marginBottom: 6 }}>
{t("tags")}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 140, overflowY: "auto" }}>
{allAvailableTags.map((tag) => {
const active = tags.some((tItem) => tItem.tag.id === tag.id);
return (
<div
key={tag.id}
className="context-menu-item"
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
onClick={() => toggleTag(tag)}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: tag.color || "var(--accent)" }} />
<span style={{ flex: 1 }}>{tag.name}</span>
{active && <span></span>}
</div>
);
})}
</div>
<div style={{ display: "flex", gap: 4, marginTop: 6, borderTop: "1px solid var(--border)", paddingTop: 6 }}>
<input
className="form-input"
placeholder="New tag..."
style={{ fontSize: 11, padding: "3px 6px", flex: 1 }}
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomTag();
}
}}
/>
<button className="btn btn-primary btn-sm" style={{ fontSize: 10, padding: "2px 6px" }} onClick={addCustomTag} type="button">
+
</button>
</div>
</div>
)}
</div>
</div>
{/* Modular Blocks Container (Subtasks & Notes) with Split Resizer */}
<div
ref={containerRef}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 280,
overflow: "hidden",
gap: 4,
}}
>
{blockOrder.map((blockType, idx) => (
<React.Fragment key={blockType}>
{blockType === "subtasks" ? renderSubtasksBlock() : renderNoteBlock()}
{/* Split Resizer bar between blocks if neither is collapsed */}
{idx === 0 && !subtasksCollapsed && !noteCollapsed && (
<div
className={`detail-split-resizer${isDraggingSplit ? " dragging" : ""}`}
onMouseDown={handleSplitMouseDown}
title="Drag to resize blocks"
>
<div className="detail-split-resizer-line" />
</div>
)}
</React.Fragment>
))}
</div>
{/* Footer Info */}
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid var(--border)", paddingTop: 8, flexWrap: "wrap", gap: 6 }}>
{listName && (
<span className="tick-meta-chip" style={{ fontSize: 11, padding: "2px 8px" }}>
📁 {listName}
</span>
)}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginLeft: "auto", flexWrap: "wrap" }}>
<span>
{t("created")}:{" "}
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
{task.updatedAt && (
<span>
{t("edited")}:{" "}
{new Date(task.updatedAt).toLocaleTimeString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
</div>
</div>
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { useI18n } from "@/lib/i18n";
import { Task } from "../tasks/TaskList";
interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
tasks: Task[];
onSelectTask: (task: Task) => void;
}
export function CommandPalette({ isOpen, onClose, tasks, onSelectTask }: CommandPaletteProps) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
if (isOpen) onClose();
else {
// Open handled by parent
document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"));
}
}
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Flatten recursive tasks for search
const flattenTasks = (list: Task[]): Task[] => {
let result: Task[] = [];
for (const item of list) {
result.push(item);
if (item.children && item.children.length > 0) {
result = result.concat(flattenTasks(item.children));
}
}
return result;
};
const allTasks = flattenTasks(tasks);
const filtered = query.trim()
? allTasks.filter(
(task) =>
task.title.toLowerCase().includes(query.toLowerCase()) ||
(task.note && task.note.toLowerCase().includes(query.toLowerCase())) ||
(task.tags && task.tags.some((t) => t.tag.name.toLowerCase().includes(query.toLowerCase())))
)
: allTasks.slice(0, 8);
return (
<div className="modal-overlay" onClick={onClose} style={{ zIndex: 99999, alignItems: "flex-start", paddingTop: "12vh" }}>
<div
className="modal command-palette-modal"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: 580, width: "90%", padding: 0, overflow: "hidden", borderRadius: "var(--radius-md)" }}
>
{/* Search Header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 18px", borderBottom: "1px solid var(--border)" }}>
<span style={{ fontSize: 16, opacity: 0.6 }}>🔍</span>
<input
ref={inputRef}
className="form-input"
placeholder={t("searchPlaceholder") || "Search all tasks, notes, tags (Ctrl+K)..."}
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ border: "none", background: "none", fontSize: 15, padding: 0, outline: "none", boxShadow: "none" }}
/>
<span style={{ fontSize: 11, color: "var(--text-tertiary)", background: "var(--bg-secondary)", padding: "2px 6px", borderRadius: 4 }}>
ESC
</span>
</div>
{/* Results List */}
<div style={{ maxHeight: 340, overflowY: "auto", padding: "8px 0" }}>
{filtered.length === 0 ? (
<div style={{ padding: "24px 20px", textAlign: "center", color: "var(--text-tertiary)", fontSize: 13 }}>
No matching tasks or notes found
</div>
) : (
filtered.map((task) => (
<div
key={task.id}
className="context-menu-item"
style={{ padding: "10px 18px", gap: 12, borderRadius: 0 }}
onClick={() => {
onSelectTask(task);
onClose();
}}
>
<button
className={`task-check-btn${task.completed ? " checked" : ""}`}
style={{ width: 16, height: 16, flexShrink: 0 }}
type="button"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{task.title}
</div>
{task.note && (
<div style={{ fontSize: 11, color: "var(--text-tertiary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{task.note.replace(/[#*`]/g, "").slice(0, 60)}
</div>
)}
</div>
{task.tags && task.tags.length > 0 && (
<div style={{ display: "flex", gap: 4 }}>
{task.tags.map((tg) => (
<span key={tg.tag.id} className="badge" style={{ background: tg.tag.color + "22", color: tg.tag.color, fontSize: 10, padding: "1px 6px" }}>
#{tg.tag.name}
</span>
))}
</div>
)}
</div>
))
)}
</div>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React, { useEffect, useRef } from "react";
export interface MenuItem {
label: string;
icon?: string | React.ReactNode;
danger?: boolean;
divider?: boolean;
onClick?: () => void;
}
interface ContextMenuProps {
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
// Adjust coordinates so menu stays inside viewport
const adjustedX = typeof window !== "undefined" ? Math.min(x, window.innerWidth - 180) : x;
const adjustedY = typeof window !== "undefined" ? Math.min(y, window.innerHeight - 250) : y;
return (
<div
ref={menuRef}
className="custom-context-menu"
style={{
position: "fixed",
top: adjustedY,
left: adjustedX,
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item, i) => {
if (item.divider) {
return <div key={i} className="context-menu-divider" />;
}
return (
<div
key={i}
className={`context-menu-item${item.danger ? " danger" : ""}`}
onClick={() => {
if (item.onClick) item.onClick();
onClose();
}}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span style={{ flex: 1 }}>{item.label}</span>
</div>
);
})}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { useI18n, Language } from "@/lib/i18n";
const LANGUAGES: { code: Language; label: string; flag: string }[] = [
{ code: "en", label: "English", flag: "🇺🇸" },
{ code: "ko", label: "한국어", flag: "🇰🇷" },
{ code: "ja", label: "日本語", flag: "🇯🇵" },
];
export function LanguageSelector() {
const { lang, setLang } = useI18n();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const current = LANGUAGES.find((l) => l.code === lang) || LANGUAGES[0];
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div className="lang-selector-wrap" ref={wrapRef}>
<button
className="lang-btn"
onClick={() => setOpen((p) => !p)}
aria-label="Select Language"
type="button"
>
<span style={{ fontSize: 13, lineHeight: 1 }}>🌐</span>
<span>{current.code.toUpperCase()}</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{ transform: open ? "rotate(180deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{open && (
<div className="lang-dropdown">
{LANGUAGES.map((item) => (
<div
key={item.code}
className={`lang-option${lang === item.code ? " selected" : ""}`}
onClick={() => {
setLang(item.code);
setOpen(false);
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span>{item.flag}</span>
<span>{item.label}</span>
</div>
{lang === item.code && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
))}
</div>
)}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const authOptions: NextAuthOptions = {
session: { strategy: "jwt" },
pages: {
signIn: "/login",
newUser: "/register",
},
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
if (!valid) return null;
// Admin identification logic
const adminEmail = process.env.ADMIN_EMAIL;
const isEmailAdmin = adminEmail && user.email === adminEmail;
const isAdmin = isEmailAdmin || user.email.endsWith("@checkflow.local") || user.email.startsWith("admin@");
const role = isAdmin ? "ADMIN" : "USER";
return { id: user.id, email: user.email, name: user.name, role };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role?: string }).role;
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
};
+595
View File
@@ -0,0 +1,595 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
export type Language = "en" | "ko" | "ja";
export const translations = {
en: {
// Auth
appName: "CheckFlow",
tagline: "Your Personal Todo",
welcomeBack: "Welcome back",
signInSubtitle: "Sign in to your account",
createAccount: "Create account",
createAccountSubtitle: "Start organizing your tasks today",
displayName: "Display Name",
email: "Email",
password: "Password",
min8Chars: "Min. 8 characters",
signIn: "Sign in",
signingIn: "Signing in...",
createBtn: "Create account",
creatingBtn: "Creating account...",
alreadyHaveAccount: "Already have an account?",
dontHaveAccount: "Don't have an account?",
tryDemoMode: "Try Demo Mode (No DB Required)",
demoBadge: "Demo Mode",
signOut: "Sign out",
// Sidebar
lists: "Lists",
newList: "New List",
listNamePlaceholder: "List name",
create: "Create",
cancel: "Cancel",
save: "Save",
deleteListConfirm: "Delete this list and all its tasks?",
undoDelete: "Undo",
listDeleted: "List deleted",
importTasks: "Import Tasks",
importModalTitle: "Import Tasks",
targetList: "Target List",
fileSelectLabel: "File (CSV or ICS from TickTick)",
tickTickExportHint: "TickTick: Settings → Export → Export as CSV or iCalendar",
importBtn: "Import",
importing: "Importing...",
exportTasks: "Export Tasks",
exportModalTitle: "Export Tasks",
exportFormat: "Format",
exportScope: "Target List",
allLists: "All Lists (Entire Tasks)",
includeCompleted: "Include Completed Tasks",
exportBtn: "Export & Download",
exportSuccess: "Tasks exported successfully!",
theme: "Theme",
themeSystem: "System",
themeLight: "Light",
themeDark: "Dark",
language: "Language",
tags: "Tags",
trash: "Trash",
emptyTrash: "Empty Trash",
searchPlaceholder: "Search tasks, notes, tags...",
searchExpandedPlaceholder: "Search anything — tasks, notes, tags (Ctrl+K)...",
// Settings Modal
settingsModalTitle: "Settings",
profile: "Profile",
preferences: "Preferences",
syncIntegrations: "Integrations (DAVx⁵)",
admin: "Admin",
trashRetention: "Trash Retention Period",
trashRetentionHint: "Deleted tasks will be permanently removed after the specified period.",
days7: "7 Days",
days14: "14 Days",
days30: "30 Days (Recommended)",
neverDelete: "Never Auto-Delete",
// CalDAV & Sync
syncTitle: "Galaxy & External Sync (CalDAV)",
syncDesc: "CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV.",
syncBaseUrl: "CalDAV Server Base URL",
syncCopyUrl: "Copy URL",
syncCopied: "Copied!",
syncDownloadIcs: "Download .ICS Feed",
syncTestConnection: "Test Endpoint",
syncTesting: "Testing...",
syncTestSuccess: "✓ CalDAV endpoint responded successfully (200 OK)",
syncTabAndroid: "Galaxy / Android (DAVx⁵)",
syncTabApple: "Apple Reminders (iOS / macOS)",
syncTabThunderbird: "Thunderbird (Desktop)",
syncAndroidStep1: "Install DAVx⁵ from Google Play Store or F-Droid.",
syncAndroidStep2: "Open DAVx⁵ → Add Account → Select 'Login with URL and user name'.",
syncAndroidStep3: "Paste the Base URL above, then enter your CheckFlow email & password.",
syncAndroidStep4: "Enable 'VTODO (Tasks)' collection to sync with Samsung Reminder or OpenTasks.",
syncAppleStep1: "Open Settings → Reminders → Accounts → Add Account.",
syncAppleStep2: "Select 'Other' → Add CalDAV Account.",
syncAppleStep3: "Server: paste base URL without protocol, User/Pass: CheckFlow credentials.",
syncAppleStep4: "Toggle 'Reminders' ON and tap Save.",
syncThunderbirdStep1: "Open Thunderbird → Switch to Calendar view → New Calendar.",
syncThunderbirdStep2: "Select 'On the Network' → Format: CalDAV.",
syncThunderbirdStep3: "Location: paste the Base URL, Username: CheckFlow email.",
syncThunderbirdStep4: "Enter your password when prompted to finalize sync.",
// Task List
tasks: "Tasks",
hideDone: "Hide done",
showDone: "Show done",
completedSection: "Completed",
addTaskPlaceholder: "Add a task...",
add: "Add",
noTasksYet: "No tasks yet. Add one below!",
selectListToStart: "Select a list to get started",
loading: "Loading...",
today: "Today",
tomorrow: "Tomorrow",
// Task Detail
taskTitlePlaceholder: "Task title",
priority: "Priority",
priorityNone: "None",
priorityLow: "Low",
priorityMedium: "Medium",
priorityHigh: "High",
dueDate: "Due Date",
clear: "Clear",
notes: "Notes",
notesPlaceholder: "Add notes, details, or anything you need to remember…\n\nMarkdown supported: **bold**, *italic*, # heading, - list, - [ ] checkbox",
subtasks: "Sub-tasks",
addSubtaskPlaceholder: "Add sub-task…",
deleteTaskConfirm: "Delete this task?",
autoSaved: "Auto-saved",
saving: "Saving…",
created: "Created",
edited: "Edited",
// Toolbars
bold: "Bold",
italic: "Italic",
heading: "Heading",
bulletList: "Bullet list",
numberedList: "Numbered list",
checkbox: "Checkbox",
code: "Code",
// TickTick Mode Switcher & Quick Add
textMode: "Text Note Mode",
subtaskMode: "Checklist Mode",
switchToTextMode: "Switch to Markdown Note",
switchToSubtaskMode: "Switch to Checklist Sub-tasks",
moveToList: "Move to List",
quickAdd: "Quick Add",
quickToday: "Today",
quickTomorrow: "Tomorrow",
quickNextWeek: "Next Week",
selectDate: "Date",
selectPriority: "Priority",
selectTag: "Tag",
copied: "Copied!",
// Context Menu
editTask: "Edit",
duplicateTask: "Duplicate",
moveTask: "Move to…",
deleteTask: "Delete",
renameList: "Rename",
deleteList: "Delete list",
taskUndoHint: "Deleted. Tap to undo.",
listUndoHint: "List deleted. Tap to undo.",
// Labs, Kanban & Customization
labs: "Labs",
labsDesc: "Enable or disable experimental features individually.",
kanbanView: "Kanban Board",
listView: "List View",
enableKanban: "Kanban Board View",
enableKanbanDesc: "Switch between list and 3-column board (To Do / Doing / Done).",
swapBlocks: "Swap Position",
resetDefaults: "Reset to Defaults",
resetConfirm: "Reset all UI customizations to factory defaults?",
todoCol: "To Do",
inProgressCol: "In Progress",
doneCol: "Done",
accessRestricted: "Access Restricted",
adminRequired: "This console requires ADMIN role.",
},
ko: {
// Auth
appName: "CheckFlow",
tagline: "나만의 할 일 관리, 심플하게",
welcomeBack: "다시 돌아오셨네요 👋",
signInSubtitle: "이메일로 로그인해주세요",
createAccount: "새 계정 만들기",
createAccountSubtitle: "오늘부터 할 일을 깔끔하게 정리해볼까요?",
displayName: "이름 (닉네임)",
email: "이메일",
password: "비밀번호",
min8Chars: "8자 이상 입력해주세요",
signIn: "로그인",
signingIn: "로그인 중이에요...",
createBtn: "계정 만들기",
creatingBtn: "계정 만드는 중...",
alreadyHaveAccount: "이미 계정이 있으신가요?",
dontHaveAccount: "아직 계정이 없으신가요?",
tryDemoMode: "로그인 없이 체험해보기",
demoBadge: "체험 중",
signOut: "로그아웃",
// Sidebar
lists: "목록",
newList: "새 목록",
listNamePlaceholder: "목록 이름을 입력해주세요",
create: "만들기",
cancel: "취소",
save: "저장",
deleteListConfirm: "이 목록을 삭제할까요? 안에 있는 할 일도 모두 사라져요.",
undoDelete: "되돌리기",
listDeleted: "목록이 삭제됐어요",
importTasks: "가져오기",
importModalTitle: "할 일 가져오기",
targetList: "가져올 목록 선택",
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)",
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar",
importBtn: "가져오기",
importing: "가져오는 중이에요...",
exportTasks: "내보내기",
exportModalTitle: "할 일 내보내기",
exportFormat: "내보내기 형식",
exportScope: "내보낼 목록",
allLists: "모든 목록 (전체 할 일)",
includeCompleted: "완료된 항목 포함",
exportBtn: "내보내기 및 다운로드",
exportSuccess: "할 일을 성공적으로 내보냈어요!",
theme: "테마",
themeSystem: "시스템",
themeLight: "라이트",
themeDark: "다크",
language: "언어",
tags: "태그",
trash: "휴지통",
emptyTrash: "휴지통 비우기",
searchPlaceholder: "검색...",
searchExpandedPlaceholder: "할 일, 메모, 태그 무엇이든 검색해보세요 (Ctrl+K)...",
// Settings Modal
settingsModalTitle: "설정",
profile: "프로필",
preferences: "환경설정",
syncIntegrations: "외부 연동 (CalDAV)",
admin: "관리자",
trashRetention: "휴지통 자동 비우기",
trashRetentionHint: "설정한 기간이 지나면 휴지통이 자동으로 비워져요.",
days7: "7일 후 자동 삭제",
days14: "14일 후 자동 삭제",
days30: "30일 후 자동 삭제 (권장)",
neverDelete: "자동 삭제 안 함",
// CalDAV & Sync
syncTitle: "갤럭시 및 외부 캘린더 연동 (CalDAV)",
syncDesc: "삼성 갤럭시 리마인더, 애플 미리알림, Thunderbird 등 CalDAV 표준을 지원하는 모든 기기와 양방향으로 동기화됩니다.",
syncBaseUrl: "CalDAV 기본 주소 (Base URL)",
syncCopyUrl: "URL 복사",
syncCopied: "복사 완료!",
syncDownloadIcs: ".ICS 피드 다운로드",
syncTestConnection: "엔드포인트 점검",
syncTesting: "점검 중...",
syncTestSuccess: "✓ CalDAV 엔드포인트가 정상적으로 응답합니다 (200 OK)",
syncTabAndroid: "갤럭시 / 안드로이드 (DAVx⁵)",
syncTabApple: "애플 미리알림 (iOS / macOS)",
syncTabThunderbird: "Thunderbird (PC)",
syncAndroidStep1: "구글 플레이 스토어 또는 F-Droid에서 'DAVx⁵' 앱을 설치합니다.",
syncAndroidStep2: "DAVx⁵ 실행 → 계정 추가(+) → 'URL 및 사용자 이름으로 로그인'을 선택합니다.",
syncAndroidStep3: "위 기본 주소를 붙여넣고, CheckFlow 로그인 이메일과 비밀번호를 입력합니다.",
syncAndroidStep4: "'VTODO (할 일)'을 켜면 삼성 리마인더 또는 OpenTasks와 자동으로 실시간 동기화됩니다.",
syncAppleStep1: "기기 설정 → 미리알림(또는 캘린더) → 계정 → '계정 추가'를 탭합니다.",
syncAppleStep2: "'기타' 선택 → 'CalDAV 계정 추가'를 누릅니다.",
syncAppleStep3: "서버에 위 주소를 입력하고, 사용자 이름/비밀번호에 CheckFlow 계정 정보를 입력합니다.",
syncAppleStep4: "'미리알림' 항목을 활성화하고 저장합니다.",
syncThunderbirdStep1: "Thunderbird 실행 → 캘린더 탭으로 이동 → '새 캘린더'를 클릭합니다.",
syncThunderbirdStep2: "'네트워크에 저장' 선택 → 형식: 'CalDAV'를 선택합니다.",
syncThunderbirdStep3: "위치에 위 기본 주소를 붙여넣고, 사용자 이름에 CheckFlow 이메일을 입력합니다.",
syncThunderbirdStep4: "인증 팝업이 뜨면 비밀번호를 입력하여 완료합니다.",
// Task List
tasks: "할 일",
hideDone: "완료 숨기기",
showDone: "완료 보기",
completedSection: "완료한 항목",
addTaskPlaceholder: "할 일을 추가해보세요...",
add: "추가",
noTasksYet: "아직 할 일이 없어요. 아래에서 추가해볼까요? 😊",
selectListToStart: "왼쪽에서 목록을 선택해주세요",
loading: "불러오는 중...",
today: "오늘",
tomorrow: "내일",
// Task Detail
taskTitlePlaceholder: "할 일 제목을 입력하세요",
priority: "우선순위",
priorityNone: "없음",
priorityLow: "낮음",
priorityMedium: "보통",
priorityHigh: "높음",
dueDate: "마감일",
clear: "지우기",
notes: "메모",
notesPlaceholder: "여기에 자유롭게 메모하세요…\n\n마크다운도 쓸 수 있어요: **굵게**, *기울임*, # 제목, - 리스트, - [ ] 체크박스",
subtasks: "하위 할 일",
addSubtaskPlaceholder: "하위 할 일 추가…",
deleteTaskConfirm: "이 할 일을 삭제할까요?",
autoSaved: "자동 저장됨",
saving: "저장 중…",
created: "만든 날",
edited: "수정한 날",
// Toolbars
bold: "굵게",
italic: "기울임",
heading: "제목",
bulletList: "글머리 기호",
numberedList: "번호 매기기",
checkbox: "체크박스",
code: "코드 블록",
// TickTick Mode Switcher & Quick Add
textMode: "텍스트 메모 모드",
subtaskMode: "체크리스트 모드",
switchToTextMode: "마크다운 메모 모드로 전환",
switchToSubtaskMode: "체크리스트 모드로 전환",
moveToList: "다른 목록으로 이동",
quickAdd: "빠른 추가",
quickToday: "오늘",
quickTomorrow: "내일",
quickNextWeek: "다음 주",
selectDate: "날짜",
selectPriority: "우선순위",
selectTag: "태그",
copied: "복사했어요!",
// Context Menu
editTask: "수정",
duplicateTask: "복제",
moveTask: "이동…",
deleteTask: "삭제",
renameList: "이름 변경",
deleteList: "목록 삭제",
taskUndoHint: "삭제했어요. 되돌릴 수 있어요.",
listUndoHint: "목록이 삭제됐어요. 되돌릴 수 있어요.",
// Labs, Kanban & Customization
labs: "실험실 🧪",
labsDesc: "아직 정식 출시 전인 기능들이에요. 원하는 걸 골라서 켜보세요!",
kanbanView: "칸반 보드",
listView: "리스트",
enableKanban: "칸반 보드 뷰",
enableKanbanDesc: "할 일을 보드 형태로 볼 수 있어요 (할 일 / 진행 중 / 완료).",
swapBlocks: "순서 바꾸기",
resetDefaults: "기본값으로 되돌리기",
resetConfirm: "모든 커스텀 설정을 기본값으로 되돌릴까요?",
todoCol: "할 일",
inProgressCol: "진행 중",
doneCol: "완료",
accessRestricted: "접근 권한 없음",
adminRequired: "이 페이지는 관리자만 볼 수 있어요.",
},
ja: {
// Auth
appName: "CheckFlow",
tagline: "シンプルに、毎日のタスクを",
welcomeBack: "おかえりなさい 👋",
signInSubtitle: "メールアドレスでサインインしてください",
createAccount: "アカウントを作成",
createAccountSubtitle: "さあ、今日からタスクを整理しましょう!",
displayName: "お名前",
email: "メールアドレス",
password: "パスワード",
min8Chars: "8文字以上で入力してください",
signIn: "サインイン",
signingIn: "サインイン中...",
createBtn: "アカウントを作成する",
creatingBtn: "作成中...",
alreadyHaveAccount: "すでにアカウントをお持ちですか?",
dontHaveAccount: "アカウントをお持ちでないですか?",
tryDemoMode: "ログインなしで試してみる",
demoBadge: "体験中",
signOut: "サインアウト",
// Sidebar
lists: "リスト",
newList: "新しいリスト",
listNamePlaceholder: "リスト名を入力してください",
create: "作成する",
cancel: "キャンセル",
save: "保存",
deleteListConfirm: "このリストを削除しますか?中のタスクもすべて消えます。",
undoDelete: "元に戻す",
listDeleted: "リストを削除しました",
importTasks: "インポート",
importModalTitle: "タスクをインポート",
targetList: "インポート先のリスト",
fileSelectLabel: "ファイルを選択(TickTick CSV または ICS",
tickTickExportHint: "TickTick: 設定 → エクスポート → CSV または iCalendar",
importBtn: "インポートする",
importing: "インポート中...",
exportTasks: "エクスポート",
exportModalTitle: "タスクをエクスポート",
exportFormat: "エクスポート形式",
exportScope: "対象リスト",
allLists: "すべてのリスト(全体)",
includeCompleted: "完了したタスクを含める",
exportBtn: "エクスポートして保存",
exportSuccess: "タスクをエクスポートしました!",
theme: "テーマ",
themeSystem: "システム",
themeLight: "ライト",
themeDark: "ダーク",
language: "言語",
tags: "タグ",
trash: "ゴミ箱",
emptyTrash: "ゴミ箱を空にする",
searchPlaceholder: "検索...",
searchExpandedPlaceholder: "タスク、メモ、タグなど何でも検索できます (Ctrl+K)...",
// Settings Modal
settingsModalTitle: "設定",
profile: "プロフィール",
preferences: "環境設定",
syncIntegrations: "外部連携 (CalDAV)",
admin: "管理者",
trashRetention: "ゴミ箱の自動削除",
trashRetentionHint: "設定した期間が過ぎると、ゴミ箱が自動的に空になります。",
days7: "7日後に自動削除",
days14: "14日後に自動削除",
days30: "30日後に自動削除(推奨)",
neverDelete: "自動削除しない",
// CalDAV & Sync
syncTitle: "Galaxy & 外部カレンダー連携 (CalDAV)",
syncDesc: "Samsung Galaxy リマインダー、Apple リマインダー、Thunderbird など CalDAV 対応アプリと双方向同期できます。",
syncBaseUrl: "CalDAV サーバー基本 URL",
syncCopyUrl: "URL をコピー",
syncCopied: "コピーしました!",
syncDownloadIcs: ".ICS フィードをダウンロード",
syncTestConnection: "接続テスト",
syncTesting: "テスト中...",
syncTestSuccess: "✓ CalDAV エンドポイントは正常に応答しています (200 OK)",
syncTabAndroid: "Galaxy / Android (DAVx⁵)",
syncTabApple: "Apple リマインダー (iOS / macOS)",
syncTabThunderbird: "Thunderbird (PC)",
syncAndroidStep1: "Google Play または F-Droid から「DAVx⁵」アプリをインストールします。",
syncAndroidStep2: "DAVx⁵ を起動 → アカウント追加 →「URL とユーザー名でログイン」を選択します。",
syncAndroidStep3: "上記の基本 URL を貼り付け、CheckFlow のメールアドレスとパスワードを入力します。",
syncAndroidStep4: "「VTODO (タスク)」を有効にすると、Samsung リマインダーや OpenTasks と連携されます。",
syncAppleStep1: "設定 → リマインダー → アカウント →「アカウントを追加」を開きます。",
syncAppleStep2: "「その他」を選択 →「CalDAV アカウントを追加」を選択します。",
syncAppleStep3: "サーバーに上記の URL、ユーザー名/パスワードに CheckFlow のアカウント情報を入力します。",
syncAppleStep4: "「リマインダー」を有効にして保存します。",
syncThunderbirdStep1: "Thunderbird を起動 → カレンダータブ → 新しいカレンダーを作成します。",
syncThunderbirdStep2: "「ネットワーク上」を選択 → 形式: CalDAV を選択します。",
syncThunderbirdStep3: "場所に上記の URL、ユーザー名に CheckFlow のメールアドレスを入力します。",
syncThunderbirdStep4: "ログイン画面でパスワードを入力して完了します。",
// Task List
tasks: "タスク",
hideDone: "完了を非表示",
showDone: "完了を表示",
completedSection: "完了済み",
addTaskPlaceholder: "タスクを追加してみましょう...",
add: "追加",
noTasksYet: "まだタスクがありません。下から追加してみてください 😊",
selectListToStart: "左側からリストを選んでください",
loading: "読み込み中...",
today: "今日",
tomorrow: "明日",
// Task Detail
taskTitlePlaceholder: "タスク名を入力してください",
priority: "優先度",
priorityNone: "なし",
priorityLow: "低め",
priorityMedium: "普通",
priorityHigh: "高め",
dueDate: "期限",
clear: "クリア",
notes: "メモ",
notesPlaceholder: "ここに自由にメモしてください…\n\nMarkdownも使えます: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
subtasks: "サブタスク",
addSubtaskPlaceholder: "サブタスクを追加…",
deleteTaskConfirm: "このタスクを削除しますか?",
autoSaved: "自動保存しました",
saving: "保存中…",
created: "作成日",
edited: "更新日",
// Toolbars
bold: "太字",
italic: "斜体",
heading: "見出し",
bulletList: "箇条書き",
numberedList: "番号付きリスト",
checkbox: "チェックボックス",
code: "コードブロック",
// TickTick Mode Switcher & Quick Add
textMode: "テキストメモ",
subtaskMode: "チェックリスト",
switchToTextMode: "Markdownメモに切り替え",
switchToSubtaskMode: "チェックリストに切り替え",
moveToList: "別のリストに移動",
quickAdd: "クイック追加",
quickToday: "今日",
quickTomorrow: "明日",
quickNextWeek: "来週",
selectDate: "日付",
selectPriority: "優先度",
selectTag: "タグ",
copied: "コピーしました!",
accessRestricted: "アクセス制限",
adminRequired: "このページは管理者のみ閲覧できます。",
// Context Menu
editTask: "編集",
duplicateTask: "複製",
moveTask: "移動…",
deleteTask: "削除",
renameList: "名前を変更",
deleteList: "リストを削除",
taskUndoHint: "削除しました。元に戻せます。",
listUndoHint: "リストを削除しました。元に戻せます。",
// Labs, Kanban & Customization
labs: "実験室 🧪",
labsDesc: "まだ正式リリース前の機能です。好きなものを選んでオンにしてみてください!",
kanbanView: "カンバンボード",
listView: "リスト",
enableKanban: "カンバンボードビュー",
enableKanbanDesc: "タスクをボード形式で表示できます(未着手 / 進行中 / 完了)。",
swapBlocks: "順番を入れ替え",
resetDefaults: "デフォルトに戻す",
resetConfirm: "すべてのカスタム設定をデフォルトに戻しますか?",
todoCol: "未着手",
inProgressCol: "進行中",
doneCol: "完了",
},
};
type TranslationKeys = keyof typeof translations.en;
interface I18nContextType {
lang: Language;
setLang: (l: Language) => void;
t: (key: TranslationKeys) => string;
}
const I18nContext = createContext<I18nContextType>({
lang: "en",
setLang: () => {},
t: (k) => k,
});
const LANG_STORAGE_KEY = "checkflow_lang";
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Language>("en");
useEffect(() => {
try {
const saved = localStorage.getItem(LANG_STORAGE_KEY) as Language | null;
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
setLangState(saved);
}
} catch {
// ignore
}
}, []);
const setLang = (l: Language) => {
setLangState(l);
try {
localStorage.setItem(LANG_STORAGE_KEY, l);
} catch {
// ignore
}
};
const t = (key: TranslationKeys): string => {
const dict = (translations[lang] || translations.en) as Record<string, string>;
return dict[key] || translations.en[key] || key;
};
return <I18nContext.Provider value={{ lang, setLang, t }}>{children}</I18nContext.Provider>;
}
export const useI18n = () => useContext(I18nContext);
+363
View File
@@ -0,0 +1,363 @@
export interface MockTag {
id: string;
name: string;
color: string;
}
export interface MockList {
id: string;
name: string;
color: string;
icon: string;
_count?: { tasks: number };
}
export interface MockTask {
id: string;
listId: string;
parentId: string | null;
title: string;
note: string | null;
completed: boolean;
completedAt: string | null;
dueDate: string | null;
priority: number;
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
isDeleted?: boolean;
children: MockTask[];
tags: { tag: MockTag }[];
}
export interface UserSettings {
displayName: string;
email: string;
trashRetentionDays: number; // 7, 14, 30, 0 (0 means never auto-delete)
theme: "system" | "light" | "dark";
language: "en" | "ko" | "ja";
}
const STORAGE_KEY_LISTS = "checkflow_demo_lists";
const STORAGE_KEY_TASKS = "checkflow_demo_tasks";
const STORAGE_KEY_SETTINGS = "checkflow_user_settings";
const STORAGE_KEY_TAGS = "checkflow_custom_tags";
export const DEFAULT_TAGS: MockTag[] = [
{ id: "tag-1", name: "Dev", color: "#4B7BF5" },
{ id: "tag-2", name: "NAS", color: "#10B981" },
{ id: "tag-3", name: "Important", color: "#EF4444" },
{ id: "tag-4", name: "Routine", color: "#F59E0B" },
];
export const DEFAULT_SETTINGS: UserSettings = {
displayName: "Demo Explorer",
email: "demo@checkflow.local",
trashRetentionDays: 30,
theme: "system",
language: "en",
};
export const INITIAL_DEMO_LISTS: MockList[] = [
{ id: "list-1", name: "🚀 Project Launch", color: "#4B7BF5", icon: "work" },
{ id: "list-2", name: "🏠 Personal & Daily", color: "#10B981", icon: "personal" },
{ id: "list-3", name: "📚 Tech Research", color: "#8B5CF6", icon: "study" },
];
export const INITIAL_DEMO_TASKS: MockTask[] = [
{
id: "task-1",
listId: "list-1",
parentId: null,
title: "Setup Self-Hosted CheckFlow on NAS",
note: "## 🐳 Docker Deployment Guide\n\n- Deploy with `docker compose up -d`\n- Forward port 3000 to NPM (Nginx Proxy Manager)\n- Setup SSL certificate for custom domain\n\n### 🔗 Key Endpoints\n- Web App: https://todo.yourdomain.com\n- CardDAV: https://todo.yourdomain.com/api/dav",
completed: false,
completedAt: null,
dueDate: new Date(Date.now() + 86400000).toISOString(),
priority: 3,
sortOrder: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
tags: [{ tag: DEFAULT_TAGS[1] }, { tag: DEFAULT_TAGS[2] }],
children: [
{
id: "sub-1-1",
listId: "list-1",
parentId: "task-1",
title: "Configure .env environment variables",
note: "Database URL, NextAuth secret, and port settings",
completed: false,
completedAt: null,
dueDate: null,
priority: 0,
sortOrder: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
tags: [],
children: [
{
id: "sub-1-1-1",
listId: "list-1",
parentId: "sub-1-1",
title: "Database connection string verification",
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 2,
sortOrder: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: [{ tag: DEFAULT_TAGS[0] }],
},
],
},
{
id: "sub-1-2",
listId: "list-1",
parentId: "task-1",
title: "Test DAVx⁵ sync on Samsung Galaxy",
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 2,
sortOrder: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: [],
},
{
id: "sub-1-3",
listId: "list-1",
parentId: "task-1",
title: "Verify real-time sync with main list",
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 1,
sortOrder: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: [],
},
],
},
{
id: "task-2",
listId: "list-1",
parentId: null,
title: "Import existing tasks from TickTick",
note: "1. Export CSV/ICS in TickTick Settings\n2. Click user profile at bottom-left\n3. Select 'Import Tasks'",
completed: false,
completedAt: null,
dueDate: null,
priority: 2,
sortOrder: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: [{ tag: DEFAULT_TAGS[3] }],
},
];
export function getDemoStore() {
if (typeof window === "undefined") {
return { lists: INITIAL_DEMO_LISTS, tasks: INITIAL_DEMO_TASKS };
}
let lists: MockList[] = INITIAL_DEMO_LISTS;
let tasks: MockTask[] = INITIAL_DEMO_TASKS;
try {
const savedLists = localStorage.getItem(STORAGE_KEY_LISTS);
if (savedLists) lists = JSON.parse(savedLists);
else localStorage.setItem(STORAGE_KEY_LISTS, JSON.stringify(INITIAL_DEMO_LISTS));
const savedTasks = localStorage.getItem(STORAGE_KEY_TASKS);
if (savedTasks) tasks = JSON.parse(savedTasks);
else localStorage.setItem(STORAGE_KEY_TASKS, JSON.stringify(INITIAL_DEMO_TASKS));
} catch (e) {
console.error("Failed to read demo storage", e);
}
return { lists, tasks };
}
export function saveDemoStore(lists: MockList[], tasks: MockTask[]) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY_LISTS, JSON.stringify(lists));
localStorage.setItem(STORAGE_KEY_TASKS, JSON.stringify(tasks));
} catch (e) {
console.error("Failed to save demo storage", e);
}
}
// User settings store
export function getUserSettings(): UserSettings {
if (typeof window === "undefined") return DEFAULT_SETTINGS;
try {
const s = localStorage.getItem(STORAGE_KEY_SETTINGS);
if (s) return { ...DEFAULT_SETTINGS, ...JSON.parse(s) };
} catch {}
return DEFAULT_SETTINGS;
}
export function saveUserSettings(settings: UserSettings) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY_SETTINGS, JSON.stringify(settings));
} catch {}
}
// Custom tags store
export function getCustomTags(): MockTag[] {
if (typeof window === "undefined") return DEFAULT_TAGS;
try {
const t = localStorage.getItem(STORAGE_KEY_TAGS);
if (t) return JSON.parse(t);
} catch {}
return DEFAULT_TAGS;
}
export function saveCustomTags(tags: MockTag[]) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY_TAGS, JSON.stringify(tags));
} catch {}
}
// Recursive tree helpers for N-depth sub-tasks
export function updateTaskInTree(tree: MockTask[], updated: MockTask): MockTask[] {
return tree.map((node) => {
if (node.id === updated.id) {
return { ...updated, children: updated.children || node.children || [] };
}
if (node.children && node.children.length > 0) {
return { ...node, children: updateTaskInTree(node.children, updated) };
}
return node;
});
}
// Soft delete to Trash
export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] {
return tree.map((node) => {
if (node.id === id) {
return { ...node, isDeleted: true, deletedAt: new Date().toISOString() };
}
if (node.children && node.children.length > 0) {
return { ...node, children: moveToTrashInTree(node.children, id) };
}
return node;
});
}
// Restore from Trash
export function restoreTaskInTree(tree: MockTask[], id: string): MockTask[] {
return tree.map((node) => {
if (node.id === id) {
return { ...node, isDeleted: false, deletedAt: null };
}
if (node.children && node.children.length > 0) {
return { ...node, children: restoreTaskInTree(node.children, id) };
}
return node;
});
}
// Permanent delete
export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask[] {
return tree
.filter((node) => node.id !== idToDelete)
.map((node) => {
if (node.children && node.children.length > 0) {
return { ...node, children: deleteTaskInTree(node.children, idToDelete) };
}
return node;
});
}
export function emptyTrashInTree(tree: MockTask[]): MockTask[] {
return tree
.filter((node) => !node.isDeleted)
.map((node) => {
if (node.children && node.children.length > 0) {
return { ...node, children: emptyTrashInTree(node.children) };
}
return node;
});
}
export function addTaskToTree(tree: MockTask[], parentId: string | null, newTask: MockTask): MockTask[] {
if (!parentId) {
return [...tree, newTask];
}
return tree.map((node) => {
if (node.id === parentId) {
const currentChildren = node.children || [];
return { ...node, children: [...currentChildren, newTask] };
}
if (node.children && node.children.length > 0) {
return { ...node, children: addTaskToTree(node.children, parentId, newTask) };
}
return node;
});
}
export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
for (const node of tree) {
if (node.id === id) return node;
if (node.children && node.children.length > 0) {
const found = findTaskInTree(node.children, id);
if (found) return found;
}
}
return null;
}
// Get all active tasks or all trash tasks flattened
export function getAllTrashTasks(tree: MockTask[]): MockTask[] {
let trash: MockTask[] = [];
for (const node of tree) {
if (node.isDeleted) {
trash.push(node);
}
if (node.children && node.children.length > 0) {
trash = trash.concat(getAllTrashTasks(node.children));
}
}
return trash;
}
// Filter tasks by custom tag
export function filterTasksByTag(tree: MockTask[], tagName: string): MockTask[] {
let matched: MockTask[] = [];
for (const node of tree) {
if (!node.isDeleted && node.tags?.some((t) => t.tag.name.toLowerCase() === tagName.toLowerCase())) {
matched.push(node);
}
if (node.children && node.children.length > 0) {
matched = matched.concat(filterTasksByTag(node.children, tagName));
}
}
return matched;
}
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+54
View File
@@ -0,0 +1,54 @@
/**
* useUserPrefs.ts
* React hook that subscribes to the global prefs store and provides
* live updates whenever any component calls savePrefs().
*/
"use client";
import { useState, useEffect, useCallback } from "react";
import {
UserPrefs,
loadPrefs,
savePrefs as persistPrefs,
resetPrefs as doReset,
prefsToCssVars,
} from "./userPrefs";
const EVENT = "checkflow:prefsChanged";
export function useUserPrefs() {
const [prefs, setPrefs] = useState<UserPrefs>(() => loadPrefs());
useEffect(() => {
const handler = (e: Event) => {
const next = (e as CustomEvent<UserPrefs>).detail;
setPrefs({ ...next });
};
window.addEventListener(EVENT, handler);
return () => window.removeEventListener(EVENT, handler);
}, []);
const updatePrefs = useCallback((patch: Partial<UserPrefs>) => {
const next = persistPrefs(patch);
setPrefs({ ...next });
}, []);
const resetToDefaults = useCallback(() => {
const next = doReset();
setPrefs({ ...next });
}, []);
return { prefs, updatePrefs, resetToDefaults };
}
/**
* PrefsStyleInjector
* Renders a <style> tag that applies CSS variable overrides
* derived from the current prefs. Place once in the root layout.
*/
export function usePrefsStyle(prefs: UserPrefs): string {
const vars = prefsToCssVars(prefs);
const body = Object.entries(vars)
.map(([k, v]) => ` ${k}: ${v};`)
.join("\n");
return `:root {\n${body}\n}`;
}
+151
View File
@@ -0,0 +1,151 @@
/**
* userPrefs.ts
* Global, reactive user-preference store persisted in localStorage.
* All components that call `loadPrefs()` get the same snapshot.
* Emit the `checkflow:prefsChanged` event to notify all listeners.
*/
export interface LabsFeatures {
/** Each flag independently enables/disables a Labs feature */
kanbanBoard: boolean; // List ↔ Kanban view switcher shown in header
// Add more feature flags here as Labs grows
}
export interface UserPrefs {
/** Layout */
sidebarWidth: number; // 160420px
detailWidth: number; // 300760px
density: "compact" | "default" | "comfortable";
detailBlockOrder: ("subtasks" | "note")[];
detailSplitRatio: number; // 1585 %
/** View (driven by Labs kanbanBoard flag) */
viewMode: "list" | "kanban";
/** Labs feature toggles */
labs: LabsFeatures;
/** Appearance */
fontSize: "small" | "default" | "large";
accentHue: number; // 0360 (HSL)
saturation: number; // 20100 (%)
roundness: "sharp" | "default" | "round";
animationSpeed: "none" | "fast" | "default" | "slow";
}
export const DEFAULT_PREFS: UserPrefs = {
sidebarWidth: 260,
detailWidth: 520,
density: "default",
detailBlockOrder: ["subtasks", "note"],
detailSplitRatio: 45,
viewMode: "list",
labs: {
kanbanBoard: false,
},
fontSize: "default",
accentHue: 220,
saturation: 75,
roundness: "default",
animationSpeed: "default",
};
const KEY = "checkflow_prefs_v2";
const EVENT = "checkflow:prefsChanged";
export function loadPrefs(): UserPrefs {
if (typeof window === "undefined") return { ...DEFAULT_PREFS };
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { ...DEFAULT_PREFS };
const parsed = JSON.parse(raw);
// Deep merge labs to ensure new feature flags get defaults
return {
...DEFAULT_PREFS,
...parsed,
labs: { ...DEFAULT_PREFS.labs, ...(parsed.labs || {}) },
};
} catch {
return { ...DEFAULT_PREFS };
}
}
export function savePrefs(patch: Partial<UserPrefs>): UserPrefs {
const current = loadPrefs();
const next: UserPrefs = {
...current,
...patch,
// Deep merge labs
labs: patch.labs ? { ...current.labs, ...patch.labs } : current.labs,
};
try {
localStorage.setItem(KEY, JSON.stringify(next));
window.dispatchEvent(new CustomEvent(EVENT, { detail: next }));
} catch { /* storage full */ }
return next;
}
export function resetPrefs(): UserPrefs {
try {
localStorage.removeItem(KEY);
// Also remove legacy keys from v0.5
localStorage.removeItem("checkflow_block_order");
localStorage.removeItem("checkflow_split_ratio");
localStorage.removeItem("checkflow_view_mode");
window.dispatchEvent(new CustomEvent(EVENT, { detail: { ...DEFAULT_PREFS } }));
} catch { /* ignore */ }
return { ...DEFAULT_PREFS };
}
/** Compute CSS variable overrides from a UserPrefs snapshot */
export function prefsToCssVars(p: UserPrefs): Record<string, string> {
const sat = Math.round(p.saturation * 0.9);
const accent = `hsl(${p.accentHue}, ${sat}%, 54%)`;
const accentHover = `hsl(${p.accentHue}, ${sat}%, 46%)`;
const accentLight = `hsla(${p.accentHue}, ${sat}%, 54%, 0.1)`;
const accentMedium = `hsla(${p.accentHue}, ${sat}%, 54%, 0.18)`;
const radMap = {
sharp: { xs: "2px", sm: "4px", md: "6px", lg: "8px", xl: "10px" },
default: { xs: "4px", sm: "8px", md: "12px", lg: "16px", xl: "20px" },
round: { xs: "8px", sm: "14px", md: "18px", lg: "24px", xl: "28px" },
};
const r = radMap[p.roundness];
const fontMap = { small: "12px", default: "14px", large: "15px" };
const durMap = {
none: { fast: "0ms", normal: "0ms", slow: "0ms" },
fast: { fast: "60ms", normal: "100ms", slow: "160ms" },
default: { fast: "120ms", normal: "200ms", slow: "300ms" },
slow: { fast: "220ms", normal: "360ms", slow: "500ms" },
};
const d = durMap[p.animationSpeed];
const densityMap = {
compact: { taskPad: "6px 20px", gap: "2px" },
default: { taskPad: "10px 20px", gap: "4px" },
comfortable: { taskPad: "14px 20px", gap: "8px" },
};
const dn = densityMap[p.density];
return {
"--accent": accent,
"--accent-hover": accentHover,
"--accent-light": accentLight,
"--accent-medium": accentMedium,
"--radius-xs": r.xs,
"--radius-sm": r.sm,
"--radius-md": r.md,
"--radius-lg": r.lg,
"--radius-xl": r.xl,
"--font-size-base": fontMap[p.fontSize],
"--dur-fast": d.fast,
"--dur-normal": d.normal,
"--dur-slow": d.slow,
"--task-item-padding": dn.taskPad,
"--task-item-gap": dn.gap,
"--sidebar-width": `${p.sidebarWidth}px`,
"--detail-width": `${p.detailWidth}px`,
};
}
+73
View File
@@ -0,0 +1,73 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Public paths:
// - /login, /register: Auth pages
// - /demo: Local preview without DB
// - /api/auth: NextAuth endpoints
// - /api/dav: DAVx⁵ uses HTTP Basic Auth
// - Static assets & metadata
const publicPrefixes = [
"/login",
"/register",
"/demo",
"/api/auth",
"/api/dav",
"/icons",
"/manifest.json",
"/sw.js",
"/favicon.ico",
];
const isPublic = publicPrefixes.some((p) => pathname.startsWith(p));
if (isPublic) return NextResponse.next();
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
// 비로그인 사용자의 보호된 라우트 접근 차단
if (!token) {
// API 라우트는 401 JSON 반환
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 페이지 라우트는 /login으로 리디렉션
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", encodeURIComponent(pathname));
return NextResponse.redirect(loginUrl);
}
// /admin 라우트의 경우 추가 보안 검증
if (pathname.startsWith("/admin")) {
const userRole = (token as { role?: string }).role;
const adminEmail = process.env.ADMIN_EMAIL;
const isEmailAdmin = adminEmail && token.email === adminEmail;
const isAdmin = userRole === "ADMIN" || isEmailAdmin || token.email?.endsWith("@checkflow.local");
if (!isAdmin) {
// 일반 사용자가 어드민에 접근하려 할 때 403 차단 혹은 메인으로 리디렉션
const forbiddenUrl = new URL("/?error=forbidden", request.url);
return NextResponse.redirect(forbiddenUrl);
}
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};
+24
View File
@@ -0,0 +1,24 @@
import "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface User {
id: string;
role?: string;
}
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
role?: string;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role?: string;
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long