feat: initial commit
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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*
|
||||||
@@ -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 -->
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# CheckFlow
|
||||||
|
|
||||||
|
> 셀프호스팅 TickTick-like Todo 앱 — 계층형 체크리스트 + 메모장 + CardDAV + PWA
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- ✅ **계층형 체크리스트** — 메인 태스크 + 하위 태스크
|
||||||
|
- 📝 **태스크 메모장** — 넓은 노트 영역 (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
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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:
|
||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
provider = "postgresql"
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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">✓</text></svg>
|
||||||
|
After Width: | Height: | Size: 247 B |
@@ -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">✓</text></svg>
|
||||||
|
After Width: | Height: | Size: 247 B |
@@ -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">✓</text></svg>
|
||||||
|
After Width: | Height: | Size: 247 B |
@@ -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"}
|
||||||
@@ -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 |
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 |
@@ -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 |
@@ -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.
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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; // 160–420px
|
||||||
|
detailWidth: number; // 300–760px
|
||||||
|
density: "compact" | "default" | "comfortable";
|
||||||
|
detailBlockOrder: ("subtasks" | "note")[];
|
||||||
|
detailSplitRatio: number; // 15–85 %
|
||||||
|
|
||||||
|
/** View (driven by Labs kanbanBoard flag) */
|
||||||
|
viewMode: "list" | "kanban";
|
||||||
|
|
||||||
|
/** Labs feature toggles */
|
||||||
|
labs: LabsFeatures;
|
||||||
|
|
||||||
|
/** Appearance */
|
||||||
|
fontSize: "small" | "default" | "large";
|
||||||
|
accentHue: number; // 0–360 (HSL)
|
||||||
|
saturation: number; // 20–100 (%)
|
||||||
|
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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||