Author SHA1 Message Date
Neru_Han 04fef885a8 feat(i18n,admin): modular localization dictionary & admin console demo preview
Build and Push Docker Image / build-and-push (push) Successful in 13m7s
2026-08-21 20:00:01 +09:00
Neru_Han 94217d773d feat: enhance task item UX with Notion drag handle, F2 edit, and floating undo toast (v0.6.3)
Build and Push Docker Image / build-and-push (push) Canceled after 23s
2026-08-21 19:42:50 +09:00
Neru_Han 5579e71839 fix: resolve Prisma 6 CLI effect dependency in Docker runner and polish Korean/Japanese translations
Build and Push Docker Image / build-and-push (push) Successful in 9m21s
2026-08-21 19:08:52 +09:00
Neru_Han 31f4e900b3 docs: generalize deployment instructions in README for public repository audience
Build and Push Docker Image / build-and-push (push) Canceled after 9m1s
2026-08-21 18:48:50 +09:00
Neru_Han 6555d33e91 docs: clarify standalone 2-file Docker deployment without git clone
Build and Push Docker Image / build-and-push (push) Successful in 10m43s
2026-08-21 18:46:29 +09:00
Neru_Han 59b20d459c chore: update default registry image path in .env.example
Build and Push Docker Image / build-and-push (push) Successful in 10m27s
2026-08-21 18:39:47 +09:00
Neru_Han 309060e4da chore: streamline deployment configuration for Gitea registry images and Watchtower updates
Build and Push Docker Image / build-and-push (push) Canceled after 0s
2026-08-21 18:30:34 +09:00
Neru_Han 3e312f3960 ci: add OCI image source label for automatic Gitea package linkage
Build and Push Docker Image / build-and-push (push) Canceled after 9m14s
2026-08-21 18:23:19 +09:00
Neru_Han 6b81b0d5e7 ci: convert repository name to lowercase for Docker image tags
Build and Push Docker Image / build-and-push (push) Successful in 10m59s
2026-08-21 17:18:38 +09:00
Neru_Han 2ee686f036 docs: overhaul README with modern design philosophy and standardize CalDAV/Android sync guidelines
Build and Push Docker Image / build-and-push (push) Failing after 1m41s
2026-08-21 17:09:56 +09:00
Neru_Han 9beaebb5d8 feat: optimize docker build pipeline with dockerignore, automated entrypoint migrations, and configurable compose image
Build and Push Docker Image / build-and-push (push) Failing after 1m1s
2026-08-21 16:59:24 +09:00
Neru_Han 364ff4c4e8 ci: configure automated docker build workflow via Gitea Actions and repository hygiene
Build and Push Docker Image / build-and-push (push) Failing after 55s
2026-08-21 16:36:38 +09:00
26 changed files with 1654 additions and 821 deletions
+14 -8
View File
@@ -4,26 +4,32 @@
- **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.
- 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 +42,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.
+35
View File
@@ -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
View File
@@ -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
+13 -3
View File
@@ -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 }}
+3
View File
@@ -17,3 +17,6 @@ out/
*.pem *.pem
npm-debug.log* npm-debug.log*
chamgojaryo/ chamgojaryo/
# local memory & ai context
.clinecontext
+5 -3
View File
@@ -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,9 @@ 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 |
--- ---
@@ -119,7 +121,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
View File
@@ -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"]
+218 -84
View File
@@ -1,117 +1,251 @@
# CheckFlow <div align="center">
> 셀프호스팅 TickTick-like Todo 앱 — 계층형 체크리스트 + 메모장 + CardDAV + PWA # ⚡ CheckFlow
![CheckFlow](public/icons/icon.svg) **프라이버시 중심의 독립형 스마트 태스크 & 마크다운 워크스페이스**
*A modern, self-hosted, privacy-first task management & note workspace built for focus, speed, and open-standard freedom.*
## 주요 기능 <br/>
-**계층형 체크리스트** — 메인 태스크 + 하위 태스크 [![Next.js](https://img.shields.io/badge/Next.js-15.x-black?style=flat-square&logo=next.js)](https://nextjs.org/)
- 📝 **태스크 메모장** — 넓은 노트 영역 (Markdown 지원) [![React](https://img.shields.io/badge/React-19.x-%2320232a?style=flat-square&logo=react)](https://reactjs.org/)
- 👥 **멀티유저** — 각 사용자 데이터 완전 격리 [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-%233178C6?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
- 📱 **PWA** — Android 홈 화면 추가, 오프라인 지원 [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16.x-%23336791?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
- 📲 **CardDAV** — DAVx⁵ 앱으로 Galaxy 동기화 [![Prisma](https://img.shields.io/badge/Prisma-6.x-%232D3748?style=flat-square&logo=prisma)](https://www.prisma.io/)
- 📥 **TickTick Import** — CSV/ICS 내보내기 파일 import [![Docker](https://img.shields.io/badge/Docker-Ready-%232496ED?style=flat-square&logo=docker)](https://www.docker.com/)
- 🌙 **다크모드** — 시스템/수동 전환 [![CalDAV](https://img.shields.io/badge/RFC%204791-CalDAV%20VTODO-%234B7BF5?style=flat-square)](https://tools.ietf.org/html/rfc4791)
- 🐳 **Docker** — 원클릭 배포 [![PWA](https://img.shields.io/badge/PWA-Installable-purple?style=flat-square)](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
View File
@@ -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:
+12
View File
@@ -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 "$@"
+15 -5
View File
@@ -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"
+1
View File
@@ -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",
+35 -22
View File
@@ -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>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}> <h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
CheckFlow Admin Console CheckFlow Admin Console
</h1> </h1>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: 0 }}> {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 }}>
<div>
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>Registered Users & Privacy Isolation</h2> <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..."
+192 -28
View File
@@ -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 {
+108 -4
View File
@@ -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 target = findTaskInTree(store.tasks, id);
if (target) {
handleDeleteTaskWithUndo(target as Task);
} else {
const newTasks = moveToTrashInTree(store.tasks, id); const newTasks = moveToTrashInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id)); setTasks((prev) => prev.filter((t) => t.id !== id));
if (selectedTask?.id === id) { if (selectedTask?.id === id) setSelectedTask(null);
setSelectedTask(null);
} }
refresh();
}; };
// Restore from Trash // Restore from Trash
@@ -435,7 +510,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 +532,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 +600,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"
+13
View File
@@ -739,6 +739,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 ? (
+72 -11
View File
@@ -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,14 +660,75 @@ 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>
<h3 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>
{t("admin") || "Admin Console"}
</h3>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
Multi-user administration, account control, and system telemetry
</p> </p>
<a href="/admin" className="btn btn-primary" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}> </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 🚀 Open Admin Dashboard
</a> </a>
</div> </div>
</div>
)} )}
</div> </div>
+102 -36
View File
@@ -43,6 +43,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 +59,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 +156,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 +179,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 +205,30 @@ 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
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.setData("text/plain", 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 +300,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 +364,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 +371,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 +400,27 @@ function RecursiveTaskItem({
</button> </button>
</div> </div>
) : ( ) : (
<div className="task-actions-right">
<button <button
className="icon-btn" className="badge badge-neutral"
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }} style={{
cursor: "pointer",
fontSize: 11,
padding: "2px 8px",
border: "1px solid var(--border)",
background: "var(--bg-secondary)",
}}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setEditingTitle(true); setAddingSubtask(true);
setExpanded(true);
}} }}
title="Edit title" title="Add subtask"
type="button" type="button"
> >
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + {t("subtasks")}
<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" />
</svg>
</button> </button>
</div>
)} )}
</div> </div>
@@ -397,6 +441,7 @@ function RecursiveTaskItem({
isTrashMode={isTrashMode} isTrashMode={isTrashMode}
onRestore={onRestore} onRestore={onRestore}
onPermanentDelete={onPermanentDelete} onPermanentDelete={onPermanentDelete}
onDragTask={onDragTask}
/> />
))} ))}
</div> </div>
@@ -460,6 +505,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 +531,7 @@ export function TaskList({
onDemoToggleTask, onDemoToggleTask,
onUpdateTaskTitle, onUpdateTaskTitle,
onUpdateListName, onUpdateListName,
onDeleteTaskWithUndo,
onDeleteTask, onDeleteTask,
}: Props) { }: Props) {
const { t } = useI18n(); const { t } = useI18n();
@@ -640,6 +687,30 @@ export function TaskList({
setEditingHeader(false); setEditingHeader(false);
}; };
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => {
setTasks((prev) => {
const draggedIndex = prev.findIndex((t) => t.id === draggedId);
const targetIndex = prev.findIndex((t) => t.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) return prev;
const newArr = [...prev];
const [draggedItem] = newArr.splice(draggedIndex, 1);
const insertAt = position === "before"
? (draggedIndex < targetIndex ? targetIndex - 1 : targetIndex)
: (draggedIndex < targetIndex ? targetIndex : targetIndex + 1);
newArr.splice(Math.max(0, insertAt), 0, draggedItem);
return newArr;
});
}, [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)" }}>
@@ -830,6 +901,7 @@ export function TaskList({
isTrashMode={isTrashActive} isTrashMode={isTrashActive}
onRestore={onRestoreTask} onRestore={onRestoreTask}
onPermanentDelete={onPermanentDeleteTask} onPermanentDelete={onPermanentDeleteTask}
onDragTask={handleReorderTasks}
/> />
))} ))}
@@ -862,6 +934,7 @@ export function TaskList({
isTrashMode={isTrashActive} isTrashMode={isTrashActive}
onRestore={onRestoreTask} onRestore={onRestoreTask}
onPermanentDelete={onPermanentDeleteTask} onPermanentDelete={onPermanentDeleteTask}
onDragTask={handleReorderTasks}
/> />
))} ))}
</div> </div>
@@ -897,14 +970,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)}
+7 -548
View File
@@ -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;
}; };
+181
View File
@@ -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.",
};
+12
View File
@@ -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 };
+181
View File
@@ -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権限が必要です。",
};
+181
View File
@@ -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 권한이 필요합니다.",
};
+183
View File
@@ -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;
+15 -8
View File
@@ -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) {
function collect(nodes: MockTask[]) {
for (const node of nodes) {
if (node.isDeleted) { if (node.isDeleted) {
trash.push(node); if (!map.has(node.id)) {
map.set(node.id, { ...node, children: [] });
}
} }
if (node.children && node.children.length > 0) { if (node.children && node.children.length > 0) {
trash = trash.concat(getAllTrashTasks(node.children)); collect(node.children);
} }
} }
return trash; }
collect(tree);
return Array.from(map.values());
} }
// Filter tasks by custom tag // Filter tasks by custom tag
+2 -14
View File
@@ -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();
} }