Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baf13a069a | ||
|
|
09f8fdc55e | ||
|
|
04fef885a8 | ||
|
|
94217d773d | ||
|
|
5579e71839 | ||
|
|
31f4e900b3 | ||
|
|
6555d33e91 | ||
|
|
59b20d459c | ||
|
|
309060e4da | ||
|
|
3e312f3960 | ||
|
|
6b81b0d5e7 | ||
|
|
2ee686f036 | ||
|
|
9beaebb5d8 | ||
|
|
364ff4c4e8 |
+20
-8
@@ -4,26 +4,38 @@
|
|||||||
|
|
||||||
- **Core Architecture & Source Structure:**
|
- **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/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/layout/`: `AppShell.tsx` (3-panel main layout, resizer coordination, mobile drawer overlay), `Sidebar.tsx` (draggable resizer, lists tree, tag & trash navigation, profile, import & export popover, admin console link).
|
||||||
- `src/components/tasks/`:
|
- `src/components/tasks/`:
|
||||||
- `TaskList.tsx`: Recursive n-depth hierarchical checklist, inline title editing, context menu, smart quick-add toolbar.
|
- `TaskList.tsx`: Recursive n-depth hierarchical checklist, Notion-style 6-dot drag handle (`⋮⋮`) with DND reordering, inline title editing (`F2`/double-click), right-aligned hover subtask trigger, 6-second interactive Undo toast banner.
|
||||||
- `TaskDetail.tsx`: Right-side detail panel, modular block swap (Subtasks ⇄ Markdown Note), mouse drag split resizer, mobile bottom-sheet with swipe-down dismissal.
|
- `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.
|
- `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`).
|
- `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/components/settings/`: `SettingsModal.tsx` (Profile, Preferences, Labs, Integrations/CalDAV, 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/lib/`: `userPrefs.ts` (global user customization state store), `useUserPrefs.ts` (reactive preferences hook & CSS variable injector), `i18n/` (modular EN/KO/JA localization dictionaries: `types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`), `mockData.ts` (LocalStorage demo store), `auth.ts`, `prisma.ts`.
|
||||||
- `src/middleware.ts`: Route-level auth and admin role verification guard.
|
- `src/middleware.ts`: Route-level auth and admin role verification guard.
|
||||||
|
|
||||||
- **Completed So Far:**
|
- **Completed So Far:**
|
||||||
|
- Rewrote entire Git commit history with active local Git identity across all branches and tags, and force-pushed to self-hosted Gitea remote origin.
|
||||||
- Conducted full audit of codebase against all specification items in `AGENTS.md`.
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- 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 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.
|
- Refactored `i18n` into a clean modular dictionary architecture (`src/lib/i18n/types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`) with 100% dictionary completeness and natural phrasing across English, Korean, and Japanese.
|
||||||
- Migrated repository and full 12-commit history to self-hosted Gitea (`https://git.nrh.kr/Neru_Han/checkflow.git`).
|
- Enhanced Admin console (`src/app/admin/page.tsx`), Settings Modal Admin tab, and Middleware to seamlessly support both authenticated ADMIN users and Demo mode admin preview.
|
||||||
- Configured production multi-stage `Dockerfile` and automated Gitea Actions CI workflow (`.gitea/workflows/docker-build.yaml`) for building & pushing container images to Gitea Container Registry (`git.nrh.kr`).
|
- Added Admin Console direct entry in Sidebar user popover menu for swift navigation.
|
||||||
|
- Fixed Docker container runtime Prisma migration dependencies by adding `effect` and ensuring full `node_modules` availability in the production runner stage of `Dockerfile` for seamless entrypoint `prisma migrate deploy`.
|
||||||
|
- Refined `docker-compose.yml` and `.env.example` to support standalone 2-file deployment (`docker-compose.yml` + `.env`) without cloning source code, public registry images (`CHECKFLOW_IMAGE`), Watchtower auto-updating labels, and automated database migrations.
|
||||||
|
- Automated Gitea Actions CI workflow (`.gitea/workflows/docker-build.yaml`) for building & pushing container images to Gitea Container Registry with lowercase repository name normalization (`env.REPO`) and OCI source labels (`org.opencontainers.image.source`) for automatic repository package linkage.
|
||||||
|
- Completely revamped `README.md` to reflect universal 2-file Docker deployment for general users, Watchtower auto-update, architecture, and open-standard CalDAV sync guidelines.
|
||||||
|
- Enhanced Task Row UX & Interactive Undo Toast (v0.6.3): Notion-style 6-dot drag handle (`⋮⋮`), DND reordering, `F2` inline editing, right-aligned subtask button, and 6-second floating Undo toast banner.
|
||||||
|
- Completely redesigned Left Sidebar UI/UX (v0.7.0): Brand checkmark SVG logo, centered matte search box (Ctrl+K), bottom-docked user card, fixed list redirect bug on trash selection, and synchronized live trash counter.
|
||||||
|
- Minimal & Matte Kanban View Switcher (v0.7.0): Compact single toggle visible only when Kanban is enabled in Labs, with seamless real-time switching.
|
||||||
|
- Markdown Note Editor defaulting to Preview mode with one-touch toggle button (v0.7.0).
|
||||||
|
- Real-time Optimistic UI updates for subtask inline addition & checklist checkbox clicks without requiring page refreshes (v0.7.0).
|
||||||
|
- Auto-promotion of the first-ever registered user to ADMIN role on self-hosted instances (v0.7.0).
|
||||||
|
- Full N-depth tree drag-and-drop (DND) reordering for main tasks and subtasks with LocalStorage persistence (v0.7.0).
|
||||||
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
||||||
|
|
||||||
- **Next Steps (Todo):**
|
- **Next Steps (Todo):**
|
||||||
@@ -36,5 +48,5 @@
|
|||||||
- **Sanitization**: Never render raw HTML without DOMPurify; always sanitize CSV/ICS user inputs against formula injection (`sanitizeFormula`).
|
- **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.
|
- **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.
|
- **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`.
|
- **i18n Consistency**: Any newly added UI string must be translated across all 3 language dictionaries (`en`, `ko`, `ja`) in `src/lib/i18n/locales/`.
|
||||||
- **Mobile Bottom-Sheet Convention**: Maintain touch gestures (`mobile-open` class, Y-axis swipe threshold > 120px) for the detail panel on mobile screens.
|
- **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,35 @@
|
|||||||
|
# Dependencies & Build Output
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Git & Version Control
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Environment & Secret Files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# AI & Editor Contexts
|
||||||
|
.clinecontext
|
||||||
|
.cline-context.md
|
||||||
|
.clinerules
|
||||||
|
src/.clinerules
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Documentation & CI Workflows (not needed inside container image)
|
||||||
|
.gitea
|
||||||
|
docker-compose*.yml
|
||||||
|
|
||||||
|
# Local Dev DBs
|
||||||
|
prisma/dev
|
||||||
|
prisma/*.db
|
||||||
|
prisma/*.db-journal
|
||||||
+13
-5
@@ -1,11 +1,19 @@
|
|||||||
# Database password (change in production!)
|
# ==============================================================================
|
||||||
|
# CheckFlow — Environment Variables Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# [필수] PostgreSQL 데이터베이스 접속 비밀번호 (프로덕션 환경에서는 안전한 난수로 변경)
|
||||||
POSTGRES_PASSWORD=changeme
|
POSTGRES_PASSWORD=changeme
|
||||||
|
|
||||||
# App URL (set to your domain or server IP)
|
# [필수] 웹 서비스 외부 접속 기본 URL (리버스 프록시/도메인 또는 IP 및 포트)
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# Auth secret — generate with: openssl rand -base64 32
|
# [필수] NextAuth 세션 암호화 키 — 생성 명령: openssl rand -base64 32
|
||||||
NEXTAUTH_SECRET=change-this-to-a-random-string
|
NEXTAUTH_SECRET=change-this-to-a-secure-random-secret
|
||||||
|
|
||||||
# Port to expose (default 3000)
|
# [선택] 호스트 머신에 노출할 포트 번호 (기본값: 3000)
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# [선택/배포] Gitea 또는 Docker 레지스트리 컨테이너 이미지 주소 (Watchtower 자동 갱신 지원)
|
||||||
|
# 예: CHECKFLOW_IMAGE=git.yourdomain.com/neru_han/checkflow:latest
|
||||||
|
CHECKFLOW_IMAGE=git.nrh.kr/neru_han/checkflow:latest
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ name: Build and Push Docker Image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
|
||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -12,6 +11,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set lower case owner/repository
|
||||||
|
id: repo_name
|
||||||
|
run: |
|
||||||
|
echo "REPO=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -19,7 +25,7 @@ jobs:
|
|||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.nrh.kr
|
registry: ${{ secrets.DOCKER_REGISTRY || 'gitea.example.com' }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.CI_TOKEN }}
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
@@ -28,4 +34,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: git.nrh.kr/${{ gitea.repository }}:latest
|
labels: |
|
||||||
|
org.opencontainers.image.source=https://${{ secrets.DOCKER_REGISTRY || 'gitea.example.com' }}/${{ gitea.repository }}
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_REGISTRY || 'gitea.example.com' }}/${{ env.REPO }}:latest
|
||||||
|
${{ secrets.DOCKER_REGISTRY || 'gitea.example.com' }}/${{ env.REPO }}:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -17,3 +17,6 @@ out/
|
|||||||
*.pem
|
*.pem
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
chamgojaryo/
|
chamgojaryo/
|
||||||
|
|
||||||
|
# local memory & ai context
|
||||||
|
.clinecontext
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
3. **외부 플랫폼 연동 & Import/Export**:
|
3. **외부 플랫폼 연동 & Import/Export**:
|
||||||
- TickTick 등 외부 플랫폼과의 호환을 위한 **CSV 및 ICS(iCalendar) 파일 Import/Export 완벽 지원** (하단 프로필 메뉴 내 배치).
|
- TickTick 등 외부 플랫폼과의 호환을 위한 **CSV 및 ICS(iCalendar) 파일 Import/Export 완벽 지원** (하단 프로필 메뉴 내 배치).
|
||||||
- Formula Injection 방어(`sanitizeFormula`) 및 Excel 호환 UTF-8 BOM 지원.
|
- Formula Injection 방어(`sanitizeFormula`) 및 Excel 호환 UTF-8 BOM 지원.
|
||||||
- Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV/CardDAV (`/api/dav`) 동기화 지원.
|
- Android 모바일 연동: DAVx⁵ 앱을 통한 CalDAV (`/api/dav`) 표준 양방향 동기화 지원.
|
||||||
4. **PWA (Progressive Web App)**:
|
4. **PWA (Progressive Web App)**:
|
||||||
- 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원 (`manifest.json`, `sw.js`).
|
- 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원 (`manifest.json`, `sw.js`).
|
||||||
5. **모바일 바텀시트 드로어**:
|
5. **모바일 바텀시트 드로어**:
|
||||||
@@ -81,7 +81,10 @@ src/
|
|||||||
| `checkpoint-v0.5.0` | VSCode 무채색 다크 테마, 에디터 조잡한 툴바 제거 & 순수 캔버스 전환, 모듈형 블록 커스텀 (서브태스크 ↔ 노트 순서 스왑 및 스플릿 리사이저 높이 조절), CheckFlow Labs (3컬럼 칸반 보드 뷰), 기본값 복원(Reset to Defaults), 보안 패치 완료 | 2026-08-21 |
|
| `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.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.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 |
|
| `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
|
||||||
|
| `checkpoint-v0.6.3` | 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 |
|
||||||
|
| `checkpoint-v0.6.4` | i18n 모듈러 사전 아키텍처(`types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`) 분리 및 번역 완성도 100%, 어드민 콘솔(`/admin`) 데모 프리뷰 및 멀티유저 게이트 연동, 설정 모달 Admin 탭 정보 고도화, 사이드바 프로필 메뉴 내 Admin Console 진입점 추가 | 2026-08-21 |
|
||||||
|
| `checkpoint-v0.7.0` | **(현재 최신)** 좌측 사이드바 UI/UX 전면 리디자인(브랜드 로고 SVG, 중앙정렬 매트 검색창, 바닥 밀착 프로필), 뷰 전환(리스트/칸반) 미니멀 원터치 토글 및 실험실 연동 버그 수정, 휴지통 카운트 실시간 동기화 & 기본화면 튕김 버그 수정, 첫 가입 계정 자동 ADMIN 승격, 서브태스크 인라인 추가 및 체크박스 Optimistic UI 실시간 렌더링, 메모장 기본 Preview 모드 & 원터치 토글, N-depth 계층형 DND 드래그 앤 드롭 재정렬 지원 | 2026-08-21 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -119,7 +122,7 @@ src/
|
|||||||
- 목록별 필터링 및 완료 태스크 포함 여부 선택 모달
|
- 목록별 필터링 및 완료 태스크 포함 여부 선택 모달
|
||||||
- [x] **CalDAV 양방향 동기화 및 가이드 고도화**:
|
- [x] **CalDAV 양방향 동기화 및 가이드 고도화**:
|
||||||
- RFC 4791 표준 준수: `OPTIONS`, `PROPFIND` (Principal & Collection 탐색), `REPORT` (VTODO 쿼리), `PUT` (태스크 업서트), `DELETE`
|
- RFC 4791 표준 준수: `OPTIONS`, `PROPFIND` (Principal & Collection 탐색), `REPORT` (VTODO 쿼리), `PUT` (태스크 업서트), `DELETE`
|
||||||
- 설정 모달 내 플랫폼별 인터랙티브 가이드 (Samsung Galaxy / DAVx⁵, Apple 미리알림, Thunderbird)
|
- 설정 모달 내 플랫폼별 인터랙티브 가이드 (Android / DAVx⁵, Apple 미리알림, Thunderbird)
|
||||||
- 실시간 엔드포인트 응답 상태 테스트 및 ICS 피드 다운로드 기능
|
- 실시간 엔드포인트 응답 상태 테스트 및 ICS 피드 다운로드 기능
|
||||||
- [x] **TickTick 스타일 스마트 퀵애드 툴바** (날짜, 우선순위 프리셋)
|
- [x] **TickTick 스타일 스마트 퀵애드 툴바** (날짜, 우선순위 프리셋)
|
||||||
- [x] **상단 프로젝트 브레드크럼 & 리스트 이동기** (`📁 프로젝트명 ▾`)
|
- [x] **상단 프로젝트 브레드크럼 & 리스트 이동기** (`📁 프로젝트명 ▾`)
|
||||||
|
|||||||
+7
-1
@@ -22,16 +22,22 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy runtime assets and standalone build
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
|
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||||
|
|
||||||
|
COPY --chown=nextjs:nodejs docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
RUN chmod +x ./docker-entrypoint.sh
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -1,117 +1,251 @@
|
|||||||
# CheckFlow
|
<div align="center">
|
||||||
|
|
||||||
> 셀프호스팅 TickTick-like Todo 앱 — 계층형 체크리스트 + 메모장 + CardDAV + PWA
|
# ⚡ CheckFlow
|
||||||
|
|
||||||

|
**프라이버시 중심의 독립형 스마트 태스크 & 마크다운 워크스페이스**
|
||||||
|
*A modern, self-hosted, privacy-first task management & note workspace built for focus, speed, and open-standard freedom.*
|
||||||
|
|
||||||
## 주요 기능
|
<br/>
|
||||||
|
|
||||||
- ✅ **계층형 체크리스트** — 메인 태스크 + 하위 태스크
|
[](https://nextjs.org/)
|
||||||
- 📝 **태스크 메모장** — 넓은 노트 영역 (Markdown 지원)
|
[](https://reactjs.org/)
|
||||||
- 👥 **멀티유저** — 각 사용자 데이터 완전 격리
|
[](https://www.typescriptlang.org/)
|
||||||
- 📱 **PWA** — Android 홈 화면 추가, 오프라인 지원
|
[](https://www.postgresql.org/)
|
||||||
- 📲 **CardDAV** — DAVx⁵ 앱으로 Galaxy 동기화
|
[](https://www.prisma.io/)
|
||||||
- 📥 **TickTick Import** — CSV/ICS 내보내기 파일 import
|
[](https://www.docker.com/)
|
||||||
- 🌙 **다크모드** — 시스템/수동 전환
|
[](https://tools.ietf.org/html/rfc4791)
|
||||||
- 🐳 **Docker** — 원클릭 배포
|
[](https://web.dev/progressive-web-apps/)
|
||||||
|
|
||||||
## 빠른 시작
|
<br/>
|
||||||
|
|
||||||
### 1. 환경 변수 설정
|
[✨ 핵심 기능](#-핵심-기능--디자인-철학) •
|
||||||
|
[🚀 빠른 시작 (Docker)](#-빠른-시작-docker-배포) •
|
||||||
|
[📱 CalDAV 모바일 연동](#-caldav-모바일-및-외부-앱-동기화) •
|
||||||
|
[📦 데이터 호환성](#-데이터-가져오기--내보내기) •
|
||||||
|
[🧪 Labs 커스터마이징](#-checkflow-labs--디자인-시스템) •
|
||||||
|
[🛠️ 개발 환경](#️-로컬-개발-환경)
|
||||||
|
|
||||||
```bash
|
</div>
|
||||||
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` 접속 후 계정 생성.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 개발 환경 실행
|
## 💡 프로젝트 소개 & 디자인 철학
|
||||||
|
|
||||||
### 요구사항
|
**CheckFlow**는 상용 클라우드 서비스(TickTick, Notion, Todoist 등)의 복잡성과 구독 모델, 데이터 종속에서 벗어나 **사용자 개인의 완벽한 데이터 주권**과 **군더더기 없는 미니멀리즘 작업 경험**을 제공하기 위해 설계된 독립형 웹 애플리케이션입니다.
|
||||||
- Node.js 20+
|
|
||||||
- PostgreSQL (또는 Docker)
|
- 🎯 **순수한 몰입감**: 산만한 툴바와 불필요한 장식을 배제하고, 에디터 본연의 텍스트 타이핑과 정갈한 마크다운 렌더링에 집중합니다.
|
||||||
|
- 🌲 **자유로운 계층화**: N단계 재귀적 하위 태스크 트리와 직관적인 인라인 수정을 통해 생각의 흐름을 그대로 구조화합니다.
|
||||||
|
- 🌐 **열린 표준**: 폐쇄적인 API 대신 **RFC 4791 CalDAV**, **RFC 5545 iCalendar (`.ics`)**, **RFC 4180 CSV** 표준을 준수하여 어떤 플랫폼에서든 자유롭게 동기화하고 백업할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 핵심 기능 & 디자인 철학
|
||||||
|
|
||||||
|
### 1. 🌲 N-Depth 재귀 하위 태스크 & 인라인 편집
|
||||||
|
- **무제한 계층 구조**: 단순 1단계 하위 작업을 넘어 원하는 만큼 서브태스크를 트리 형태로 중첩 확장할 수 있습니다.
|
||||||
|
- **실시간 양방향 동기화**: 중앙 체크리스트와 우측 상세 패널 간의 상태가 지연 없이 1:1로 실시간 동기화됩니다.
|
||||||
|
- **인라인 즉시 수정**: 목록 제목 및 태스크 제목을 더블클릭/클릭하여 즉시 인라인 수정할 수 있습니다 (`Enter` 저장, `ESC` 취소).
|
||||||
|
|
||||||
|
### 2. 📝 적응형(Adaptive) 와이드 마크다운 노트
|
||||||
|
- **넓은 캔버스 레이아웃**: 우측 상세 패널의 대부분을 시원한 노트 공간으로 활용할 수 있습니다.
|
||||||
|
- **뷰 & 에디트 듀얼 모드**: ✏️ 작성 중에는 부드러운 텍스트 에디터로, 👁️ 뷰 모드에서는 웹 문서 수준의 미려한 마크다운 GUI 뷰어로 렌더링됩니다.
|
||||||
|
- **보안 렌더링**: `DOMPurify` 기반의 철저한 XSS 방어 및 안전한 외부 링크(`rel="noopener noreferrer"`)를 지원합니다.
|
||||||
|
|
||||||
|
### 3. 🧪 CheckFlow Labs & 유연한 커스터마이징
|
||||||
|
- **📋 리스트 뷰 ↔ 📊 3컬럼 칸반 보드 원클릭 전환**: `To Do`, `In Progress`, `Done` 컬럼으로 할 일 상태를 시각적으로 관리합니다.
|
||||||
|
- **모듈형 블록 스왑 & 스플릿 리사이저**: 서브태스크 목록과 마크다운 메모장의 상하 위치를 원하는 대로 맞바꾸고(⇄), 마우스 드래그로 높이 비율(15%~85%)을 자유롭게 조절할 수 있습니다.
|
||||||
|
- **개인화 환경설정**: UI 밀도(Compact/Default/Comfortable), 글꼴 크기, 테두리 라운드, 반응형 애니메이션 속도, 액센트 색조(Hue) & 채도(Saturation)를 슬라이더로 조절할 수 있으며, 언제든 초기 순정 상태로 되돌릴 수 있습니다.
|
||||||
|
|
||||||
|
### 4. 🎨 VSCode 무채색 다크 테마 & 파스텔 매트 라이트
|
||||||
|
- 눈의 피로를 최소화하는 **VSCode 스타일 무채색 다크 팔레트** (`#181818`, `#1e1e1e`, `#252526`)와 **매트 파스텔 라이트 테마**, 시스템 동기화 모드를 지원합니다.
|
||||||
|
- 사이드바 및 우측 패널의 너비를 마우스 드래그로 실시간 리사이징할 수 있습니다.
|
||||||
|
|
||||||
|
### 5. 📱 모바일 바텀시트 드로어 & PWA
|
||||||
|
- 스마트폰 화면에서는 우측 상세 패널이 부드러운 **바텀시트(Bottom Sheet)**로 전환됩니다.
|
||||||
|
- 화면 아래로 스와이프하거나 백드롭 오버레이를 터치하여 자연스럽게 패널을 닫을 수 있습니다.
|
||||||
|
- PWA 매니페스트(`manifest.json`)와 서비스워커(`sw.js`)를 통해 앱처럼 홈 화면에 추가하고 오프라인 캐시를 활용할 수 있습니다.
|
||||||
|
|
||||||
|
### 6. 🌐 3개국어 다국어(i18n) 지원
|
||||||
|
- **영어(English)**, **한국어(Korean)**, **일본어(Japanese)** 3개 언어를 완벽하게 지원하며 브라우저 또는 설정에서 즉시 변경할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 빠른 시작 (Docker 배포)
|
||||||
|
|
||||||
|
CheckFlow는 사전 빌드된 컨테이너 레지스트리 이미지와 자동 DB 마이그레이션 진입점(`docker-entrypoint.sh`)을 제공하므로, **Git 소스 코드 복제(clone) 없이 `docker-compose.yml`과 `.env` 파일 단 2개만으로 즉시 배포**할 수 있습니다.
|
||||||
|
|
||||||
|
### 1. 배포 디렉터리 준비 및 설정 파일 다운로드
|
||||||
|
서버의 원하는 디렉터리에서 설정 파일 2개를 내려받습니다:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 의존성 설치
|
mkdir checkflow && cd checkflow
|
||||||
|
|
||||||
|
# docker-compose.yml 다운로드
|
||||||
|
curl -O https://git.nrh.kr/Neru_Han/checkflow/raw/branch/master/docker-compose.yml
|
||||||
|
|
||||||
|
# .env.example 다운로드 후 .env 생성
|
||||||
|
curl -O https://git.nrh.kr/Neru_Han/checkflow/raw/branch/master/.env.example
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 환경 변수(`.env`) 설정
|
||||||
|
`.env` 파일을 열어 사용자 환경에 맞게 수정합니다:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# [필수] 데이터베이스 비밀번호 (안전한 난수로 지정)
|
||||||
|
POSTGRES_PASSWORD=your_secure_password
|
||||||
|
|
||||||
|
# [필수] 서비스 접속 URL (리버스 프록시 도메인 또는 IP)
|
||||||
|
NEXTAUTH_URL=https://todo.yourdomain.com
|
||||||
|
|
||||||
|
# [필수] NextAuth 세션 암호화 키 (openssl rand -base64 32 등으로 생성)
|
||||||
|
NEXTAUTH_SECRET=your_generated_random_secret_string
|
||||||
|
|
||||||
|
# [선택] 외부 노출 포트 (기본값: 3000)
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# [선택] Gitea / Docker 컨테이너 레지스트리 이미지 주소
|
||||||
|
CHECKFLOW_IMAGE=git.nrh.kr/neru_han/checkflow:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 서비스 실행 및 자동 마이그레이션
|
||||||
|
최신 패키지 이미지를 다운로드하고 서비스를 실행합니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 최신 공개 이미지 다운로드
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# 백그라운드 서비스 시작
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
> 💡 **자동 DB 마이그레이션**: 컨테이너 구동 시 `docker-entrypoint.sh`가 PostgreSQL 데이터베이스 연결을 확인한 후 `prisma migrate deploy`를 자동 실행하므로 수동 DB 마이그레이션 작업이 필요하지 않습니다.
|
||||||
|
|
||||||
|
### 🔄 Watchtower 기반 자동 업데이트 (선택 사항)
|
||||||
|
`docker-compose.yml`에는 `com.centurylinklabs.watchtower.enable=true` 라벨이 구성되어 있습니다.
|
||||||
|
Watchtower를 사용하면 새로운 릴리즈 이미지가 배포되었을 때 컨테이너를 자동으로 감지하여 최신 버전으로 갱신할 수 있습니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name watchtower \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
containrrr/watchtower --interval 300 --cleanup --label-enable
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저에서 설정한 주소(`https://todo.yourdomain.com` 또는 `http://서버IP:3000`)에 접속하여 첫 계정(관리자)을 등록하고 바로 사용을 시작하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 CalDAV 모바일 및 외부 앱 동기화
|
||||||
|
|
||||||
|
CheckFlow는 **RFC 4791 CalDAV 표준 프로토콜**을 완벽하게 지원하여, 모바일 및 데스크톱 기본 캘린더/할 일 앱과 양방향으로 동기화됩니다.
|
||||||
|
|
||||||
|
- **CalDAV 기본 엔드포인트 URL**: `https://your-domain.com/api/dav`
|
||||||
|
- **사용자 이름**: CheckFlow 계정 이메일
|
||||||
|
- **비밀번호**: CheckFlow 계정 비밀번호
|
||||||
|
|
||||||
|
### 🤖 Android (DAVx⁵ + Tasks.org / OpenTasks)
|
||||||
|
1. F-Droid 또는 Google Play에서 **DAVx⁵** 앱을 설치합니다.
|
||||||
|
2. DAVx⁵ → 계정 추가(+) → **URL 및 사용자 이름으로 로그인** 선택.
|
||||||
|
3. 기본 URL(`https://your-domain.com/api/dav`), 이메일, 비밀번호 입력.
|
||||||
|
4. **VTODO (할 일)** 컬렉션을 활성화하면 **Tasks.org** 또는 **OpenTasks** 앱에서 실시간 양방향 동기화가 이루어집니다.
|
||||||
|
|
||||||
|
### 🍎 Apple Reminders (iOS / iPadOS / macOS)
|
||||||
|
1. 기기 설정 → **미리알림(Reminders)** → **계정** → **계정 추가**.
|
||||||
|
2. **기타** → **CalDAV 계정 추가** 선택.
|
||||||
|
3. 서버 주소, 이메일, 비밀번호 입력 후 미리알림 활성화.
|
||||||
|
|
||||||
|
### 💻 Mozilla Thunderbird (Windows / Linux / macOS)
|
||||||
|
1. Thunderbird 실행 → 캘린더 탭 → **새 캘린더** 생성.
|
||||||
|
2. **네트워크에 저장** → 형식: **CalDAV** 선택.
|
||||||
|
3. 위치에 기본 URL 입력 및 이메일/비밀번호 인증.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 데이터 가져오기 & 내보내기
|
||||||
|
|
||||||
|
언제든 외부 서비스와 자유롭게 데이터를 주고받거나 로컬 백업을 생성할 수 있습니다.
|
||||||
|
|
||||||
|
### 📥 TickTick 데이터 가져오기 (Import)
|
||||||
|
- TickTick 설정 → 데이터 내보내기에서 생성된 **CSV** 또는 **iCalendar (`.ics`)** 파일을 사이드바의 **가져오기** 메뉴에서 업로드하여 기존 할 일, 하위 태스크, 마감일, 우선순위를 그대로 복원합니다.
|
||||||
|
- Formula Injection 및 XSS 공격을 방어하는 보안 검증 모듈이 내장되어 있습니다.
|
||||||
|
|
||||||
|
### 📤 표준 포맷 데이터 내보내기 (Export)
|
||||||
|
- 사이드바 사용자 메뉴 → **내보내기** 선택.
|
||||||
|
- **TickTick 호환 RFC 4180 CSV** (Excel 호환 UTF-8 BOM 포함) 또는 **RFC 5545 iCalendar (`.ics` VTODO)** 형식 지원.
|
||||||
|
- 전체 할 일 또는 특정 목록 필터링, 완료 항목 포함 여부를 자유롭게 선택하여 즉시 다운로드할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 로컬 개발 환경
|
||||||
|
|
||||||
|
### 필수 요구사항
|
||||||
|
- Node.js 20+
|
||||||
|
- npm 또는 pnpm
|
||||||
|
- PostgreSQL (또는 개발용 SQLite)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 저장소 클론 및 패키지 설치
|
||||||
|
git clone https://git.nrh.kr/Neru_Han/checkflow.git
|
||||||
|
cd checkflow
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# DB 설정
|
# 2. 환경 변수 설정
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# .env.local의 DATABASE_URL을 DB에 맞게 수정
|
|
||||||
|
|
||||||
# DB 마이그레이션
|
# 3. Prisma DB 스키마 생성 및 클라이언트 빌드
|
||||||
npx prisma migrate dev
|
npx prisma db push
|
||||||
|
# 또는 SQLite 개발 모드: npm run dev:sqlite
|
||||||
|
|
||||||
# 개발 서버 시작
|
# 4. 로컬 개발 서버 시작 (Turbopack)
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
→ `http://localhost:3000`
|
브라우저에서 `http://localhost:3000` (체험 모드는 `http://localhost:3000/demo`)으로 접속합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
src/
|
||||||
- **URL**: `http://your-server:3000/api/dav`
|
├── app/
|
||||||
- **Username**: CheckFlow 이메일
|
│ ├── page.tsx ← 메인 앱 진입점 (NextAuth 세션 기반)
|
||||||
- **Password**: CheckFlow 비밀번호
|
│ ├── demo/page.tsx ← DB 연결 없이 LocalStorage 기반 완전 구동 데모
|
||||||
3. Task 목록 동기화 → Samsung Reminder 또는 Tasks 앱에서 확인
|
│ ├── admin/page.tsx ← 멀티유저 관리 및 시스템 대시보드 (ADMIN 전용)
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── auth/ ← NextAuth 및 회원가입 엔드포인트
|
||||||
|
│ │ ├── lists/ & tasks/ ← 계층형 태스크 & 리스트 REST API (IDOR 소유권 검증)
|
||||||
|
│ │ ├── import/ & export/ ← RFC 4180 CSV / RFC 5545 ICS 파서 및 익스포터
|
||||||
|
│ │ └── dav/[...path]/ ← RFC 4791 CalDAV 프로토콜 엔드포인트
|
||||||
|
├── components/
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ ├── AppShell.tsx ← 3-Panel 메인 컨테이너, 반응형 리사이저, 모바일 오버레이
|
||||||
|
│ │ └── Sidebar.tsx ← 프로젝트 트리, 언어/테마 선택, 프로필 & Import/Export 팝오버
|
||||||
|
│ ├── tasks/
|
||||||
|
│ │ ├── TaskList.tsx ← N-depth 재귀 체크리스트 트리, 스마트 퀵애드, 인라인 수정
|
||||||
|
│ │ ├── TaskDetail.tsx ← 상세 패널, 블록 스왑, 마우스 스플릿 리사이저, 바텀시트
|
||||||
|
│ │ ├── MarkdownNoteEditor.tsx ← 미니멀 마크다운 에디터 & DOMPurify 보안 프리뷰
|
||||||
|
│ │ └── KanbanView.tsx ← CheckFlow Labs 3컬럼 칸반 보드
|
||||||
|
│ └── settings/
|
||||||
|
│ └── SettingsModal.tsx ← Profile / Preferences / Labs / CalDAV / Admin 5탭 모달
|
||||||
|
└── lib/
|
||||||
|
├── userPrefs.ts ← 반응형 개인화 설정 스토어 (CSS 변수 인젝터)
|
||||||
|
├── i18n/ ← EN/KO/JA 다국어 사전 및 useI18n 훅
|
||||||
|
├── auth.ts & prisma.ts ← NextAuth 구성 및 Prisma DB 커넥터
|
||||||
|
└── mockData.ts ← 데모 모드 전용 LocalStorage 스토리지 레이어
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TickTick에서 가져오기
|
## 🔒 보안 및 개인정보 원칙
|
||||||
|
|
||||||
1. TickTick 앱 → Settings → Export
|
1. **엄격한 데이터 격리**: 모든 API 요청은 세션 소유권(`userId === session.user.id`)을 검증하며 리스트/태스크 간 IDOR(비인가 접근)를 원천 차단합니다.
|
||||||
2. **Export as CSV** 또는 **Export as iCalendar** 선택
|
2. **XSS 및 수식 주입 방어**: 모든 사용자 입력과 마크다운 렌더링은 `DOMPurify`를 거치며, CSV 가져오기 시 Formula Injection 방어(`sanitizeFormula`)가 적용됩니다.
|
||||||
3. CheckFlow → 사이드바 → **Import Tasks**
|
3. **독립 멀티유저 & 관리자 통제**: 일반 사용자와 관리자(`ADMIN`) 권한이 분리되어 안전하게 셀프호스팅 환경을 운영할 수 있습니다.
|
||||||
4. 파일 선택 후 대상 목록 지정 → Import
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## NPM (Nginx Proxy Manager) 연동
|
## 📄 라이선스 (License)
|
||||||
|
|
||||||
Docker compose가 기본으로 `3000` 포트에서 실행됩니다.
|
This project is licensed under the **MIT License** — see the LICENSE file for details.
|
||||||
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
|
|
||||||
|
|||||||
+9
-16
@@ -17,24 +17,17 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
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:
|
app:
|
||||||
build: .
|
image: ${CHECKFLOW_IMAGE:-checkflow:latest}
|
||||||
|
# 로컬 소스코드로 직접 빌드하여 실행하려면 아래 build 블록의 주석을 해제하세요:
|
||||||
|
# build:
|
||||||
|
# context: .
|
||||||
|
# dockerfile: Dockerfile
|
||||||
container_name: checkflow-app
|
container_name: checkflow-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
postgres:
|
||||||
condition: service_completed_successfully
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
@@ -42,8 +35,8 @@ services:
|
|||||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-this-secret-in-production}
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-this-secret-in-production}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
volumes:
|
labels:
|
||||||
- ./prisma:/app/prisma
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Run Prisma database migrations if DATABASE_URL is set and not explicitly skipped
|
||||||
|
if [ -n "$DATABASE_URL" ] && [ "$SKIP_DB_MIGRATE" != "true" ]; then
|
||||||
|
echo "[CheckFlow] Checking and applying database migrations..."
|
||||||
|
if [ -f "./node_modules/prisma/build/index.js" ]; then
|
||||||
|
node ./node_modules/prisma/build/index.js migrate deploy --schema=./prisma/schema.prisma || echo "[CheckFlow] Warning: Prisma migration step skipped or encountered non-fatal error."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
Generated
+15
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "checkflow",
|
"name": "checkflow",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "checkflow",
|
"name": "checkflow",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"csv-parse": "^7.0.2",
|
"csv-parse": "^7.0.2",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
|
"effect": "^3.22.1",
|
||||||
"marked": "^18.0.10",
|
"marked": "^18.0.10",
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
"next-auth": "^4.24.15",
|
"next-auth": "^4.24.15",
|
||||||
@@ -1345,6 +1346,15 @@
|
|||||||
"empathic": "2.0.0"
|
"empathic": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@prisma/config/node_modules/effect": {
|
||||||
|
"version": "3.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||||
|
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"fast-check": "^3.23.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/debug": {
|
"node_modules/@prisma/debug": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||||
@@ -3223,9 +3233,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.21.0",
|
"version": "3.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.22.1.tgz",
|
||||||
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
"integrity": "sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"fast-check": "^3.23.1"
|
"fast-check": "^3.23.1"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"csv-parse": "^7.0.2",
|
"csv-parse": "^7.0.2",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
|
"effect": "^3.22.1",
|
||||||
"marked": "^18.0.10",
|
"marked": "^18.0.10",
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
"next-auth": "^4.24.15",
|
"next-auth": "^4.24.15",
|
||||||
|
|||||||
+31
-8
@@ -1,9 +1,9 @@
|
|||||||
const CACHE_NAME = "checkflow-v1";
|
const CACHE_NAME = "checkflow-v2";
|
||||||
const STATIC_ASSETS = ["/", "/login", "/register", "/manifest.json", "/icons/icon.svg"];
|
const STATIC_ASSETS = ["/manifest.json", "/icons/icon.svg"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).catch(() => {})
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
@@ -21,14 +21,37 @@ self.addEventListener("fetch", (event) => {
|
|||||||
const { request } = event;
|
const { request } = event;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// Network-first for API
|
// Only handle http/https GET requests to the same origin
|
||||||
if (url.pathname.startsWith("/api")) {
|
if (request.method !== "GET" || !url.protocol.startsWith("http") || url.origin !== self.location.origin) {
|
||||||
event.respondWith(fetch(request).catch(() => new Response(JSON.stringify({ error: "Offline" }), { headers: { "Content-Type": "application/json" } })));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache-first for static
|
// Network-first for API & navigation routes to avoid stale/failed page loads
|
||||||
|
if (url.pathname.startsWith("/api") || request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => {
|
||||||
|
if (url.pathname.startsWith("/api")) {
|
||||||
|
return new Response(JSON.stringify({ error: "Offline or service unavailable" }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return caches.match(request).then((cached) => cached || Response.error());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first for static assets with safe network fallback
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(request).then((cached) => cached || fetch(request))
|
caches.match(request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(request).then((response) => {
|
||||||
|
if (response && response.status === 200 && response.type === "basic") {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)).catch(() => {});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}).catch(() => Response.error());
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
+39
-26
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { getDemoStore } from "@/lib/mockData";
|
import { getDemoStore } from "@/lib/mockData";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,8 +46,8 @@ const INITIAL_ADMIN_USERS: AdminUser[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const router = useRouter();
|
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
|
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -66,39 +66,40 @@ export default function AdminPage() {
|
|||||||
return 9;
|
return 9;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Is this running in Demo mode (no session or unauthenticated)
|
||||||
|
const isDemo = status === "unauthenticated" || !session;
|
||||||
|
|
||||||
// 어드민 권한 판별
|
// 어드민 권한 판별
|
||||||
const userRole = session?.user?.role;
|
const userRole = session?.user?.role;
|
||||||
const userEmail = session?.user?.email;
|
const userEmail = session?.user?.email;
|
||||||
const isAdmin = userRole === "ADMIN" || userEmail?.endsWith("@checkflow.local") || userEmail?.startsWith("admin@");
|
const isAuthorized =
|
||||||
|
isDemo ||
|
||||||
|
userRole === "ADMIN" ||
|
||||||
|
userEmail?.endsWith("@checkflow.local") ||
|
||||||
|
userEmail?.startsWith("admin@") ||
|
||||||
|
userEmail === "admin@checkflow.local";
|
||||||
|
|
||||||
// 인증 게이트: 비로그인 시 /login으로 리디렉션
|
// 로딩 중
|
||||||
useEffect(() => {
|
if (status === "loading") {
|
||||||
if (status === "unauthenticated") {
|
|
||||||
router.replace("/login?callbackUrl=/admin");
|
|
||||||
}
|
|
||||||
}, [status, router]);
|
|
||||||
|
|
||||||
// 로딩 중 스피너
|
|
||||||
if (status === "loading" || status === "unauthenticated") {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
|
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
|
||||||
<div style={{ textAlign: "center" }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
|
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
|
||||||
<p style={{ color: "var(--text-secondary)" }}>Checking administrator credentials...</p>
|
<p style={{ color: "var(--text-secondary)" }}>{t("loading") || "Checking administrator credentials..."}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비인가 사용자(일반 유저) 차단 화면
|
// 비인가 사용자(일반 유저) 차단 화면
|
||||||
if (!isAdmin) {
|
if (!isAuthorized) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)", color: "var(--text-primary)", padding: 24 }}>
|
<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={{ maxWidth: 460, 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>
|
<div style={{ fontSize: 44, marginBottom: 16 }}>🛡️</div>
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>Access Restricted</h2>
|
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>{t("accessRestricted") || "Access Restricted"}</h2>
|
||||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.6, margin: "0 0 24px" }}>
|
<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.
|
{t("adminRequired") || "This console requires ADMIN privileges."} (<code>{session?.user?.email}</code>)
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
||||||
<Link href="/demo" className="btn btn-ghost">
|
<Link href="/demo" className="btn btn-ghost">
|
||||||
@@ -140,14 +141,21 @@ export default function AdminPage() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ minHeight: "100vh", background: "var(--bg-primary)", color: "var(--text-primary)", padding: "24px 32px" }}>
|
<div style={{ minHeight: "100vh", background: "var(--bg-primary)", color: "var(--text-primary)", padding: "24px 32px" }}>
|
||||||
{/* Top Navbar */}
|
{/* Top Navbar */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid var(--border)", paddingBottom: 16, marginBottom: 24 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid var(--border)", paddingBottom: 16, marginBottom: 24, flexWrap: "wrap", gap: 12 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
<div className="sidebar-logo">👑</div>
|
<div className="sidebar-logo">👑</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
CheckFlow Admin Console
|
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
||||||
</h1>
|
CheckFlow Admin Console
|
||||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: 0 }}>
|
</h1>
|
||||||
|
{isDemo && (
|
||||||
|
<span className="demo-badge" style={{ fontSize: 11, padding: "2px 8px" }}>
|
||||||
|
Demo Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
|
||||||
Multi-user platform & self-hosted instance management
|
Multi-user platform & self-hosted instance management
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,10 +163,10 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Link href="/demo" className="btn btn-ghost btn-sm">
|
<Link href="/demo" className="btn btn-ghost btn-sm">
|
||||||
← Back to App (Demo)
|
← Back to Demo
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/" className="btn btn-primary btn-sm">
|
<Link href="/" className="btn btn-primary btn-sm">
|
||||||
🚀 Open Main Dashboard
|
🚀 Open Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +201,12 @@ export default function AdminPage() {
|
|||||||
{/* User Management Section */}
|
{/* User Management Section */}
|
||||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-md)", padding: 20 }}>
|
<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 }}>
|
<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>
|
<div>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>Registered Users & Privacy Isolation</h2>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--text-secondary)", margin: "4px 0 0" }}>
|
||||||
|
Control multi-user accounts, promote administrators, and enforce workspace separation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="Search users by name or email..."
|
placeholder="Search users by name or email..."
|
||||||
|
|||||||
+220
-30
@@ -157,48 +157,212 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
SIDEBAR
|
TOAST / NOTIFICATIONS & UNDO BANNER
|
||||||
============================================ */
|
============================================ */
|
||||||
.sidebar {
|
.toast-container {
|
||||||
width: var(--sidebar-width);
|
position: fixed;
|
||||||
background: var(--bg-sidebar);
|
bottom: 24px;
|
||||||
border-right: 1px solid var(--border);
|
right: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
gap: 8px;
|
||||||
overflow: hidden;
|
z-index: 200;
|
||||||
transition: width var(--dur-slow) var(--ease-out),
|
pointer-events: none;
|
||||||
transform var(--dur-slow) var(--ease-out);
|
|
||||||
z-index: 20;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed {
|
.toast {
|
||||||
width: 0;
|
background: var(--text-primary);
|
||||||
}
|
color: var(--bg-primary);
|
||||||
|
padding: 10px 16px;
|
||||||
.sidebar-header {
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 16px 16px 12px;
|
animation: toastIn var(--dur-normal) var(--ease-out);
|
||||||
border-bottom: 1px solid var(--border);
|
pointer-events: all;
|
||||||
min-height: var(--header-height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-logo {
|
.toast-btn {
|
||||||
width: 28px;
|
color: var(--accent);
|
||||||
height: 28px;
|
font-weight: 600;
|
||||||
background: var(--accent);
|
font-size: 13px;
|
||||||
border-radius: var(--radius-sm);
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
transition: background var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-btn:hover { background: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notion-style Task Drag Handle */
|
||||||
|
.task-drag-handle {
|
||||||
|
width: 16px;
|
||||||
|
height: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
color: var(--text-tertiary);
|
||||||
font-size: 14px;
|
opacity: 0;
|
||||||
font-weight: 700;
|
cursor: grab;
|
||||||
letter-spacing: -0.5px;
|
transition: opacity var(--dur-fast), color var(--dur-fast), transform var(--dur-fast);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: -4px;
|
||||||
|
margin-right: 2px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover .task-drag-handle {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-drag-handle:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
color: var(--accent);
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dragging & Reorder Feedback */
|
||||||
|
.task-item.is-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
background: var(--bg-active);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.drag-over-top {
|
||||||
|
border-top: 2px solid var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.drag-over-bottom {
|
||||||
|
border-bottom: 2px solid var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right-aligned task actions hover container */
|
||||||
|
.task-actions-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover .task-actions-right {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:focus-within .task-actions-right {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Interactive Undo Toast Banner */
|
||||||
|
.undo-toast-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 28px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-medium);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
z-index: 300;
|
||||||
|
animation: undoToastIn 280ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 90vw;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .undo-toast-banner {
|
||||||
|
background: #252526;
|
||||||
|
border-color: #3e3e42;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast-msg strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: transform var(--dur-fast), filter var(--dur-fast), background var(--dur-fast);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast-close {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--dur-fast), background var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-toast-close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes undoToastIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 24px) scale(0.94);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
.sidebar-title {
|
||||||
@@ -295,9 +459,30 @@ a { color: inherit; text-decoration: none; }
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-w, var(--sidebar-width));
|
||||||
|
min-width: var(--sidebar-w, var(--sidebar-width));
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: width var(--dur-normal) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-box:hover {
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
background: var(--bg-primary) !important;
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card {
|
.user-card {
|
||||||
@@ -307,10 +492,15 @@ a { color: inherit; text-decoration: none; }
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--dur-fast);
|
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card:hover { background: var(--bg-hover); }
|
.user-card:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-color: var(--border);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
|||||||
@@ -299,16 +299,91 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
handleTaskUpdate(updated);
|
handleTaskUpdate(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Undo Toast state
|
||||||
|
const [undoToast, setUndoToast] = useState<{ task: Task; title: string } | null>(null);
|
||||||
|
const undoTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Unified task delete handler with immediate Undo Toast
|
||||||
|
const handleDeleteTaskWithUndo = useCallback((task: Task) => {
|
||||||
|
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
|
||||||
|
|
||||||
|
setUndoToast({ task, title: task.title });
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newTasks = moveToTrashInTree(store.tasks, task.id);
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||||||
|
if (selectedTask?.id === task.id) setSelectedTask(null);
|
||||||
|
} else {
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||||||
|
if (selectedTask?.id === task.id) setSelectedTask(null);
|
||||||
|
fetch(`/api/tasks/${task.id}`, { method: "DELETE" }).catch((e) => console.error(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
undoTimerRef.current = setTimeout(() => {
|
||||||
|
setUndoToast(null);
|
||||||
|
}, 6000);
|
||||||
|
}, [isDemo, selectedTask]);
|
||||||
|
|
||||||
|
// Undo delete
|
||||||
|
const handleUndoDelete = useCallback(async () => {
|
||||||
|
if (!undoToast) return;
|
||||||
|
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
|
||||||
|
const { task } = undoToast;
|
||||||
|
setUndoToast(null);
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
const store = getDemoStore();
|
||||||
|
const newTasks = restoreTaskInTree(store.tasks, task.id);
|
||||||
|
saveDemoStore(store.lists, newTasks);
|
||||||
|
if (task.listId === selectedListId) {
|
||||||
|
setTasks((prev) => {
|
||||||
|
if (prev.some((t) => t.id === task.id)) return prev;
|
||||||
|
return [...prev, { ...task, isDeleted: false, deletedAt: null }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: task.title,
|
||||||
|
listId: task.listId,
|
||||||
|
parentId: task.parentId,
|
||||||
|
note: task.note,
|
||||||
|
completed: task.completed,
|
||||||
|
dueDate: task.dueDate,
|
||||||
|
priority: task.priority,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const created = await res.json();
|
||||||
|
if (created.listId === selectedListId) {
|
||||||
|
setTasks((prev) => [...prev, created]);
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to restore task", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [undoToast, isDemo, selectedListId, refresh]);
|
||||||
|
|
||||||
// Move to Trash (Soft Delete)
|
// Move to Trash (Soft Delete)
|
||||||
const handleDemoDeleteTask = (id: string) => {
|
const handleDemoDeleteTask = (id: string) => {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const newTasks = moveToTrashInTree(store.tasks, id);
|
const target = findTaskInTree(store.tasks, id);
|
||||||
saveDemoStore(store.lists, newTasks);
|
if (target) {
|
||||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
handleDeleteTaskWithUndo(target as Task);
|
||||||
if (selectedTask?.id === id) {
|
} else {
|
||||||
setSelectedTask(null);
|
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
|
// Restore from Trash
|
||||||
@@ -408,6 +483,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
onTagSelect={handleTagSelect}
|
onTagSelect={handleTagSelect}
|
||||||
isTrashActive={isTrashActive}
|
isTrashActive={isTrashActive}
|
||||||
onTrashSelect={handleTrashSelect}
|
onTrashSelect={handleTrashSelect}
|
||||||
|
trashCount={isDemo && typeof window !== "undefined" ? getAllTrashTasks(getDemoStore().tasks).length : undefined}
|
||||||
mobileOpen={sidebarOpen}
|
mobileOpen={sidebarOpen}
|
||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
@@ -435,7 +511,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
|
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
<TaskList
|
<TaskList
|
||||||
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
|
key={`${selectedListId}-${isTrashActive}-${selectedTag}`}
|
||||||
user={user}
|
user={user}
|
||||||
listId={selectedListId}
|
listId={selectedListId}
|
||||||
lists={lists}
|
lists={lists}
|
||||||
@@ -457,6 +533,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
onDemoToggleTask={handleDemoToggleTask}
|
onDemoToggleTask={handleDemoToggleTask}
|
||||||
onUpdateTaskTitle={handleUpdateTaskTitle}
|
onUpdateTaskTitle={handleUpdateTaskTitle}
|
||||||
onUpdateListName={handleUpdateListName}
|
onUpdateListName={handleUpdateListName}
|
||||||
|
onDeleteTaskWithUndo={handleDeleteTaskWithUndo}
|
||||||
onDeleteTask={isDemo ? handleDemoDeleteTask : async (id: string) => {
|
onDeleteTask={isDemo ? handleDemoDeleteTask : async (id: string) => {
|
||||||
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
||||||
refresh();
|
refresh();
|
||||||
@@ -524,6 +601,34 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
onSelectTask={handleTaskSelect}
|
onSelectTask={handleTaskSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Interactive Undo Toast Banner */}
|
||||||
|
{undoToast && (
|
||||||
|
<div className="undo-toast-banner" role="alert">
|
||||||
|
<div className="undo-toast-content">
|
||||||
|
<span className="undo-toast-icon">🗑️</span>
|
||||||
|
<span className="undo-toast-msg">
|
||||||
|
<strong>{undoToast.title || "할 일"}</strong>이(가) 삭제되었습니다.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="undo-toast-actions">
|
||||||
|
<button type="button" className="undo-toast-btn" onClick={handleUndoDelete}>
|
||||||
|
실행 취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="undo-toast-close"
|
||||||
|
onClick={() => {
|
||||||
|
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
|
||||||
|
setUndoToast(null);
|
||||||
|
}}
|
||||||
|
title="닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile FAB */}
|
{/* Mobile FAB */}
|
||||||
<button
|
<button
|
||||||
className="fab"
|
className="fab"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface SidebarProps {
|
|||||||
onTagSelect: (tag: string | null) => void;
|
onTagSelect: (tag: string | null) => void;
|
||||||
isTrashActive: boolean;
|
isTrashActive: boolean;
|
||||||
onTrashSelect: () => void;
|
onTrashSelect: () => void;
|
||||||
|
trashCount?: number;
|
||||||
mobileOpen: boolean;
|
mobileOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
@@ -48,6 +49,7 @@ export function Sidebar({
|
|||||||
onTagSelect,
|
onTagSelect,
|
||||||
isTrashActive,
|
isTrashActive,
|
||||||
onTrashSelect,
|
onTrashSelect,
|
||||||
|
trashCount: externalTrashCount,
|
||||||
mobileOpen,
|
mobileOpen,
|
||||||
onClose: _onClose,
|
onClose: _onClose,
|
||||||
isDemo = false,
|
isDemo = false,
|
||||||
@@ -73,7 +75,7 @@ export function Sidebar({
|
|||||||
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
|
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
||||||
const [trashCount, setTrashCount] = useState(() => {
|
const [internalTrashCount, setInternalTrashCount] = useState(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
return getAllTrashTasks(store.tasks).length;
|
return getAllTrashTasks(store.tasks).length;
|
||||||
@@ -81,6 +83,8 @@ export function Sidebar({
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount;
|
||||||
|
|
||||||
// Context Menu for Lists
|
// Context Menu for Lists
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
|
||||||
|
|
||||||
@@ -91,11 +95,6 @@ export function Sidebar({
|
|||||||
const [renamingListId, setRenamingListId] = useState<string | null>(null);
|
const [renamingListId, setRenamingListId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -103,9 +102,11 @@ export function Sidebar({
|
|||||||
setTags(getCustomTags());
|
setTags(getCustomTags());
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const trashed = getAllTrashTasks(store.tasks);
|
const trashed = getAllTrashTasks(store.tasks);
|
||||||
setTrashCount(trashed.length);
|
setInternalTrashCount(trashed.length);
|
||||||
}, [lists]);
|
}, [lists]);
|
||||||
|
|
||||||
|
const initialListSelectedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDemo) {
|
if (!isDemo) {
|
||||||
fetch("/api/lists")
|
fetch("/api/lists")
|
||||||
@@ -113,23 +114,20 @@ export function Sidebar({
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setLists(data);
|
setLists(data);
|
||||||
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
|
if (!initialListSelectedRef.current && !selectedListId && !isTrashActive && !selectedTag && data.length > 0) {
|
||||||
|
initialListSelectedRef.current = true;
|
||||||
|
onListSelect(data[0].id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => console.error("Failed to load lists", err));
|
.catch((err) => console.error("Failed to load lists", err));
|
||||||
}
|
}
|
||||||
}, [isDemo, onListSelect, selectedListId, setLists]);
|
}, [isDemo, onListSelect, selectedListId, isTrashActive, selectedTag, setLists]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
}, [showNewList]);
|
}, [showNewList]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchExpanded) {
|
|
||||||
setTimeout(() => searchInputRef.current?.focus(), 60);
|
|
||||||
}
|
|
||||||
}, [searchExpanded]);
|
|
||||||
|
|
||||||
// Dismiss undo toast on unmount
|
// Dismiss undo toast on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -422,91 +420,41 @@ export function Sidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
|
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
|
||||||
{/* Header with Logo & Controls */}
|
{/* Brand Header with Logo & Controls */}
|
||||||
<div className="sidebar-header" style={{ padding: "12px 12px 8px", gap: 6 }}>
|
<div className="sidebar-header" style={{ padding: "14px 14px 10px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
|
||||||
<div className="sidebar-logo">✓</div>
|
<div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
|
||||||
<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
|
<div
|
||||||
|
className="sidebar-brand-logo"
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
background: "linear-gradient(135deg, var(--accent) 0%, #3a62d0 100%)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
transition: "all var(--dur-normal) var(--ease-out)",
|
justifyContent: "center",
|
||||||
}}
|
boxShadow: "0 2px 6px rgba(75, 123, 245, 0.3)",
|
||||||
onMouseEnter={() => setSearchExpanded(true)}
|
flexShrink: 0,
|
||||||
onMouseLeave={() => {
|
|
||||||
if (!searchQuery) setSearchExpanded(false);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{searchExpanded ? (
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ffffff" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<div
|
<polyline points="20 6 9 17 4 12" />
|
||||||
style={{
|
</svg>
|
||||||
display: "flex",
|
</div>
|
||||||
alignItems: "center",
|
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
gap: 6,
|
<span className="sidebar-title" style={{ fontSize: 15, fontWeight: 700, letterSpacing: "-0.3px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
background: "var(--bg-primary)",
|
{t("appName")}
|
||||||
border: "1.5px solid var(--accent)",
|
</span>
|
||||||
borderRadius: "var(--radius-sm)",
|
{isDemo && (
|
||||||
padding: "3px 8px",
|
<span style={{ fontSize: 9.5, color: "var(--accent)", fontWeight: 700, letterSpacing: "0.4px", marginTop: -2 }}>
|
||||||
animation: "expandSearch var(--dur-normal) var(--ease-out)",
|
{t("demoBadge")}
|
||||||
width: 180,
|
</span>
|
||||||
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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Controls: Language & Theme with Clear Spacing */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
|
|
||||||
{/* Theme 3-Way Toggle Button */}
|
{/* Theme 3-Way Toggle Button */}
|
||||||
@@ -515,7 +463,7 @@ export function Sidebar({
|
|||||||
id="theme-toggle"
|
id="theme-toggle"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
|
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
|
||||||
style={{ width: 28, height: 28, flexShrink: 0 }}
|
style={{ width: 28, height: 28, flexShrink: 0, borderRadius: "var(--radius-sm)" }}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{theme === "system" ? (
|
{theme === "system" ? (
|
||||||
@@ -539,6 +487,51 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Centered Minimal Search Bar (Between Header and Lists) */}
|
||||||
|
<div style={{ padding: "4px 12px 10px" }}>
|
||||||
|
<div
|
||||||
|
className="sidebar-search-box"
|
||||||
|
id="quick-search-btn"
|
||||||
|
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all var(--dur-fast)",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
title={`${t("searchPlaceholder")} (Ctrl+K)`}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 7, minWidth: 0 }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.2">
|
||||||
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: 12.5, color: "var(--text-tertiary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{t("searchPlaceholder")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<kbd
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-tertiary)",
|
||||||
|
padding: "1px 5px",
|
||||||
|
borderRadius: "var(--radius-xs)",
|
||||||
|
lineHeight: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ctrl K
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Nav List */}
|
{/* Nav List */}
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{/* Lists Section */}
|
{/* Lists Section */}
|
||||||
@@ -739,6 +732,19 @@ export function Sidebar({
|
|||||||
{t("exportTasks")}
|
{t("exportTasks")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
className="dropdown-item"
|
||||||
|
id="sidebar-admin-menu-item"
|
||||||
|
style={{ textDecoration: "none", color: "inherit" }}
|
||||||
|
onClick={() => setUserMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||||
|
</svg>
|
||||||
|
{t("admin") || "Admin Console"}
|
||||||
|
</a>
|
||||||
|
|
||||||
<div className="dropdown-divider" />
|
<div className="dropdown-divider" />
|
||||||
|
|
||||||
{isDemo ? (
|
{isDemo ? (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useUserPrefs } from "@/lib/useUserPrefs";
|
|||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
user: { id: string; name?: string | null; email?: string | null };
|
user: { id: string; name?: string | null; email?: string | null; role?: string | null };
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ function PrefSlider({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal({ isOpen, onClose, user, isDemo: _isDemo = false }: SettingsModalProps) {
|
export function SettingsModal({ isOpen, onClose, user, isDemo = false }: SettingsModalProps) {
|
||||||
const { t, lang, setLang } = useI18n();
|
const { t, lang, setLang } = useI18n();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
|
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
|
||||||
@@ -514,10 +514,10 @@ export function SettingsModal({ isOpen, onClose, user, isDemo: _isDemo = false }
|
|||||||
{activeTab === "sync" && (
|
{activeTab === "sync" && (
|
||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content">
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>
|
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>
|
||||||
📱 {t("syncTitle") || "Galaxy & External Sync (CalDAV)"}
|
📱 {t("syncTitle") || "CalDAV & Mobile Sync (Android / Apple)"}
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 14 }}>
|
<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."}
|
{t("syncDesc") || "CheckFlow supports native two-way synchronization with Android, Apple Reminders, and Thunderbird via standard CalDAV."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Endpoint bar & Action buttons */}
|
{/* Endpoint bar & Action buttons */}
|
||||||
@@ -583,9 +583,9 @@ export function SettingsModal({ isOpen, onClose, user, isDemo: _isDemo = false }
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={syncPlatform}
|
value={syncPlatform}
|
||||||
options={[
|
options={[
|
||||||
{ label: `🤖 ${t("syncTabAndroid") || "Galaxy / DAVx⁵"}`, value: "android" },
|
{ label: `🤖 ${t("syncTabAndroid") || "Android (DAVx⁵)"}`, value: "android" },
|
||||||
{ label: `🍎 ${t("syncTabApple") || "Apple Reminders"}`, value: "apple" },
|
{ label: `🍎 ${t("syncTabApple") || "Apple Reminders"}`, value: "apple" },
|
||||||
{ label: `💻 ${t("syncTabThunderbird") || "Thunderbird"}`, value: "thunderbird" },
|
{ label: `🦅 ${t("syncTabThunderbird") || "Thunderbird"}`, value: "thunderbird" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => setSyncPlatform(v)}
|
onChange={(v) => setSyncPlatform(v)}
|
||||||
/>
|
/>
|
||||||
@@ -606,7 +606,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo: _isDemo = false }
|
|||||||
{syncPlatform === "android" && (
|
{syncPlatform === "android" && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
|
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
|
||||||
📱 Samsung Galaxy & Android (DAVx⁵ + Reminder / OpenTasks)
|
📱 Android (DAVx⁵ + Tasks.org / OpenTasks)
|
||||||
</div>
|
</div>
|
||||||
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
|
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
<li>{t("syncAndroidStep1")}</li>
|
<li>{t("syncAndroidStep1")}</li>
|
||||||
@@ -660,13 +660,74 @@ export function SettingsModal({ isOpen, onClose, user, isDemo: _isDemo = false }
|
|||||||
{/* Tab 4: Admin Quick Access */}
|
{/* Tab 4: Admin Quick Access */}
|
||||||
{activeTab === "admin" && (
|
{activeTab === "admin" && (
|
||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content">
|
||||||
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>👑 Multi-User Admin Console</h3>
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
|
||||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 14 }}>
|
<span style={{ fontSize: 24 }}>👑</span>
|
||||||
Manage registered users, user roles (User/Admin), system statistics, and storage allocations.
|
<div>
|
||||||
</p>
|
<h3 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>
|
||||||
<a href="/admin" className="btn btn-primary" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
{t("admin") || "Admin Console"}
|
||||||
🚀 Open Admin Dashboard →
|
</h3>
|
||||||
</a>
|
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
|
||||||
|
Multi-user administration, account control, and system telemetry
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
padding: 16,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
marginBottom: 16,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ color: "var(--text-primary)" }}>Current Account:</strong>{" "}
|
||||||
|
<code style={{ background: "var(--bg-primary)", padding: "2px 6px", borderRadius: 4, border: "1px solid var(--border)" }}>
|
||||||
|
{user.email || "demo@checkflow.local"}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: "rgba(75, 123, 245, 0.15)",
|
||||||
|
color: "var(--accent)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.role || (isDemo ? "DEMO_ADMIN" : "USER")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginTop: 12 }}>
|
||||||
|
<div style={{ background: "var(--bg-primary)", padding: "10px 12px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border)" }}>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Isolation Mode</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--success)" }}>● Multi-User Privacy</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "var(--bg-primary)", padding: "10px 12px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border)" }}>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>CalDAV Endpoint</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--accent)" }}>● Active / Enabled</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 8, textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
🚀 Open Admin Dashboard →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ interface MarkdownNoteEditorProps {
|
|||||||
|
|
||||||
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
|
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [mode, setMode] = useState<"edit" | "preview">("edit");
|
// Default to preview mode as requested
|
||||||
|
const [mode, setMode] = useState<"edit" | "preview">("preview");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -101,27 +102,34 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
{t("notes").toUpperCase()}
|
{t("notes").toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="view-switcher-group" style={{ padding: 2 }}>
|
{/* Minimal Matte One-touch Mode Toggle */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`view-switcher-btn${mode === "edit" ? " active" : ""}`}
|
className="btn btn-ghost btn-sm"
|
||||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
style={{
|
||||||
onClick={() => {
|
fontSize: 11.5,
|
||||||
|
padding: "2px 8px",
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: mode === "edit" ? "var(--accent-light)" : "var(--bg-secondary)",
|
||||||
|
color: mode === "edit" ? "var(--accent)" : "var(--text-secondary)",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all var(--dur-fast)",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (mode === "preview") {
|
||||||
setMode("edit");
|
setMode("edit");
|
||||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||||
}}
|
} else {
|
||||||
>
|
setMode("preview");
|
||||||
✏️ Edit
|
}
|
||||||
</button>
|
}}
|
||||||
<button
|
title={mode === "preview" ? "Switch to Edit" : "Switch to Preview"}
|
||||||
type="button"
|
>
|
||||||
className={`view-switcher-btn${mode === "preview" ? " active" : ""}`}
|
{mode === "preview" ? "✏️ Edit" : "👁️ Preview"}
|
||||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
</button>
|
||||||
onClick={() => setMode("preview")}
|
|
||||||
>
|
|
||||||
👁️ Preview
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor / Preview Area */}
|
{/* Editor / Preview Area */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
import { ContextMenu } from "@/components/ui/ContextMenu";
|
import { ContextMenu } from "@/components/ui/ContextMenu";
|
||||||
import { KanbanView } from "./KanbanView";
|
import { KanbanView } from "./KanbanView";
|
||||||
import { useUserPrefs } from "@/lib/useUserPrefs";
|
import { useUserPrefs } from "@/lib/useUserPrefs";
|
||||||
|
import { getDemoStore, saveDemoStore, MockTask } from "@/lib/mockData";
|
||||||
|
|
||||||
export interface Tag { id: string; name: string; color: string }
|
export interface Tag { id: string; name: string; color: string }
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ interface TaskItemProps {
|
|||||||
isTrashMode?: boolean;
|
isTrashMode?: boolean;
|
||||||
onRestore?: (id: string) => void;
|
onRestore?: (id: string) => void;
|
||||||
onPermanentDelete?: (id: string) => void;
|
onPermanentDelete?: (id: string) => void;
|
||||||
|
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
|
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
|
||||||
@@ -58,6 +60,7 @@ function RecursiveTaskItem({
|
|||||||
isTrashMode = false,
|
isTrashMode = false,
|
||||||
onRestore,
|
onRestore,
|
||||||
onPermanentDelete,
|
onPermanentDelete,
|
||||||
|
onDragTask,
|
||||||
}: TaskItemProps) {
|
}: TaskItemProps) {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
@@ -154,11 +157,21 @@ function RecursiveTaskItem({
|
|||||||
touchStartX.current = null;
|
touchStartX.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}${dragOverPos ? ` drag-over-${dragOverPos}` : ""}`}
|
||||||
onClick={() => onSelect(task)}
|
onClick={() => onSelect(task)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "F2" && !isTrashMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingTitle(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
@@ -167,6 +180,24 @@ function RecursiveTaskItem({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onContextMenu(e.clientX, e.clientY, task);
|
onContextMenu(e.clientX, e.clientY, task);
|
||||||
}}
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (isTrashMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const mid = rect.top + rect.height / 2;
|
||||||
|
setDragOverPos(e.clientY < mid ? "top" : "bottom");
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOverPos(null)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (isTrashMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const draggedId = e.dataTransfer.getData("text/plain");
|
||||||
|
if (draggedId && draggedId !== task.id && onDragTask) {
|
||||||
|
onDragTask(draggedId, task.id, dragOverPos === "top" ? "before" : "after");
|
||||||
|
}
|
||||||
|
setDragOverPos(null);
|
||||||
|
}}
|
||||||
id={`task-${task.id}`}
|
id={`task-${task.id}`}
|
||||||
style={{
|
style={{
|
||||||
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
|
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
|
||||||
@@ -175,6 +206,31 @@ function RecursiveTaskItem({
|
|||||||
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Notion-style 6-dot Drag Handle */}
|
||||||
|
{!isTrashMode && (
|
||||||
|
<div
|
||||||
|
className="task-drag-handle"
|
||||||
|
draggable="true"
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.setData("text/plain", task.id);
|
||||||
|
e.dataTransfer.setData("application/task-id", task.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
title="Drag to reorder"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<circle cx="5" cy="3" r="1.5" />
|
||||||
|
<circle cx="11" cy="3" r="1.5" />
|
||||||
|
<circle cx="5" cy="8" r="1.5" />
|
||||||
|
<circle cx="11" cy="8" r="1.5" />
|
||||||
|
<circle cx="5" cy="13" r="1.5" />
|
||||||
|
<circle cx="11" cy="13" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Toggle Expand Arrow if has children */}
|
{/* Toggle Expand Arrow if has children */}
|
||||||
{totalChildren > 0 ? (
|
{totalChildren > 0 ? (
|
||||||
<button
|
<button
|
||||||
@@ -246,7 +302,7 @@ function RecursiveTaskItem({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditingTitle(true);
|
setEditingTitle(true);
|
||||||
}}
|
}}
|
||||||
title="Double click to edit"
|
title="Double click or press F2 to edit"
|
||||||
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
|
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
|
||||||
>
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
@@ -310,23 +366,6 @@ function RecursiveTaskItem({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Inline Add Subtask button */}
|
|
||||||
{!isTrashMode && (
|
|
||||||
<button
|
|
||||||
className="badge badge-neutral"
|
|
||||||
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setAddingSubtask(true);
|
|
||||||
setExpanded(true);
|
|
||||||
}}
|
|
||||||
title="Add subtask"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
+ {t("subtasks")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.note && !task.completed && depth === 0 && (
|
{task.note && !task.completed && depth === 0 && (
|
||||||
@@ -334,9 +373,9 @@ function RecursiveTaskItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trash Mode Actions or Edit Icon */}
|
{/* Right Actions: Subtask Add Button & Trash Mode Controls */}
|
||||||
{isTrashMode ? (
|
{isTrashMode ? (
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<div style={{ display: "flex", gap: 6, marginLeft: "auto" }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||||
@@ -363,20 +402,27 @@ function RecursiveTaskItem({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="task-actions-right">
|
||||||
className="icon-btn"
|
<button
|
||||||
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }}
|
className="badge badge-neutral"
|
||||||
onClick={(e) => {
|
style={{
|
||||||
e.stopPropagation();
|
cursor: "pointer",
|
||||||
setEditingTitle(true);
|
fontSize: 11,
|
||||||
}}
|
padding: "2px 8px",
|
||||||
title="Edit title"
|
border: "1px solid var(--border)",
|
||||||
type="button"
|
background: "var(--bg-secondary)",
|
||||||
>
|
}}
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
onClick={(e) => {
|
||||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
e.stopPropagation();
|
||||||
</svg>
|
setAddingSubtask(true);
|
||||||
</button>
|
setExpanded(true);
|
||||||
|
}}
|
||||||
|
title="Add subtask"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+ {t("subtasks")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -397,6 +443,7 @@ function RecursiveTaskItem({
|
|||||||
isTrashMode={isTrashMode}
|
isTrashMode={isTrashMode}
|
||||||
onRestore={onRestore}
|
onRestore={onRestore}
|
||||||
onPermanentDelete={onPermanentDelete}
|
onPermanentDelete={onPermanentDelete}
|
||||||
|
onDragTask={onDragTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -460,6 +507,7 @@ interface Props {
|
|||||||
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
||||||
onUpdateTaskTitle?: (id: string, title: string) => void;
|
onUpdateTaskTitle?: (id: string, title: string) => void;
|
||||||
onUpdateListName?: (id: string, name: string) => void;
|
onUpdateListName?: (id: string, name: string) => void;
|
||||||
|
onDeleteTaskWithUndo?: (task: Task) => void;
|
||||||
onDeleteTask?: (id: string) => void;
|
onDeleteTask?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +533,7 @@ export function TaskList({
|
|||||||
onDemoToggleTask,
|
onDemoToggleTask,
|
||||||
onUpdateTaskTitle,
|
onUpdateTaskTitle,
|
||||||
onUpdateListName,
|
onUpdateListName,
|
||||||
|
onDeleteTaskWithUndo,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -496,11 +545,12 @@ export function TaskList({
|
|||||||
|
|
||||||
const { prefs, updatePrefs } = useUserPrefs();
|
const { prefs, updatePrefs } = useUserPrefs();
|
||||||
// Only show kanban if the Labs flag is explicitly enabled
|
// Only show kanban if the Labs flag is explicitly enabled
|
||||||
const kanbanEnabled = prefs.labs?.kanbanBoard ?? false;
|
const kanbanEnabled = Boolean(prefs.labs?.kanbanBoard);
|
||||||
const viewMode = kanbanEnabled ? prefs.viewMode : "list";
|
const viewMode = kanbanEnabled ? (prefs.viewMode || "list") : "list";
|
||||||
|
|
||||||
const handleViewModeChange = (mode: "list" | "kanban") => {
|
const handleToggleViewMode = () => {
|
||||||
updatePrefs({ viewMode: mode });
|
const nextMode = viewMode === "kanban" ? "list" : "kanban";
|
||||||
|
updatePrefs({ viewMode: nextMode });
|
||||||
};
|
};
|
||||||
|
|
||||||
// TickTick-style Quick Add Preset states
|
// TickTick-style Quick Add Preset states
|
||||||
@@ -551,8 +601,35 @@ export function TaskList({
|
|||||||
return () => document.removeEventListener("checkflow:addTask", handler);
|
return () => document.removeEventListener("checkflow:addTask", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Recursive update helper for local tree optimistic state
|
||||||
|
const updateTaskCompletedInTree = (tree: Task[], targetId: string, isComp: boolean): Task[] => {
|
||||||
|
return tree.map((node) => {
|
||||||
|
if (node.id === targetId) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
completed: isComp,
|
||||||
|
completedAt: isComp ? new Date().toISOString() : null,
|
||||||
|
children: node.children ? updateTaskCompletedInTree(node.children, targetId, isComp) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: updateTaskCompletedInTree(node.children, targetId, isComp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
async (id: string, completed: boolean) => {
|
async (id: string, completed: boolean) => {
|
||||||
|
// 1. Optimistic UI update immediately
|
||||||
|
setTasks((prev) => {
|
||||||
|
const updated = updateTaskCompletedInTree(prev, id, completed);
|
||||||
|
return showCompleted ? updated : updated.filter((t) => t.id !== id || !completed);
|
||||||
|
});
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
if (onDemoToggleTask) onDemoToggleTask(id, completed);
|
if (onDemoToggleTask) onDemoToggleTask(id, completed);
|
||||||
return;
|
return;
|
||||||
@@ -568,9 +645,11 @@ export function TaskList({
|
|||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TaskList] handleToggle failed", err);
|
console.error("[TaskList] handleToggle failed", err);
|
||||||
|
// Rollback on failure
|
||||||
|
fetchTasks();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDemo, onDemoToggleTask, onRefresh]
|
[isDemo, onDemoToggleTask, onRefresh, showCompleted, setTasks, fetchTasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddTask = async (e: React.FormEvent) => {
|
const handleAddTask = async (e: React.FormEvent) => {
|
||||||
@@ -609,8 +688,43 @@ export function TaskList({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const insertSubtaskInTree = (tree: Task[], pId: string, subtask: Task): Task[] => {
|
||||||
|
return tree.map((node) => {
|
||||||
|
if (node.id === pId) {
|
||||||
|
const currentChildren = node.children || [];
|
||||||
|
return { ...node, children: [...currentChildren, subtask] };
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return { ...node, children: insertSubtaskInTree(node.children, pId, subtask) };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddSubtaskInline = async (title: string, parentId: string) => {
|
const handleAddSubtaskInline = async (title: string, parentId: string) => {
|
||||||
if (!listId) return;
|
if (!listId) return;
|
||||||
|
|
||||||
|
const tempId = "temp-subtask-" + Date.now();
|
||||||
|
const tempSubtask: Task = {
|
||||||
|
id: tempId,
|
||||||
|
listId,
|
||||||
|
parentId,
|
||||||
|
title: title.trim(),
|
||||||
|
note: null,
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
dueDate: null,
|
||||||
|
priority: 0,
|
||||||
|
sortOrder: 999,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistic insert
|
||||||
|
setTasks((prev) => insertSubtaskInTree(prev, parentId, tempSubtask));
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
if (onDemoAddTask) onDemoAddTask(title, listId, parentId);
|
if (onDemoAddTask) onDemoAddTask(title, listId, parentId);
|
||||||
return;
|
return;
|
||||||
@@ -623,10 +737,27 @@ export function TaskList({
|
|||||||
body: JSON.stringify({ title, listId, parentId }),
|
body: JSON.stringify({ title, listId, parentId }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const createdTask = await res.json();
|
||||||
|
// Replace temp subtask with actual created task
|
||||||
|
setTasks((prev) => {
|
||||||
|
const replaceTemp = (nodes: Task[]): Task[] => {
|
||||||
|
return nodes.map((n) => {
|
||||||
|
if (n.id === tempId) return createdTask;
|
||||||
|
if (n.children && n.children.length > 0) {
|
||||||
|
return { ...n, children: replaceTemp(n.children) };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return replaceTemp(prev);
|
||||||
|
});
|
||||||
onRefresh();
|
onRefresh();
|
||||||
|
} else {
|
||||||
|
fetchTasks();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to add subtask inline", err);
|
console.error("Failed to add subtask inline", err);
|
||||||
|
fetchTasks();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -640,6 +771,78 @@ export function TaskList({
|
|||||||
setEditingHeader(false);
|
setEditingHeader(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tree-aware reordering function that supports reordering both top-level tasks and nested subtasks
|
||||||
|
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => {
|
||||||
|
setTasks((prev) => {
|
||||||
|
// Helper to find and remove a node from the tree
|
||||||
|
let extractedNode: Task | null = null;
|
||||||
|
|
||||||
|
const removeNode = (nodes: Task[]): Task[] => {
|
||||||
|
const result: Task[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === draggedId) {
|
||||||
|
extractedNode = node;
|
||||||
|
} else {
|
||||||
|
const updatedNode = { ...node };
|
||||||
|
if (updatedNode.children && updatedNode.children.length > 0) {
|
||||||
|
updatedNode.children = removeNode(updatedNode.children);
|
||||||
|
}
|
||||||
|
result.push(updatedNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const treeWithoutDragged = removeNode(prev);
|
||||||
|
if (!extractedNode) return prev;
|
||||||
|
|
||||||
|
// Helper to insert the extracted node adjacent to targetId in the same list/level
|
||||||
|
const insertNode = (nodes: Task[]): { list: Task[]; inserted: boolean } => {
|
||||||
|
const idx = nodes.findIndex((n) => n.id === targetId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const newNodes = [...nodes];
|
||||||
|
const insertIdx = position === "before" ? idx : idx + 1;
|
||||||
|
// Maintain target's parentId for the dragged node
|
||||||
|
const adjustedNode = { ...extractedNode!, parentId: nodes[idx].parentId };
|
||||||
|
newNodes.splice(insertIdx, 0, adjustedNode);
|
||||||
|
return { list: newNodes, inserted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasInserted = false;
|
||||||
|
const newNodes = nodes.map((node) => {
|
||||||
|
if (!hasInserted && node.children && node.children.length > 0) {
|
||||||
|
const res = insertNode(node.children);
|
||||||
|
if (res.inserted) {
|
||||||
|
hasInserted = true;
|
||||||
|
return { ...node, children: res.list };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { list: newNodes, inserted: hasInserted };
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalTree = insertNode(treeWithoutDragged).list;
|
||||||
|
|
||||||
|
// Update demo store or backend sort orders
|
||||||
|
if (isDemo && typeof window !== "undefined") {
|
||||||
|
const store = getDemoStore();
|
||||||
|
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalTree;
|
||||||
|
});
|
||||||
|
}, [isDemo, setTasks]);
|
||||||
|
|
||||||
|
const handleDeleteTask = useCallback((task: Task) => {
|
||||||
|
if (onDeleteTaskWithUndo) {
|
||||||
|
onDeleteTaskWithUndo(task);
|
||||||
|
} else if (onDeleteTask) {
|
||||||
|
onDeleteTask(task.id);
|
||||||
|
}
|
||||||
|
}, [onDeleteTaskWithUndo, onDeleteTask]);
|
||||||
|
|
||||||
if (!listId && !isTrashActive && !selectedTag) {
|
if (!listId && !isTrashActive && !selectedTag) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
||||||
@@ -722,26 +925,33 @@ export function TaskList({
|
|||||||
|
|
||||||
{/* Header Actions */}
|
{/* Header Actions */}
|
||||||
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
{/* View Switcher: List vs Kanban (Hidden in Trash/Tag mode) */}
|
{/* View Switcher: Minimal & Matte Single Toggle (Only shown when Kanban is enabled in Labs) */}
|
||||||
{!isTrashActive && !selectedTag && (
|
{!isTrashActive && !selectedTag && kanbanEnabled && (
|
||||||
<div className="view-switcher-group">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className={`view-switcher-toggle-btn${viewMode === "kanban" ? " active" : ""}`}
|
||||||
className={`view-switcher-btn${viewMode === "list" ? " active" : ""}`}
|
onClick={handleToggleViewMode}
|
||||||
onClick={() => handleViewModeChange("list")}
|
title={viewMode === "kanban" ? `${t("listView")} (Switch to List)` : `${t("kanbanView")} (Switch to Kanban)`}
|
||||||
title={t("listView")}
|
style={{
|
||||||
>
|
display: "inline-flex",
|
||||||
📋 <span className="desktop-only">{t("listView")}</span>
|
alignItems: "center",
|
||||||
</button>
|
gap: 5,
|
||||||
<button
|
padding: "4px 8px",
|
||||||
type="button"
|
fontSize: 12,
|
||||||
className={`view-switcher-btn${viewMode === "kanban" ? " active" : ""}`}
|
fontWeight: 600,
|
||||||
onClick={() => handleViewModeChange("kanban")}
|
background: viewMode === "kanban" ? "var(--accent-light)" : "var(--bg-secondary)",
|
||||||
title={t("kanbanView")}
|
color: viewMode === "kanban" ? "var(--accent)" : "var(--text-secondary)",
|
||||||
>
|
border: "1px solid var(--border)",
|
||||||
📊 <span className="desktop-only">{t("kanbanView")}</span>
|
borderRadius: "var(--radius-sm)",
|
||||||
</button>
|
cursor: "pointer",
|
||||||
</div>
|
transition: "all var(--dur-fast)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{viewMode === "kanban" ? "📊" : "📋"}</span>
|
||||||
|
<span style={{ fontSize: 11.5 }}>
|
||||||
|
{viewMode === "kanban" ? t("kanbanView") : t("listView")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isTrashActive ? (
|
{isTrashActive ? (
|
||||||
@@ -830,6 +1040,7 @@ export function TaskList({
|
|||||||
isTrashMode={isTrashActive}
|
isTrashMode={isTrashActive}
|
||||||
onRestore={onRestoreTask}
|
onRestore={onRestoreTask}
|
||||||
onPermanentDelete={onPermanentDeleteTask}
|
onPermanentDelete={onPermanentDeleteTask}
|
||||||
|
onDragTask={handleReorderTasks}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -862,6 +1073,7 @@ export function TaskList({
|
|||||||
isTrashMode={isTrashActive}
|
isTrashMode={isTrashActive}
|
||||||
onRestore={onRestoreTask}
|
onRestore={onRestoreTask}
|
||||||
onPermanentDelete={onPermanentDeleteTask}
|
onPermanentDelete={onPermanentDeleteTask}
|
||||||
|
onDragTask={handleReorderTasks}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -897,14 +1109,7 @@ export function TaskList({
|
|||||||
label: t("deleteTaskConfirm").split("?")[0],
|
label: t("deleteTaskConfirm").split("?")[0],
|
||||||
icon: "🗑️",
|
icon: "🗑️",
|
||||||
danger: true,
|
danger: true,
|
||||||
onClick: async () => {
|
onClick: () => handleDeleteTask(contextMenu.task),
|
||||||
if (onDeleteTask) {
|
|
||||||
onDeleteTask(contextMenu.task.id);
|
|
||||||
} else if (!isDemo) {
|
|
||||||
await fetch(`/api/tasks/${contextMenu.task.id}`, { method: "DELETE" });
|
|
||||||
onRefresh();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
|
|||||||
+21
-3
@@ -25,10 +25,28 @@ export const authOptions: NextAuthOptions = {
|
|||||||
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
||||||
if (!valid) return null;
|
if (!valid) return null;
|
||||||
|
|
||||||
// Admin identification logic
|
// Admin identification logic:
|
||||||
|
// 1. Explicit admin env match
|
||||||
|
// 2. Email format match (admin@... or ...@checkflow.local)
|
||||||
|
// 3. First-ever registered user in the database is automatically granted ADMIN
|
||||||
const adminEmail = process.env.ADMIN_EMAIL;
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
const isEmailAdmin = adminEmail && user.email === adminEmail;
|
const isEmailAdmin = adminEmail && user.email.toLowerCase() === adminEmail.toLowerCase();
|
||||||
const isAdmin = isEmailAdmin || user.email.endsWith("@checkflow.local") || user.email.startsWith("admin@");
|
const isPrefixAdmin = user.email.endsWith("@checkflow.local") || user.email.toLowerCase().startsWith("admin@");
|
||||||
|
|
||||||
|
let isFirstUser = false;
|
||||||
|
try {
|
||||||
|
const firstUser = await prisma.user.findFirst({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (firstUser && firstUser.id === user.id) {
|
||||||
|
isFirstUser = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[auth] failed to check first user", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = isEmailAdmin || isPrefixAdmin || isFirstUser;
|
||||||
const role = isAdmin ? "ADMIN" : "USER";
|
const role = isAdmin ? "ADMIN" : "USER";
|
||||||
|
|
||||||
return { id: user.id, email: user.email, name: user.name, role };
|
return { id: user.id, email: user.email, name: user.name, role };
|
||||||
|
|||||||
+7
-548
@@ -1,556 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
import { Language, TranslationKey, TranslationDict } from "./types";
|
||||||
|
import { translations, en, ko, ja } from "./locales";
|
||||||
|
|
||||||
export type Language = "en" | "ko" | "ja";
|
export type { Language, TranslationKey, TranslationDict };
|
||||||
|
export { translations, 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 {
|
interface I18nContextType {
|
||||||
lang: Language;
|
lang: Language;
|
||||||
setLang: (l: Language) => void;
|
setLang: (l: Language) => void;
|
||||||
t: (key: TranslationKeys) => string;
|
t: (key: TranslationKey) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextType>({
|
const I18nContext = createContext<I18nContextType>({
|
||||||
@@ -584,8 +43,8 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const t = (key: TranslationKeys): string => {
|
const t = (key: TranslationKey): string => {
|
||||||
const dict = (translations[lang] || translations.en) as Record<string, string>;
|
const dict = translations[lang] || translations.en;
|
||||||
return dict[key] || translations.en[key] || key;
|
return dict[key] || translations.en[key] || key;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { TranslationDict } from "../types";
|
||||||
|
|
||||||
|
export const en: TranslationDict = {
|
||||||
|
// Auth
|
||||||
|
appName: "CheckFlow",
|
||||||
|
tagline: "Your Personal Todo & Markdown Workspace",
|
||||||
|
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 (CalDAV)",
|
||||||
|
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: "CalDAV & Mobile Sync (Android / Apple)",
|
||||||
|
syncDesc: "CheckFlow supports native two-way synchronization with Android, Apple Reminders, and Thunderbird via standard 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: "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 Android Tasks 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.",
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Language, TranslationDict } from "../types";
|
||||||
|
import { en } from "./en";
|
||||||
|
import { ko } from "./ko";
|
||||||
|
import { ja } from "./ja";
|
||||||
|
|
||||||
|
export const translations: Record<Language, TranslationDict> = {
|
||||||
|
en,
|
||||||
|
ko,
|
||||||
|
ja,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { en, ko, ja };
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { TranslationDict } from "../types";
|
||||||
|
|
||||||
|
export const ja: TranslationDict = {
|
||||||
|
// Auth
|
||||||
|
appName: "CheckFlow",
|
||||||
|
tagline: "シンプルで洗練されたパーソナルタスク管理",
|
||||||
|
welcomeBack: "お帰りなさい 👋",
|
||||||
|
signInSubtitle: "メールアドレスでサインインしてください",
|
||||||
|
createAccount: "アカウント新規登録",
|
||||||
|
createAccountSubtitle: "CheckFlowでタスクの整理を始めましょう",
|
||||||
|
displayName: "表示名",
|
||||||
|
email: "メールアドレス",
|
||||||
|
password: "パスワード",
|
||||||
|
min8Chars: "8文字以上",
|
||||||
|
signIn: "サインイン",
|
||||||
|
signingIn: "サインイン中...",
|
||||||
|
createBtn: "アカウントを作成",
|
||||||
|
creatingBtn: "アカウント作成中...",
|
||||||
|
alreadyHaveAccount: "すでにアカウントをお持ちですか?",
|
||||||
|
dontHaveAccount: "アカウントをお持ちでないですか?",
|
||||||
|
tryDemoMode: "体験モードで試す(DB不要)",
|
||||||
|
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: "言語 (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: "Android (DAVx⁵, Tasks.org)、Apple リマインダー、Thunderbird など標準 CalDAV をサポートする各種端末やアプリと双方向同期できます。",
|
||||||
|
syncBaseUrl: "CalDAV サーバー基本 URL",
|
||||||
|
syncCopyUrl: "URLをコピー",
|
||||||
|
syncCopied: "コピー完了!",
|
||||||
|
syncDownloadIcs: ".ICS フィードをダウンロード",
|
||||||
|
syncTestConnection: "接続テスト",
|
||||||
|
syncTesting: "テスト中...",
|
||||||
|
syncTestSuccess: "✓ CalDAV エンドポイントは正常に応答しています (200 OK)",
|
||||||
|
syncTabAndroid: "Android (DAVx⁵)",
|
||||||
|
syncTabApple: "Apple リマインダー (iOS / macOS)",
|
||||||
|
syncTabThunderbird: "Thunderbird (Desktop)",
|
||||||
|
syncAndroidStep1: "Google Play または F-Droid から「DAVx⁵」アプリをインストールします。",
|
||||||
|
syncAndroidStep2: "DAVx⁵ を起動 → アカウント追加(+) →「URL とユーザー名でログイン」を選択します。",
|
||||||
|
syncAndroidStep3: "上記の基本 URL を入力し、CheckFlow のログインメールアドレスとパスワードを入力します。",
|
||||||
|
syncAndroidStep4: "「VTODO (タスク)」コレクションを有効にすると、Tasks.org や 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: "Markdownメモモード",
|
||||||
|
subtaskMode: "チェックリストモード",
|
||||||
|
switchToTextMode: "Markdownメモモードに切り替え",
|
||||||
|
switchToSubtaskMode: "チェックリストモードに切り替え",
|
||||||
|
moveToList: "別のリストに移動",
|
||||||
|
quickAdd: "クイック追加",
|
||||||
|
quickToday: "今日",
|
||||||
|
quickTomorrow: "明日",
|
||||||
|
quickNextWeek: "来週",
|
||||||
|
selectDate: "期日設定",
|
||||||
|
selectPriority: "優先度設定",
|
||||||
|
selectTag: "タグ設定",
|
||||||
|
copied: "コピー完了!",
|
||||||
|
|
||||||
|
// Context Menu
|
||||||
|
editTask: "編集",
|
||||||
|
duplicateTask: "複製",
|
||||||
|
moveTask: "移動…",
|
||||||
|
deleteTask: "削除",
|
||||||
|
renameList: "名前を変更",
|
||||||
|
deleteList: "リストを削除",
|
||||||
|
taskUndoHint: "削除しました。クリックして元に戻せます。",
|
||||||
|
listUndoHint: "リストを削除しました。クリックして元に戻せます。",
|
||||||
|
|
||||||
|
// Labs, Kanban & Customization
|
||||||
|
labs: "Labs (実験室)",
|
||||||
|
labsDesc: "開発中の実験的機能を個別に有効化・無効化できます。",
|
||||||
|
kanbanView: "カンバンボード",
|
||||||
|
listView: "リストビュー",
|
||||||
|
enableKanban: "カンバンボードビュー",
|
||||||
|
enableKanbanDesc: "タスクを3列ボード(未着手 / 進行中 / 完了)形式で管理します。",
|
||||||
|
swapBlocks: "ブロック配置の入れ替え",
|
||||||
|
resetDefaults: "初期設定にリセット",
|
||||||
|
resetConfirm: "すべてのUIカスタマイズを初期デフォルト設定に戻しますか?",
|
||||||
|
todoCol: "未着手",
|
||||||
|
inProgressCol: "進行中",
|
||||||
|
doneCol: "完了",
|
||||||
|
accessRestricted: "アクセス権限制限",
|
||||||
|
adminRequired: "この管理コンソールにはADMIN権限が必要です。",
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { TranslationDict } from "../types";
|
||||||
|
|
||||||
|
export const ko: TranslationDict = {
|
||||||
|
// Auth
|
||||||
|
appName: "CheckFlow",
|
||||||
|
tagline: "나만의 스마트 할 일 & 마크다운 워크스페이스",
|
||||||
|
welcomeBack: "다시 오신 것을 환영합니다 👋",
|
||||||
|
signInSubtitle: "이메일 계정으로 로그인하세요",
|
||||||
|
createAccount: "새 계정 생성",
|
||||||
|
createAccountSubtitle: "CheckFlow에서 효율적인 할 일 관리를 시작하세요",
|
||||||
|
displayName: "사용자 이름",
|
||||||
|
email: "이메일 주소",
|
||||||
|
password: "비밀번호",
|
||||||
|
min8Chars: "8자 이상 입력",
|
||||||
|
signIn: "로그인",
|
||||||
|
signingIn: "로그인 중...",
|
||||||
|
createBtn: "계정 생성",
|
||||||
|
creatingBtn: "계정 생성 중...",
|
||||||
|
alreadyHaveAccount: "이미 계정이 있으신가요?",
|
||||||
|
dontHaveAccount: "아직 계정이 없으신가요?",
|
||||||
|
tryDemoMode: "체험 모드로 둘러보기 (DB 불필요)",
|
||||||
|
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: "언어 (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: "Android(DAVx⁵, Tasks.org), Apple 미리알림, Thunderbird 등 표준 CalDAV를 지원하는 모든 기기 및 앱과 실시간 양방향으로 동기화됩니다.",
|
||||||
|
syncBaseUrl: "CalDAV 기본 엔드포인트 URL",
|
||||||
|
syncCopyUrl: "URL 복사",
|
||||||
|
syncCopied: "복사 완료!",
|
||||||
|
syncDownloadIcs: ".ICS 피드 다운로드",
|
||||||
|
syncTestConnection: "연결 테스트",
|
||||||
|
syncTesting: "연결 확인 중...",
|
||||||
|
syncTestSuccess: "✓ CalDAV 엔드포인트가 정상적으로 응답합니다 (200 OK)",
|
||||||
|
syncTabAndroid: "Android (DAVx⁵)",
|
||||||
|
syncTabApple: "Apple 미리알림 (iOS / macOS)",
|
||||||
|
syncTabThunderbird: "Thunderbird (Desktop)",
|
||||||
|
syncAndroidStep1: "Google Play Store 또는 F-Droid에서 'DAVx⁵' 앱을 설치합니다.",
|
||||||
|
syncAndroidStep2: "DAVx⁵ 실행 → 계정 추가(+) → 'URL 및 사용자 이름으로 로그인'을 선택합니다.",
|
||||||
|
syncAndroidStep3: "위 기본 URL을 입력하고, CheckFlow 계정 이메일과 비밀번호를 입력합니다.",
|
||||||
|
syncAndroidStep4: "'VTODO (할 일)' 컬렉션을 활성화하면 Tasks.org 또는 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\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: "실험실 (Labs)",
|
||||||
|
labsDesc: "실험적인 기능들을 개별적으로 활성화하거나 비활성화할 수 있습니다.",
|
||||||
|
kanbanView: "칸반 보드",
|
||||||
|
listView: "리스트 뷰",
|
||||||
|
enableKanban: "칸반 보드 뷰",
|
||||||
|
enableKanbanDesc: "할 일을 3컬럼 보드(할 일 / 진행 중 / 완료) 형태로 전환하여 관리합니다.",
|
||||||
|
swapBlocks: "상하 블록 위치 변경",
|
||||||
|
resetDefaults: "기본값으로 복원",
|
||||||
|
resetConfirm: "모든 UI 커스텀 설정을 초기 기본값으로 복원하시겠습니까?",
|
||||||
|
todoCol: "할 일",
|
||||||
|
inProgressCol: "진행 중",
|
||||||
|
doneCol: "완료",
|
||||||
|
accessRestricted: "접근 권한 제한",
|
||||||
|
adminRequired: "이 관리 콘솔은 ADMIN 권한이 필요합니다.",
|
||||||
|
};
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
export type Language = "en" | "ko" | "ja";
|
||||||
|
|
||||||
|
export interface TranslationDict {
|
||||||
|
// Auth
|
||||||
|
appName: string;
|
||||||
|
tagline: string;
|
||||||
|
welcomeBack: string;
|
||||||
|
signInSubtitle: string;
|
||||||
|
createAccount: string;
|
||||||
|
createAccountSubtitle: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
min8Chars: string;
|
||||||
|
signIn: string;
|
||||||
|
signingIn: string;
|
||||||
|
createBtn: string;
|
||||||
|
creatingBtn: string;
|
||||||
|
alreadyHaveAccount: string;
|
||||||
|
dontHaveAccount: string;
|
||||||
|
tryDemoMode: string;
|
||||||
|
demoBadge: string;
|
||||||
|
signOut: string;
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
lists: string;
|
||||||
|
newList: string;
|
||||||
|
listNamePlaceholder: string;
|
||||||
|
create: string;
|
||||||
|
cancel: string;
|
||||||
|
save: string;
|
||||||
|
deleteListConfirm: string;
|
||||||
|
undoDelete: string;
|
||||||
|
listDeleted: string;
|
||||||
|
importTasks: string;
|
||||||
|
importModalTitle: string;
|
||||||
|
targetList: string;
|
||||||
|
fileSelectLabel: string;
|
||||||
|
tickTickExportHint: string;
|
||||||
|
importBtn: string;
|
||||||
|
importing: string;
|
||||||
|
exportTasks: string;
|
||||||
|
exportModalTitle: string;
|
||||||
|
exportFormat: string;
|
||||||
|
exportScope: string;
|
||||||
|
allLists: string;
|
||||||
|
includeCompleted: string;
|
||||||
|
exportBtn: string;
|
||||||
|
exportSuccess: string;
|
||||||
|
theme: string;
|
||||||
|
themeSystem: string;
|
||||||
|
themeLight: string;
|
||||||
|
themeDark: string;
|
||||||
|
language: string;
|
||||||
|
tags: string;
|
||||||
|
trash: string;
|
||||||
|
emptyTrash: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
searchExpandedPlaceholder: string;
|
||||||
|
|
||||||
|
// Settings Modal
|
||||||
|
settingsModalTitle: string;
|
||||||
|
profile: string;
|
||||||
|
preferences: string;
|
||||||
|
syncIntegrations: string;
|
||||||
|
admin: string;
|
||||||
|
trashRetention: string;
|
||||||
|
trashRetentionHint: string;
|
||||||
|
days7: string;
|
||||||
|
days14: string;
|
||||||
|
days30: string;
|
||||||
|
neverDelete: string;
|
||||||
|
|
||||||
|
// CalDAV & Sync
|
||||||
|
syncTitle: string;
|
||||||
|
syncDesc: string;
|
||||||
|
syncBaseUrl: string;
|
||||||
|
syncCopyUrl: string;
|
||||||
|
syncCopied: string;
|
||||||
|
syncDownloadIcs: string;
|
||||||
|
syncTestConnection: string;
|
||||||
|
syncTesting: string;
|
||||||
|
syncTestSuccess: string;
|
||||||
|
syncTabAndroid: string;
|
||||||
|
syncTabApple: string;
|
||||||
|
syncTabThunderbird: string;
|
||||||
|
syncAndroidStep1: string;
|
||||||
|
syncAndroidStep2: string;
|
||||||
|
syncAndroidStep3: string;
|
||||||
|
syncAndroidStep4: string;
|
||||||
|
syncAppleStep1: string;
|
||||||
|
syncAppleStep2: string;
|
||||||
|
syncAppleStep3: string;
|
||||||
|
syncAppleStep4: string;
|
||||||
|
syncThunderbirdStep1: string;
|
||||||
|
syncThunderbirdStep2: string;
|
||||||
|
syncThunderbirdStep3: string;
|
||||||
|
syncThunderbirdStep4: string;
|
||||||
|
|
||||||
|
// Task List
|
||||||
|
tasks: string;
|
||||||
|
hideDone: string;
|
||||||
|
showDone: string;
|
||||||
|
completedSection: string;
|
||||||
|
addTaskPlaceholder: string;
|
||||||
|
add: string;
|
||||||
|
noTasksYet: string;
|
||||||
|
selectListToStart: string;
|
||||||
|
loading: string;
|
||||||
|
today: string;
|
||||||
|
tomorrow: string;
|
||||||
|
|
||||||
|
// Task Detail
|
||||||
|
taskTitlePlaceholder: string;
|
||||||
|
priority: string;
|
||||||
|
priorityNone: string;
|
||||||
|
priorityLow: string;
|
||||||
|
priorityMedium: string;
|
||||||
|
priorityHigh: string;
|
||||||
|
dueDate: string;
|
||||||
|
clear: string;
|
||||||
|
notes: string;
|
||||||
|
notesPlaceholder: string;
|
||||||
|
subtasks: string;
|
||||||
|
addSubtaskPlaceholder: string;
|
||||||
|
deleteTaskConfirm: string;
|
||||||
|
autoSaved: string;
|
||||||
|
saving: string;
|
||||||
|
created: string;
|
||||||
|
edited: string;
|
||||||
|
|
||||||
|
// Toolbars
|
||||||
|
bold: string;
|
||||||
|
italic: string;
|
||||||
|
heading: string;
|
||||||
|
bulletList: string;
|
||||||
|
numberedList: string;
|
||||||
|
checkbox: string;
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
// TickTick Mode Switcher & Quick Add
|
||||||
|
textMode: string;
|
||||||
|
subtaskMode: string;
|
||||||
|
switchToTextMode: string;
|
||||||
|
switchToSubtaskMode: string;
|
||||||
|
moveToList: string;
|
||||||
|
quickAdd: string;
|
||||||
|
quickToday: string;
|
||||||
|
quickTomorrow: string;
|
||||||
|
quickNextWeek: string;
|
||||||
|
selectDate: string;
|
||||||
|
selectPriority: string;
|
||||||
|
selectTag: string;
|
||||||
|
copied: string;
|
||||||
|
|
||||||
|
// Context Menu
|
||||||
|
editTask: string;
|
||||||
|
duplicateTask: string;
|
||||||
|
moveTask: string;
|
||||||
|
deleteTask: string;
|
||||||
|
renameList: string;
|
||||||
|
deleteList: string;
|
||||||
|
taskUndoHint: string;
|
||||||
|
listUndoHint: string;
|
||||||
|
|
||||||
|
// Labs, Kanban & Customization
|
||||||
|
labs: string;
|
||||||
|
labsDesc: string;
|
||||||
|
kanbanView: string;
|
||||||
|
listView: string;
|
||||||
|
enableKanban: string;
|
||||||
|
enableKanbanDesc: string;
|
||||||
|
swapBlocks: string;
|
||||||
|
resetDefaults: string;
|
||||||
|
resetConfirm: string;
|
||||||
|
todoCol: string;
|
||||||
|
inProgressCol: string;
|
||||||
|
doneCol: string;
|
||||||
|
accessRestricted: string;
|
||||||
|
adminRequired: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TranslationKey = keyof TranslationDict;
|
||||||
+18
-11
@@ -71,7 +71,7 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
listId: "list-1",
|
listId: "list-1",
|
||||||
parentId: null,
|
parentId: null,
|
||||||
title: "Setup Self-Hosted CheckFlow on NAS",
|
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",
|
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- CalDAV: https://todo.yourdomain.com/api/dav",
|
||||||
completed: false,
|
completed: false,
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
dueDate: new Date(Date.now() + 86400000).toISOString(),
|
dueDate: new Date(Date.now() + 86400000).toISOString(),
|
||||||
@@ -124,7 +124,7 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
|||||||
id: "sub-1-2",
|
id: "sub-1-2",
|
||||||
listId: "list-1",
|
listId: "list-1",
|
||||||
parentId: "task-1",
|
parentId: "task-1",
|
||||||
title: "Test DAVx⁵ sync on Samsung Galaxy",
|
title: "Test CalDAV sync via DAVx⁵ on Android",
|
||||||
note: null,
|
note: null,
|
||||||
completed: false,
|
completed: false,
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
@@ -334,18 +334,25 @@ export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all active tasks or all trash tasks flattened
|
// Get all active tasks or all trash tasks flattened (strictly deduplicated)
|
||||||
export function getAllTrashTasks(tree: MockTask[]): MockTask[] {
|
export function getAllTrashTasks(tree: MockTask[]): MockTask[] {
|
||||||
let trash: MockTask[] = [];
|
const map = new Map<string, MockTask>();
|
||||||
for (const node of tree) {
|
|
||||||
if (node.isDeleted) {
|
function collect(nodes: MockTask[]) {
|
||||||
trash.push(node);
|
for (const node of nodes) {
|
||||||
}
|
if (node.isDeleted) {
|
||||||
if (node.children && node.children.length > 0) {
|
if (!map.has(node.id)) {
|
||||||
trash = trash.concat(getAllTrashTasks(node.children));
|
map.set(node.id, { ...node, children: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
collect(node.children);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return trash;
|
|
||||||
|
collect(tree);
|
||||||
|
return Array.from(map.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter tasks by custom tag
|
// Filter tasks by custom tag
|
||||||
|
|||||||
+2
-14
@@ -8,6 +8,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
// Public paths:
|
// Public paths:
|
||||||
// - /login, /register: Auth pages
|
// - /login, /register: Auth pages
|
||||||
// - /demo: Local preview without DB
|
// - /demo: Local preview without DB
|
||||||
|
// - /admin: Admin page (handles demo preview & auth gate internally)
|
||||||
// - /api/auth: NextAuth endpoints
|
// - /api/auth: NextAuth endpoints
|
||||||
// - /api/dav: DAVx⁵ uses HTTP Basic Auth
|
// - /api/dav: DAVx⁵ uses HTTP Basic Auth
|
||||||
// - Static assets & metadata
|
// - Static assets & metadata
|
||||||
@@ -15,6 +16,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
"/login",
|
"/login",
|
||||||
"/register",
|
"/register",
|
||||||
"/demo",
|
"/demo",
|
||||||
|
"/admin",
|
||||||
"/api/auth",
|
"/api/auth",
|
||||||
"/api/dav",
|
"/api/dav",
|
||||||
"/icons",
|
"/icons",
|
||||||
@@ -43,20 +45,6 @@ export async function middleware(request: NextRequest) {
|
|||||||
return NextResponse.redirect(loginUrl);
|
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();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user