Infrastructure Sync: Initial migration to custom self-hosted Gitea

This commit is contained in:
2026-08-21 15:40:26 +09:00
parent fa15956a90
commit d64b3c72b7
47 changed files with 1228 additions and 1460 deletions
+38
View File
@@ -0,0 +1,38 @@
# Project Current Status & Rules
- **Objective:** Maintain, review, and enhance the CheckFlow self-hosted TickTick-style task management web application according to specifications in `AGENTS.md`, ensuring full feature integrity, security compliance, clean code quality, and responsive UX across all devices.
- **Core Architecture & Source Structure:**
- `src/app/`: Next.js App Router root layouts, providers, auth/demo/admin views, and REST/CalDAV API endpoints (`/api/tasks`, `/api/lists`, `/api/import`, `/api/dav/[...path]`).
- `src/components/layout/`: `AppShell.tsx` (3-panel main layout, resizer coordination, mobile drawer overlay), `Sidebar.tsx` (draggable resizer, lists tree, tag & trash navigation, profile & import popover).
- `src/components/tasks/`:
- `TaskList.tsx`: Recursive n-depth hierarchical checklist, inline title editing, context menu, smart quick-add toolbar.
- `TaskDetail.tsx`: Right-side detail panel, modular block swap (Subtasks ⇄ Markdown Note), mouse drag split resizer, mobile bottom-sheet with swipe-down dismissal.
- `MarkdownNoteEditor.tsx`: Distraction-free text/markdown note canvas with DOMPurify sanitization & preview rendering.
- `KanbanView.tsx`: CheckFlow Labs 3-column Kanban board view (`To Do` / `In Progress` / `Done`).
- `src/components/settings/`: `SettingsModal.tsx` (Profile, Preferences, Labs, Integrations/DAVx⁵, Admin tabs).
- `src/lib/`: `userPrefs.ts` (global user customization state store), `useUserPrefs.ts` (reactive preferences hook & CSS variable injector), `i18n/` (EN/KO/JA localization dictionary), `mockData.ts` (LocalStorage demo store), `auth.ts`, `prisma.ts`.
- `src/middleware.ts`: Route-level auth and admin role verification guard.
- **Completed So Far:**
- Conducted full audit of codebase against all specification items in `AGENTS.md`.
- Verified 100% implementation of core features: n-depth recursive checklist, real-time subtask synchronization, modular blocks & split resizer, 3-way theme (VSCode dark / pastel light / system), global `UserPrefs` customization, Labs Kanban view, and mobile bottom sheet.
- Enhanced CalDAV / DAVx⁵ synchronization endpoint (`src/app/api/dav/[...path]/route.ts`) with full RFC 4791 support: `OPTIONS`, `PROPFIND` (principal & calendar collection discovery), `REPORT` (calendar-query/multiget), `PUT` (task upsert from VTODO), and `DELETE`.
- Upgraded Settings Modal "Integrations (CalDAV)" tab with multi-platform interactive guides (Android/DAVx⁵, Apple Reminders, Thunderbird), one-click URL copy feedback, ICS feed direct download, and live endpoint test ping.
- Implemented full-fidelity standard Task Export pair (`/api/export`) supporting RFC 4180 / TickTick-compatible CSV with UTF-8 BOM, and RFC 5545 iCalendar (`.ics` VTODO) format.
- Added Export modal in Sidebar user popover menu supporting format selection, target list filtering, completed tasks inclusion toggle, and seamless Demo mode client-side Blob generation.
- Added localized i18n strings for Export across English, Korean, and Japanese.
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
- **Next Steps (Todo):**
- Enhance keyboard accessibility & global shortcuts (shortcuts for switching list/kanban view, quick task navigation, Command Palette bindings).
- Add optional automatic synchronization background polling / webhook integration if requested.
- Plan next feature iterations or custom integrations as requested by user.
- **Caveats & Absolute Rules:**
- **Security & Multi-user Privacy**: All API endpoints must strictly verify session ownership (`userId === session.user.id`) and list ownership before creating/updating/deleting tasks or lists.
- **Sanitization**: Never render raw HTML without DOMPurify; always sanitize CSV/ICS user inputs against formula injection (`sanitizeFormula`).
- **Admin Gatekeeping**: Admin routes must strictly require `role === "ADMIN"` both on server/middleware and client.
- **User Customization Priority**: Any new UI preferences (sizes, positions, order) must be persisted through `userPrefs.ts` and respect the "Reset to Defaults" Labs option.
- **i18n Consistency**: Any newly added UI string must be translated across all 3 language dictionaries (`en`, `ko`, `ja`) in `src/lib/i18n/translations.ts` and `src/lib/i18n/index.tsx`.
- **Mobile Bottom-Sheet Convention**: Maintain touch gestures (`mobile-open` class, Y-axis swipe threshold > 120px) for the detail panel on mobile screens.
+5
View File
@@ -0,0 +1,5 @@
[CRITICAL INSTRUCTION]
You are operating under strict context window pressure, and older chat history may be truncated.
Therefore, before starting a new task or making critical decisions, always read the `.cline-context.md` file in the project root to verify the latest status and rules.
Whenever a sub-task is completed or significant structural changes occur, you MUST update the 'Next Steps (Todo)' and 'Project Current Status' sections in `.cline-context.md` to preserve your memory.
Keep the content of `.cline-context.md` clear, concise, and written in English.
+1
View File
@@ -16,3 +16,4 @@ out/
.DS_Store
*.pem
npm-debug.log*
chamgojaryo/
+70 -117
View File
@@ -10,18 +10,25 @@
사용자의 핵심 요구사항:
1. **TickTick 스타일 To-Do 리스트 독립 웹앱**:
- 체크리스트는 **메인 - 하위(Sub-task)** N단계 계층 구조를 갖추며, 양쪽 패널 간 1:1 실시간 동기화.
- **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 마크다운이 GUI로 렌더링됨.
- **인라인 즉시 수정 UX**: 프로젝트 제목 및 태스크 제목을 클릭으로 즉시 수정 가능.
- **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 작성한 마크다운이 실제 웹 문서처럼 미려한 GUI 형태로 렌더링(Preview & 안전한 새 탭 링크 지원).
- **인라인 즉시 수정 UX**: 상단 프로젝트 제목 및 태스크 제목을 더블클릭/클릭으로 즉시 수정 가능 (Enter 저장, ESC 취소).
2. **셀프호스팅 & 프라이버시 중심 멀티유저**:
- SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼.
- 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼.
- Docker 배포 지원 (PostgreSQL 16 포함, Dockerfile & docker-compose.yml 완비).
3. **외부 플랫폼 연동 & Import**:
- TickTick CSV/ICS Import 지원 (프로필 메뉴 내 배치).
- Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV (`/api/dav`) 동기화 지원.
4. **모바일 바텀시트 드로어**:
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 밀도, 폰트 크기, 모서리 라운드, 테마 색조/채도, 애니메이션 속도 커스터마이징 지원.
---
@@ -30,76 +37,95 @@
```
src/
├── app/
│ ├── demo/page.tsx ← DB 없이 LocalStorage 기반 데모 페이지
│ ├── admin/page.tsx ← 어드민 대시보드
│ ├── 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
│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너 (실시간 하위 태스크 & 인라인 동기화, 모바일 오버레이)
│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어 셀렉터, 테마 토글, 유저 프로필 팝오버(Import 내장)
│ ├── tasks/
│ │ ├── TaskList.tsx ← 체크리스트 (재귀 트리, 인라인 수정, 우클릭 메뉴)
│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (TickTick 스타일, 바텀시트 터치)
│ │ ── MarkdownNoteEditor.tsx ← Preview/Edit 탭, 툴바, URL 클릭 지원
│ │ ├── 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/Sync/Admin 4탭
│ │ └── SettingsModal.tsx ← Profile / Preferences / Labs / Sync / Admin 5탭 모달
│ └── ui/
│ ├── LanguageSelector.tsx ← 글래스모피즘 언어 팝오버
│ ├── ContextMenu.tsx ← 커스텀 우클릭 메뉴
│ └── CommandPalette.tsx ← Ctrl+K 글로벌 검색
│ ├── ContextMenu.tsx ← 커스텀 우클릭 컨텍스트 메뉴
│ └── CommandPalette.tsx ← Ctrl+K 글로벌 명령 팔레트
└── lib/
├── i18n/ (en, ko, ja + useI18n 훅)
── mockData.ts ← Demo LocalStorage Store (trash, tags, settings)
├── 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)
## 3. 롤백 포인트 (Git Tags & Checkpoints)
| Tag | 내용 | 날짜 |
| Tag / Checkpoint | 내용 | 일자 |
|---|---|---|
| `checkpoint-v0.1.0` | 기본 Full-Stack 기반 | 초기 |
| `checkpoint-v0.2.0` | i18n, 3-Way Theme, Demo Mode | - |
| `checkpoint-v0.2.1` | 리치 마크다운 렌더러, 적응형 메모장 | - |
| `checkpoint-v0.2.2` | 하위 태스크 실시간 동기화 & 인라인 수정 | - |
| `checkpoint-v0.3.0` | 사이드바 애니메이션, Import 이동, 커스텀 메뉴, TickTick 스타일 | - |
| `checkpoint-v0.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` | 모바일 바텀시트, 스와이프 닫기, 오버레이 | 2026-08-20 |
| `checkpoint-v0.5.0` | **(현재 최신)** VSCode 스타일 무채색 다크 테마, 에디터 조잡한 툴바 제거 & 순수 캔버스 전환, 모듈형 블록 커스텀 (서브태스크 ↔ 노트 순서 스왑 및 스플릿 리사이저 높이 조절), CheckFlow Labs (3컬럼 칸반 보드 뷰), 기본값 복원(Reset to Defaults), 보안 패치 완료 | 2026-08-21 |
| `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. 구현 완료 기능
## 4. 구현 완료 기능 요약
- [x] i18n 3개국어 (영어 기본, 한국어, 일본어 사전 100% 무결성)
- [x] **VSCode 스타일 무채색 다크 테마** (`#181818`, `#1e1e1e`, `#252526`, `#2d2d2d`)
- [x] **VSCode 스타일 무채색 다크 테마** (`#181818`, `#1e1e1e`, `#252526`, `#2d2d2d`) 및 파스텔 라이트 테마
- [x] **에디터 툴바 제거 & 심플 캔버스** (조잡한 `- [ ]` 툴바 제거, 순수한 텍스트/마크다운 에디팅)
- [x] **모듈형 상세 패널 (Custom Blocks)**:
- 서브태스크 ↔ 노트 블록 **위치 스왑(⇄ Swap)**
- 마우스 드래그로 높이 비율(15%~85%)을 실시간 조절하는 **스플릿 리사이저(Split Resizer)**
- 각 블록별 독립적인 접기/펼치기 및 100% 풀스크린 확장
- 각 블록별 독립적인 접기/펼치기
- [x] **🧪 CheckFlow Labs (실험실 기능)**:
- 📋 **리스트 뷰** ↔ 📊 **3컬럼 칸반 보드 뷰 (To Do / In Progress / Done)** 원클릭 전환
- 언제든 초기 순정 상태로 되돌리는 **기본값 복원 (Reset to Defaults)**
- [x] **글로벌 UserPrefs 커스터마이징 (v0.6.0)**:
- 사이드바 너비(160~420px) 및 디테일 패널 너비(300~760px) 드래그 리사이징
- UI 밀도 (Compact / Default / Comfortable)
- 폰트 크기, 라운드 스타일, 애니메이션 속도, 액센트 색조(Hue) & 채도(Saturation) 슬라이더
- [x] Demo 모드 (LocalStorage 기반 완전한 로컬 구동)
- [x] N단계 재귀 체크리스트 (RecursiveTaskItem)
- [x] N단계 재귀 체크리스트 (`RecursiveTaskItem`)
- [x] 인라인 제목 수정 (더블클릭/엔터/ESC)
- [x] 커스텀 우클릭 컨텍스트 메뉴 (전체 트리)
- [x] 커스텀 우클릭 컨텍스트 메뉴 (`ContextMenu.tsx`, 브라우저 기본 메뉴 비활성화)
- [x] Created / Edited 타임스탬프
- [x] 커스텀 태그 (색상 자동 부여)
- [x] 휴지통 (복원/영구삭제/보관 기간 설정)
- [x] 설정 모달 (Profile/Preferences/Labs/Sync/Admin)
- [x] 설정 모달 (Profile / Preferences / Labs / Sync / Admin 5탭)
- [x] 어드민 대시보드 (`/admin` - ADMIN 롤 및 권한 통제 게이트)
- [x] 정식 라우트 보호 미들웨어 (`src/middleware.ts` 인증 & 역할 검증)
- [x] API IDOR 및 소유권 교차 검증 (서브태스크 및 리스트 이동)
- [x] CSV / ICS Import Formula Injection 방어
- [x] Ctrl+K 글로벌 명령 팔레트
- [x] TickTick CSV/ICS 임포트
- [x] CalDAV 동기화 안내 (DAVx⁵)
- [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축 스와이프, 오버레이)
- [x] **모바일 바텀시트 드로어** (Y축 120px 스와이프 닫기, 백드롭 오버레이)
- [x] FAB 버튼 (모바일 태스크 추가)
- [x] PWA 매니페스트 및 서비스워커 지원 (`manifest.json`, `sw.js`)
---
@@ -110,14 +136,14 @@ src/
- 사용자 입력(마크다운, CSV/ICS 등)은 DOMPurify 및 Formula Sanitize를 반드시 통과할 것.
- 어드민 전용 라우트는 `role === "ADMIN"`을 서버/미들웨어 레벨에서 필터링할 것.
2. **사용자 주도 커스터마이징 & 공간 효율성**:
- 모든 블록과 뷰(리스트/칸반, 서브태스크/노트 스플릿 비율 등)는 사용자가 원하는 대로 직관적으로 조절 가능해야 함.
- 모든 블록과 뷰(리스트/칸반, 서브태스크/노트 스플릿 비율, 패널 너비 등)는 `userPrefs.ts`를 통해 사용자가 직관적으로 조절 가능해야 함.
- 불필요한 조잡한 툴바를 배제하고 심플하고 직관적인 조작성 유지.
3. **컨텍스트 메뉴**: `ContextMenu.tsx` 사용, 브라우저 기본 메뉴 비활성화.
4. **i18n 무결성**: 새 텍스트는 en/ko/ja 사전에 모두 추가.
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 방향 측면 패널.
- 데스크탑: `detail-panel`이 flex 방향 측면 패널로 동작.
6. **언어**: 사용자 응답 및 에이전트 간 소통은 한국어를 기본으로 할 것.
---
@@ -128,7 +154,7 @@ src/
|---|---|
| 초기 ~ Checkpoint v2.x | Claude Sonnet 4.6 |
| Checkpoint v2.x ~ v3.x | Gemini 2.5 Flash |
| Checkpoint v4.0 | Gemini (현재) |
| Checkpoint v4.0 ~ 현재 | Gemini / Cline |
---
@@ -142,79 +168,6 @@ src/
- **로컬 실행**: `npm run dev` (포트 3000)
- **Demo 페이지**: `http://localhost:3000/demo`
---
## 1. 프로젝트 개요 & 핵심 요구사항
사용자의 핵심 요구사항:
1. **TickTick 스타일 To-Do 리스트 독립 웹앱**:
- 체크리스트는 **메인 - 하위(Sub-task)** 2단계 계층 구조를 갖추며, 양쪽 패널 간 1:1 실시간 동기화.
- **적응형(Adaptive) 와이드 메모장**: 우측 패널의 대부분을 메모장이 차지하며, 작성한 마크다운이 실제 웹 문서처럼 미려한 GUI 형태로 렌더링(Preview & Interactive Checkbox) 지원.
- **인라인 즉시 수정 UX**: 상단 프로젝트 제목 및 태스크 제목을 더블클릭/클릭으로 즉시 수정 가능.
2. **셀프호스팅 & 프라이버시 중심 멀티유저**:
- SNS 형태가 아닌 개인별 프라이버시가 완벽히 보장되는 독립 멀티유저 플랫폼.
- Docker 배포 지원 (PostgreSQL 16 포함, Dockerfile & docker-compose.yml 완비).
3. **외부 플랫폼 연동 & Import**:
- TickTick 등 외부 플랫폼에서 내보내기한 CSV 및 ICS(iCalendar) 파일 Import 지원 (하단 프로필 메뉴 내 배치).
- Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV/CardDAV (`/api/dav`) 동기화 지원.
4. **PWA (Progressive Web App)**:
- 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원.
5. **디자인 시스템 & 반응형 웹앱 UX**:
- TickTick / OneUI / Vercel 디자인 언어.
- **네이티브 웹앱 느낌의 커스텀 우클릭 컨텍스트 메뉴(ContextMenu)** 지원.
- 부드러운 사이드바 애니메이션 및 화면 테마/언어 컨트롤 잘림 방지.
---
## 2. 주요 컴포넌트 구조
```
src/
├── app/
│ ├── layout.tsx / page.tsx / providers.tsx
│ ├── demo/page.tsx ← DB 연결 없이 LocalStorage 기반으로 구동되는 완전한 데모 페이지
│ ├── login/page.tsx / register/page.tsx
│ └── api/ (auth, lists, tasks, import, dav)
├── components/
│ ├── layout/
│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너 (실시간 하위 태스크 & 인라인 동기화)
│ │ └── Sidebar.tsx ← 프로젝트 목록, 언어 셀렉터, 테마 토글, 유저 프로필 팝오버(Import 내장)
│ ├── tasks/
│ │ ├── TaskList.tsx ← 중앙 체크리스트 (인라인 제목 수정, 하위 태스크 추가, 우클릭 메뉴)
│ │ ├── TaskDetail.tsx ← 우측 상세 패널 (TickTick 스타일 메타바 + 와이드 메모장 + 하단 하위할일)
│ │ └── MarkdownNoteEditor.tsx ← 👁️ Preview / ✏️ Edit 탭, 마크다운 툴바, 인터랙티브 체크박스
│ └── ui/
│ ├── LanguageSelector.tsx ← OneUI/Vercel 스타일 커스텀 글래스모피즘 언어 팝오버
│ └── ContextMenu.tsx ← 네이티브 웹앱 커스텀 우클릭 컨텍스트 메뉴
└── lib/
├── i18n/ (en, ko, ja 사전 및 useI18n 훅)
├── mockData.ts ← Demo 모드용 LocalStorage Store
├── userPrefs.ts ← 글로벌 사용자 설정 스토어 (v0.6+)
├── useUserPrefs.ts ← 반응형 prefs 훅 + CSS var 인젝터
├── auth.ts / prisma.ts
```
---
## 3. 롤백 포인트 (Checkpoints)
- **`checkpoint-v1.0`**: 기본 Full-Stack 기반 시점
- **`checkpoint-v2.0`**: i18n(EN/KO/JA), 3-Way Theme(System/Light/Dark), Demo Mode 탑재
- **`checkpoint-v2.1`**: 리치 마크다운 GUI 렌더러(MarkdownNoteEditor), 적응형 와이드 메모장
- **`checkpoint-v2.2`**: 하위 태스크 실시간 동기화 & 인라인 즉시 수정 UX
- **`checkpoint-v3.0`**: 부드러운 사이드바 애니메이션, 테마 버튼 잘림 완벽 해결, Import 기능 프로필 메뉴 이동, 커스텀 웹앱 우클릭 컨텍스트 메뉴, TickTick 스타일 우측 상세 탭 완비
- **`checkpoint-v0.5.0`**: 버전 체계 0.x.x 전환, VSCode 무채색 다크 테마, 미니멀 에디터(툴바 제거), 모듈형 블록 스왑/리사이저, 칸반 보드 Labs, 설정 Labs 탭 신설
- **`checkpoint-v0.6.0` (현재 최신)**: 글로벌 `UserPrefs` 시스템(`userPrefs.ts` / `useUserPrefs.ts`) 도입, 사이드바 드래그 리사이저, 디테일 패널 드래그 리사이저, Labs 탭 확장(밀도/폰트/라운드/애니메이션/색조/채도/패널 너비), 뷰 전환(리스트↔칸반) 페이지 리로드 없이 반응, 파스텔 매트 라이트 팔레트 적용
---
## 4. 개발 규칙 및 주의사항
1. **메모장 우선 원칙**: 우측 상세 탭에서 메모장은 항상 메인 영역(`flex: 1`)을 차지하도록 유지할 것.
2. **웹앱 인터랙션**: 브라우저 기본 컨텍스트 메뉴 대신 `ContextMenu.tsx`를 통해 앱스러운 UX를 유지할 것.
3. **i18n 무결성**: 텍스트 추가 시 `src/lib/i18n`의 en, ko, ja 사전에 모두 추가할 것.
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
<!DOCTYPE html>
<!-- saved from url=(0785)https://js.stripe.com/v3/controller-with-preconnect-5db5e83329f7b3486c3557ee2790d9cb.html#__shared_params__[version]=v3&__shared_params__[light_experiment_assignments]=%7B%22token%22%3A%22feb38f29-c9a3-42da-acfd-c19172cfb719%22%2C%22assignments%22%3A%7B%22api_fastpath_habanero_v1%22%3A%22control%22%7D%7D&apiKey=pk_live_J95N9bhJGECTDWXInMXdcspN&stripeJsId=feb38f29-c9a3-42da-acfd-c19172cfb719&stripeObjId=sobj-f89fa765-6e66-47f7-83fe-a378314d43b5&firstStripeInstanceCreatedLatency=25&controllerCount=1&isCheckout=false&stripeJsLoadTime=1787194929312.7&manualBrowserDeprecationRollout=false&mids[guid]=NA&mids[muid]=NA&mids[sid]=NA&referrer=https%3A%2F%2Fticktick.com%2Fwebapp%23p%2F6a6c58fb8f087e90bd8da7a4%2Ftasks%2F6a6c625491d8cd0680e74606&controllerId=__privateStripeController7581 -->
<html lang="ko"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><link rel="preconnect" href="https://api.stripe.com/" crossorigin=""><link rel="preconnect" href="https://merchant-ui-api.stripe.com/" crossorigin=""><meta http-equiv="origin-trial" content="AtD0WrnMwAPI4nWWCvreE+vpgPVz45SO/1fG1IZRNpBsdWZOZN6SKr0ynC11KuzrvT903WrEU+N9Ik/RpiCRTAEAAABbeyJvcmlnaW4iOiJodHRwczovL3N0cmlwZS5jb206NDQzIiwiZmVhdHVyZSI6IlRwY2QiLCJleHBpcnkiOjE3MzUzNDM5OTksImlzU3ViZG9tYWluIjp0cnVlfQ=="><script defer="defer" src="./shared-7577eac54e88cecc1d82b6c392bfe2cf.js.다운로드"></script><script defer="defer" src="./controller-with-preconnect-f5313d6dc0cd5231b1bbefb30935e946.js.다운로드"></script></head><body></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
<!DOCTYPE html>
<!-- saved from url=(0295)https://m.stripe.network/inner.html#url=https%3A%2F%2Fticktick.com%2Fwebapp%23p%2F6a6c58fb8f087e90bd8da7a4%2Ftasks%2F6a6c625491d8cd0680e74606&title=%EC%9B%8C%ED%99%80%20-%20TickTick&referrer=https%3A%2F%2Fticktick.com%2Fwebapp&muid=NA&sid=NA&version=6&preview=false&__shared_params__[version]=v3 -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title>StripeM-Inner</title></head><body><script>!function(){var e=document.createElement("script");e.defer=!0,e.src="out-4.5.45.js",e.onload=function(){var e;window.StripeM&&(e=window.location.hash,/ping=false/.test(e)||(e=(e=e.match(/version=(4|6)/))?e[1]:"4",window.StripeM.p({t:!0,v:e})),e=function(e){if(window.opener||window.parent||window)try{var i=((t=JSON.parse(e.data)).message||t).action,t=t.message?t.message.payload:t;switch(i){case"ping":window.StripeM.p({t:!0,o:{muid:t.muid,sid:t.sid,referrer:t.referrer,url:t.url,title:t.title,v2:t.v2},v:t.version||"4"});break;case"track":if(!t.source||!t.data)return;window.StripeM.b({muid:t.muid,sid:t.sid,url:t.url,source:t.source,data:t.data},t.version||"4")}}catch(e){}},window.addEventListener?window.addEventListener("message",e,!1):window.attachEvent("onMessage",e))},document.body.appendChild(e)}()</script><script defer="" src="./out-4.5.45.js.다운로드"></script></body></html>
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
!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)}();
@@ -1,3 +0,0 @@
<!DOCTYPE html>
<!-- saved from url=(0330)https://js.stripe.com/v3/m-outer-3437aaddcdf6922d623e172c2d6f9278.html#url=https%3A%2F%2Fticktick.com%2Fwebapp%23p%2F6a6c58fb8f087e90bd8da7a4%2Ftasks%2F6a6c625491d8cd0680e74606&title=%EC%9B%8C%ED%99%80%20-%20TickTick&referrer=https%3A%2F%2Fticktick.com%2Fwebapp&muid=NA&sid=NA&version=6&preview=false&__shared_params__[version]=v3 -->
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><script defer="defer" src="./m-outer-15a2b40a058ddff1cffdb63779fe3de1.js.다운로드"></script></head><body><iframe src="./inner.html"></iframe></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
@@ -5,6 +5,18 @@ 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:
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
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.
+5
View File
@@ -0,0 +1,5 @@
[CRITICAL INSTRUCTION]
You are operating under strict context window pressure, and older chat history may be truncated.
Therefore, before starting a new task or making critical decisions, always read the `.cline-context.md` file in the project root to verify the latest status and rules.
Whenever a sub-task is completed or significant structural changes occur, you MUST update the 'Next Steps (Todo)' and 'Project Current Status' sections in `.cline-context.md` to preserve your memory.
Keep the content of `.cline-context.md` clear, concise, and written in English.
+14 -10
View File
@@ -3,7 +3,6 @@ import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useI18n } from "@/lib/i18n";
import { getDemoStore } from "@/lib/mockData";
interface AdminUser {
@@ -47,14 +46,25 @@ const INITIAL_ADMIN_USERS: AdminUser[] = [
];
export default function AdminPage() {
const { t } = useI18n();
const router = useRouter();
const { data: session, status } = useSession();
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
const [search, setSearch] = useState("");
const [totalTasks, setTotalTasks] = useState(67);
const [totalLists, setTotalLists] = useState(9);
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;
@@ -68,12 +78,6 @@ export default function AdminPage() {
}
}, [status, router]);
useEffect(() => {
const store = getDemoStore();
if (store.tasks) setTotalTasks(store.tasks.length);
if (store.lists) setTotalLists(store.lists.length);
}, []);
// 로딩 중 스피너
if (status === "loading" || status === "unauthenticated") {
return (
+331 -11
View File
@@ -2,21 +2,36 @@ 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 [email, password] = decoded.split(":");
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 }) {
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 };
let vtodo = [
const vtodo = [
"BEGIN:VTODO",
`UID:${task.id}@checkflow`,
`DTSTAMP:${now}`,
@@ -33,13 +48,82 @@ function taskToVTodo(task: { id: string; title: string; note: string | null; com
return vtodo.join("\r\n");
}
// Handle all CalDAV/CardDAV requests
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"' },
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
@@ -61,17 +145,253 @@ export async function GET(req: NextRequest) {
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": "attachment; filename=checkflow.ics",
"Content-Disposition": 'attachment; filename="checkflow-tasks.ics"',
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
// OPTIONS for CORS
export async function OPTIONS() {
return new NextResponse(null, {
// 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: {
Allow: "GET, OPTIONS",
"DAV": "1, 2, calendar-access",
"Content-Type": "application/xml; charset=utf-8",
DAV: "1, 2, 3, calendar-access",
},
});
}
// REPORT: Query calendar items
export async function REPORT(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
const host = req.headers.get("host") || "localhost:3000";
const protocol = req.headers.get("x-forwarded-proto") || "http";
const tasksUrl = `${protocol}://${host}/api/dav/calendars/${encodeURIComponent(user.email)}/tasks/`;
const tasks = await prisma.task.findMany({
where: { userId: user.id, parentId: null },
orderBy: { sortOrder: "asc" },
});
const responsesXml = tasks
.map((task) => {
const vcal = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//CheckFlow//EN",
taskToVTodo(task),
"END:VCALENDAR",
].join("\r\n");
return ` <D:response>
<D:href>${tasksUrl}${task.id}.ics</D:href>
<D:propstat>
<D:prop>
<D:getetag>"${new Date(task.updatedAt).getTime()}"</D:getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav"><![CDATA[${vcal}]]></C:calendar-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`;
})
.join("\n");
const xmlResponse = `<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
${responsesXml}
</D:multistatus>`;
return new NextResponse(xmlResponse, {
status: 207,
headers: {
"Content-Type": "application/xml; charset=utf-8",
DAV: "1, 2, 3, calendar-access",
},
});
}
// PUT: Create or update task via CalDAV sync
export async function PUT(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
const body = await req.text();
const parsed = parseVTodo(body);
if (!parsed.title && !parsed.uid) {
return new NextResponse("Bad Request: Missing task data", { status: 400 });
}
// Find existing task by UID or create new one in user's default/first list
let task = null;
if (parsed.uid) {
task = await prisma.task.findFirst({
where: { id: parsed.uid, userId: user.id },
});
}
if (task) {
// Update existing task
task = await prisma.task.update({
where: { id: task.id },
data: {
...(parsed.title !== undefined && { title: parsed.title }),
...(parsed.note !== undefined && { note: parsed.note }),
...(parsed.completed !== undefined && {
completed: parsed.completed,
completedAt: parsed.completed ? new Date() : null,
}),
...(parsed.dueDate !== undefined && { dueDate: parsed.dueDate }),
...(parsed.priority !== undefined && { priority: parsed.priority }),
},
});
return new NextResponse(null, {
status: 204,
headers: {
ETag: `"${new Date(task.updatedAt).getTime()}"`,
},
});
} else {
// Create new task in the user's primary list
let list = await prisma.list.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "asc" },
});
if (!list) {
list = await prisma.list.create({
data: {
name: "Inbox",
userId: user.id,
},
});
}
const newTask = await prisma.task.create({
data: {
...(parsed.uid && { id: parsed.uid }),
title: parsed.title || "Untitled Task",
note: parsed.note || null,
completed: parsed.completed || false,
completedAt: parsed.completed ? new Date() : null,
dueDate: parsed.dueDate || null,
priority: parsed.priority ?? 0,
listId: list.id,
userId: user.id,
},
});
return new NextResponse(null, {
status: 201,
headers: {
ETag: `"${new Date(newTask.updatedAt).getTime()}"`,
},
});
}
}
// DELETE: Remove task via CalDAV sync
export async function DELETE(req: NextRequest) {
const user = await authenticate(req);
if (!user) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="CheckFlow CalDAV"' },
});
}
// Extract ID from path URL
const pathname = req.nextUrl.pathname;
const match = pathname.match(/\/([a-zA-Z0-9_-]+)\.ics$/);
const taskId = match ? match[1] : null;
if (!taskId) {
return new NextResponse("Not Found", { status: 404 });
}
const task = await prisma.task.findFirst({
where: { id: taskId, userId: user.id },
});
if (!task) {
return new NextResponse("Not Found", { status: 404 });
}
await prisma.task.delete({
where: { id: task.id },
});
return new NextResponse(null, { status: 204 });
}
+176
View File
@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
function escapeCsvField(field: string | null | undefined): string {
if (field === null || field === undefined) return '""';
const str = String(field);
// Sanitize formula injection
const sanitized = /^[=+\-@\t\r]/.test(str.trim()) ? `'${str.trim()}` : str;
return `"${sanitized.replace(/"/g, '""')}"`;
}
function taskToVTodo(
task: {
id: string;
title: string;
note: string | null;
completed: boolean;
completedAt: Date | null;
dueDate: Date | null;
priority: number;
createdAt: Date;
updatedAt: Date;
list?: { name: string } | null;
}
) {
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
const vtodo = [
"BEGIN:VTODO",
`UID:${task.id}@checkflow`,
`DTSTAMP:${now}`,
`CREATED:${task.createdAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
`LAST-MODIFIED:${task.updatedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`,
`SUMMARY:${task.title.replace(/\n/g, "\\n")}`,
`STATUS:${task.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
`PRIORITY:${priorityMap[task.priority] ?? 0}`,
];
if (task.list?.name) {
vtodo.push(`CATEGORIES:${task.list.name.replace(/\n/g, "\\n")}`);
}
if (task.note) {
vtodo.push(`DESCRIPTION:${task.note.replace(/\n/g, "\\n")}`);
}
if (task.dueDate) {
vtodo.push(`DUE:${task.dueDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
}
if (task.completedAt) {
vtodo.push(`COMPLETED:${task.completedAt.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
}
vtodo.push("END:VTODO");
return vtodo.join("\r\n");
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = req.nextUrl;
const format = (searchParams.get("format") || "csv").toLowerCase();
const listId = searchParams.get("listId");
const includeCompleted = searchParams.get("includeCompleted") !== "false";
const whereClause: {
userId: string;
parentId: null;
listId?: string;
completed?: boolean;
} = {
userId: session.user.id,
parentId: null,
};
if (listId && listId !== "all") {
whereClause.listId = listId;
}
if (!includeCompleted) {
whereClause.completed = false;
}
const tasks = await prisma.task.findMany({
where: whereClause,
include: {
list: {
select: { name: true },
},
},
orderBy: [
{ listId: "asc" },
{ sortOrder: "asc" },
{ createdAt: "desc" },
],
});
const timestamp = new Date().toISOString().split("T")[0];
if (format === "ics") {
const icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//CheckFlow Tasks Export//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
...tasks.map(taskToVTodo),
"END:VCALENDAR",
].join("\r\n");
return new NextResponse(icsContent, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="checkflow-export-${timestamp}.ics"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
// Standard TickTick & RFC 4180 Compatible CSV
const priorityLabels: Record<number, string> = { 0: "None", 1: "Low", 2: "Medium", 3: "High" };
const headers = [
"Folder Name",
"List Name",
"Title",
"Tags",
"Content",
"Is Check list",
"Start Date",
"Due Date",
"Reminder",
"Repeat",
"Priority",
"Status",
"Created Time",
"Completed Time",
"Order",
"Timezone",
];
const csvRows = [headers.map((h) => `"${h}"`).join(",")];
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i];
const row = [
escapeCsvField(""), // Folder Name
escapeCsvField(t.list?.name || "Inbox"), // List Name
escapeCsvField(t.title), // Title
escapeCsvField(""), // Tags
escapeCsvField(t.note || ""), // Content / Note
escapeCsvField("0"), // Is Check list
escapeCsvField(""), // Start Date
escapeCsvField(t.dueDate ? t.dueDate.toISOString() : ""), // Due Date
escapeCsvField(""), // Reminder
escapeCsvField(""), // Repeat
escapeCsvField(priorityLabels[t.priority] || "None"), // Priority
escapeCsvField(t.completed ? "Completed" : "Normal"), // Status
escapeCsvField(t.createdAt.toISOString()), // Created Time
escapeCsvField(t.completedAt ? t.completedAt.toISOString() : ""), // Completed Time
escapeCsvField(String(t.sortOrder ?? i)), // Order
escapeCsvField(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"), // Timezone
];
csvRows.push(row.join(","));
}
const csvContent = "\uFEFF" + csvRows.join("\r\n"); // UTF-8 BOM for Excel compatibility
return new NextResponse(csvContent, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="checkflow-export-${timestamp}.csv"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
+8 -9
View File
@@ -1,7 +1,14 @@
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.",
@@ -22,15 +29,7 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<html lang="ko" className={inter.className} suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
+1 -1
View File
@@ -9,7 +9,7 @@ import { LanguageSelector } from "@/components/ui/LanguageSelector";
export default function LoginPage() {
const router = useRouter();
const { t, lang, setLang } = useI18n();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
const [email, setEmail] = useState("");
+19 -6
View File
@@ -22,8 +22,23 @@ const ThemeContext = createContext<ThemeContextType>({
export const useTheme = () => useContext(ThemeContext);
function ThemeManager({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>("system");
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
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";
@@ -38,10 +53,8 @@ function ThemeManager({ children }: { children: React.ReactNode }) {
};
useEffect(() => {
const saved = localStorage.getItem("checkflow_theme") as ThemeMode | null;
const initial = saved || "system";
setThemeState(initial);
applyTheme(initial);
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)");
+1 -1
View File
@@ -9,7 +9,7 @@ import { LanguageSelector } from "@/components/ui/LanguageSelector";
export default function RegisterPage() {
const router = useRouter();
const { t, lang, setLang } = useI18n();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
const [name, setName] = useState("");
+22 -14
View File
@@ -28,12 +28,30 @@ interface AppShellProps {
}
export function AppShell({ user, isDemo = false }: AppShellProps) {
const [lists, setLists] = useState<List[]>([]);
const [selectedListId, setSelectedListId] = useState<string | null>(null);
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[]>([]);
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);
@@ -98,21 +116,11 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
return () => document.removeEventListener("checkflow:openCommandPalette", handler);
}, []);
// Demo store loading
useEffect(() => {
if (isDemo) {
const store = getDemoStore();
setLists(store.lists);
if (store.lists.length > 0 && !selectedListId && !isTrashActive && !selectedTag) {
setSelectedListId(store.lists[0].id);
}
}
}, [isDemo, isTrashActive, selectedTag]);
// 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) {
+236 -5
View File
@@ -1,5 +1,6 @@
"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";
@@ -48,11 +49,12 @@ export function Sidebar({
isTrashActive,
onTrashSelect,
mobileOpen,
onClose,
onClose: _onClose,
isDemo = false,
onDemoCreateList,
onDemoDeleteList,
}: SidebarProps) {
const router = useRouter();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
@@ -61,12 +63,23 @@ export function Sidebar({
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 [tags, setTags] = useState<MockTag[]>([]);
const [trashCount, setTrashCount] = useState(0);
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);
@@ -105,7 +118,7 @@ export function Sidebar({
})
.catch((err) => console.error("Failed to load lists", err));
}
}, [isDemo]);
}, [isDemo, onListSelect, selectedListId, setLists]);
useEffect(() => {
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
@@ -216,6 +229,139 @@ export function Sidebar({
}
};
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;
@@ -578,10 +724,25 @@ export function Sidebar({
{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={() => { window.location.href = "/login"; }}>
<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>
@@ -651,6 +812,76 @@ export function Sidebar({
</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
+162 -23
View File
@@ -125,17 +125,20 @@ function PrefSlider({
);
}
export function SettingsModal({ isOpen, onClose, user, isDemo = false }: SettingsModalProps) {
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 || "Demo User");
const [email, setEmail] = useState(user.email || "demo@checkflow.local");
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(30);
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) {
@@ -146,6 +149,23 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
}
}, [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 = () => {
@@ -267,7 +287,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
</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 any)}>
<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>
@@ -428,7 +448,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
min={0}
max={360}
value={prefs.accentHue}
onChange={(e) => updatePrefs({ accentHue: parseInt(e.target.value) })}
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 }} />
@@ -490,30 +510,149 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
</div>
)}
{/* Tab: CalDAV / CardDAV Integrations */}
{/* Tab: CalDAV / External Sync */}
{activeTab === "sync" && (
<div className="settings-tab-content">
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>📱 Galaxy & Mobile Sync via DAVx</h3>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 12 }}>
CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV/CardDAV protocols.
<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>
<div className="form-group">
<label className="form-label">CalDAV Base Endpoint</label>
<div style={{ display: "flex", gap: 8 }}>
<input className="form-input" readOnly value={calDavUrl} style={{ fontFamily: "monospace", fontSize: 12 }} />
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { navigator.clipboard.writeText(calDavUrl); }}>
📋 Copy
{/* 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>
<div style={{ background: "var(--bg-secondary)", padding: 12, borderRadius: "var(--radius-sm)", fontSize: 12, color: "var(--text-secondary)" }}>
<strong>Setup Instructions for DAVx (Android):</strong>
<ol style={{ paddingLeft: 18, marginTop: 6, lineHeight: 1.6 }}>
<li>Install <strong>DAVx</strong> on your Samsung Galaxy from Google Play or F-Droid.</li>
<li>Add account <strong>Login with URL and user name</strong>.</li>
<li>Base URL: <code>{calDavUrl}</code></li>
<li>User / Password: Your CheckFlow email & password.</li>
{/* 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>
)}
+6 -8
View File
@@ -40,7 +40,7 @@ export function TaskDetail({
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[]>([]);
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
const [showTagPicker, setShowTagPicker] = useState(false);
const [showListPicker, setShowListPicker] = useState(false);
const [newTagName, setNewTagName] = useState("");
@@ -50,13 +50,13 @@ export function TaskDetail({
const blockOrder = prefs.detailBlockOrder;
const splitRatio = prefs.detailSplitRatio;
const setBlockOrder = (next: ("subtasks" | "note")[]) => {
const setBlockOrder = useCallback((next: ("subtasks" | "note")[]) => {
updatePrefs({ detailBlockOrder: next });
};
}, [updatePrefs]);
const setSplitRatio = (r: number) => {
const setSplitRatio = useCallback((r: number) => {
updatePrefs({ detailSplitRatio: r });
};
}, [updatePrefs]);
const [subtasksCollapsed, setSubtasksCollapsed] = useState(false);
const [noteCollapsed, setNoteCollapsed] = useState(false);
@@ -117,7 +117,6 @@ export function TaskDetail({
const handleMouseUp = () => {
if (isDraggingSplit) {
setIsDraggingSplit(false);
// splitRatio is already saved via updatePrefs in handleMouseMove
}
};
@@ -129,7 +128,7 @@ export function TaskDetail({
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isDraggingSplit, splitRatio]);
}, [isDraggingSplit, setSplitRatio]);
// Sync state when switching task
useEffect(() => {
@@ -144,7 +143,6 @@ export function TaskDetail({
setPriority(task.priority);
setTags(task.tags || []);
setSubtasks(task.children || []);
setAllAvailableTags(getCustomTags());
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children, task.tags]);
useEffect(() => {
+2 -7
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useI18n } from "@/lib/i18n";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
import { ContextMenu } from "@/components/ui/ContextMenu";
import { KanbanView } from "./KanbanView";
import { useUserPrefs } from "@/lib/useUserPrefs";
@@ -31,11 +31,6 @@ export interface User { id: string; name?: string | null; email?: string | null
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
function isOverdue(d: string | null) {
if (!d) return false;
return new Date(d) < new Date() && new Date(d).toDateString() !== new Date().toDateString();
}
interface TaskItemProps {
task: Task;
depth?: number;
@@ -469,7 +464,7 @@ interface Props {
}
export function TaskList({
user,
user: _user,
listId,
lists,
tasks,
+112 -6
View File
@@ -43,6 +43,14 @@ export const translations = {
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",
@@ -67,6 +75,32 @@ export const translations = {
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",
@@ -189,6 +223,14 @@ export const translations = {
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar",
importBtn: "가져오기",
importing: "가져오는 중이에요...",
exportTasks: "내보내기",
exportModalTitle: "할 일 내보내기",
exportFormat: "내보내기 형식",
exportScope: "내보낼 목록",
allLists: "모든 목록 (전체 할 일)",
includeCompleted: "완료된 항목 포함",
exportBtn: "내보내기 및 다운로드",
exportSuccess: "할 일을 성공적으로 내보냈어요!",
theme: "테마",
themeSystem: "시스템",
themeLight: "라이트",
@@ -204,7 +246,7 @@ export const translations = {
settingsModalTitle: "설정",
profile: "프로필",
preferences: "환경설정",
syncIntegrations: "외부 연동",
syncIntegrations: "외부 연동 (CalDAV)",
admin: "관리자",
trashRetention: "휴지통 자동 비우기",
trashRetentionHint: "설정한 기간이 지나면 휴지통이 자동으로 비워져요.",
@@ -213,6 +255,32 @@ export const translations = {
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: "완료 숨기기",
@@ -335,6 +403,14 @@ export const translations = {
tickTickExportHint: "TickTick: 設定 → エクスポート → CSV または iCalendar",
importBtn: "インポートする",
importing: "インポート中...",
exportTasks: "エクスポート",
exportModalTitle: "タスクをエクスポート",
exportFormat: "エクスポート形式",
exportScope: "対象リスト",
allLists: "すべてのリスト(全体)",
includeCompleted: "完了したタスクを含める",
exportBtn: "エクスポートして保存",
exportSuccess: "タスクをエクスポートしました!",
theme: "テーマ",
themeSystem: "システム",
themeLight: "ライト",
@@ -350,7 +426,7 @@ export const translations = {
settingsModalTitle: "設定",
profile: "プロフィール",
preferences: "環境設定",
syncIntegrations: "外部連携",
syncIntegrations: "外部連携 (CalDAV)",
admin: "管理者",
trashRetention: "ゴミ箱の自動削除",
trashRetentionHint: "設定した期間が過ぎると、ゴミ箱が自動的に空になります。",
@@ -359,6 +435,32 @@ export const translations = {
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: "完了を非表示",
@@ -468,19 +570,23 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
setLangState(saved);
}
} catch {}
} catch {
// ignore
}
}, []);
const setLang = (l: Language) => {
setLangState(l);
try {
localStorage.setItem(LANG_STORAGE_KEY, l);
} catch {}
} catch {
// ignore
}
};
const t = (key: TranslationKeys): string => {
const dict = translations[lang] || translations.en;
return (dict as any)[key] || translations.en[key] || key;
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>;
+1 -1
View File
File diff suppressed because one or more lines are too long