Compare commits
12
Commits
master
..
131a709186
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
131a709186 | ||
|
|
a8574be2fb | ||
|
|
dd01a8ec69 | ||
|
|
765f670ef0 | ||
|
|
d2d2034515 | ||
|
|
cadb2c78cf | ||
|
|
81ddb9cba7 | ||
|
|
1fa94b5227 | ||
|
|
633e760599 | ||
|
|
3710c313e9 | ||
|
|
dbfaa0fcb2 | ||
|
|
ed076cf5ab |
+6
-32
@@ -4,53 +4,27 @@
|
||||
|
||||
- **Core Architecture & Source Structure:**
|
||||
- `src/app/`: Next.js App Router root layouts, providers, auth/demo/admin views, and REST/CalDAV API endpoints (`/api/tasks`, `/api/lists`, `/api/import`, `/api/dav/[...path]`).
|
||||
- `src/components/layout/`: `AppShell.tsx` (3-panel main layout, resizer coordination, mobile drawer overlay), `Sidebar.tsx` (draggable resizer, lists tree, tag & trash navigation, profile, import & export popover, admin console link).
|
||||
- `src/components/layout/`: `AppShell.tsx` (3-panel main layout, resizer coordination, mobile drawer overlay), `Sidebar.tsx` (draggable resizer, lists tree, tag & trash navigation, profile & import popover).
|
||||
- `src/components/tasks/`:
|
||||
- `TaskList.tsx`: Recursive n-depth hierarchical checklist, 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.
|
||||
- `TaskList.tsx`: Recursive n-depth hierarchical checklist, inline title editing, context menu, smart quick-add toolbar.
|
||||
- `TaskDetail.tsx`: Right-side detail panel, modular block swap (Subtasks ⇄ Markdown Note), mouse drag split resizer, mobile bottom-sheet with swipe-down dismissal.
|
||||
- `MarkdownNoteEditor.tsx`: Distraction-free text/markdown note canvas with DOMPurify sanitization & preview rendering.
|
||||
- `KanbanView.tsx`: CheckFlow Labs 3-column Kanban board view (`To Do` / `In Progress` / `Done`).
|
||||
- `src/components/settings/`: `SettingsModal.tsx` (Profile, Preferences, Labs, Integrations/CalDAV, Admin tabs).
|
||||
- `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/components/settings/`: `SettingsModal.tsx` (Profile, Preferences, Labs, Integrations/DAVx⁵, Admin tabs).
|
||||
- `src/lib/`: `userPrefs.ts` (global user customization state store), `useUserPrefs.ts` (reactive preferences hook & CSS variable injector), `i18n/` (EN/KO/JA localization dictionary), `mockData.ts` (LocalStorage demo store), `auth.ts`, `prisma.ts`.
|
||||
- `src/middleware.ts`: Route-level auth and admin role verification guard.
|
||||
|
||||
- **Completed So Far:**
|
||||
- Standardized user-isolated Tag database REST API (`/api/tags`) ensuring private custom tags per account, preventing cross-user data leakage and providing live tag autocomplete (v0.7.2).
|
||||
- Streamlined Task Drag-and-Drop (DND) interaction model: hovering over a task nests as a subtask, dropping on blank list background elevates to root level, and prevents duplicate/looping trees (v0.7.2).
|
||||
- Fixed completed task toggle button layout shift and centered list loader animations (v0.7.2).
|
||||
- Constrained inline title edit double-click trigger zone strictly to task title text span (v0.7.2).
|
||||
- Integrated i18n localization with language switcher in Admin console (`/admin`), and upgraded placeholders/dialogs across EN/KO/JA to polite, natural phrasing (v0.7.2).
|
||||
- Standardized complete localization across all 3 supported languages (EN, KO, JA) without partial fallback leaks; translated all Settings Modal tabs (Profile, Preferences, Labs, Sync Integrations, Admin) and interactive guides (v0.7.1).
|
||||
- Resolved list header rename layout shift bug where editing list title pushed the completed items toggle button (v0.7.1).
|
||||
- Enhanced Markdown Note Editor preview/editor canvas alignment and unified description typography heights (v0.7.1).
|
||||
- Standardized Task Detail priority picker into a sleek custom popover chip matching the central quick-add toolbar (v0.7.1).
|
||||
- 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`.
|
||||
- Verified 100% implementation of core features: n-depth recursive checklist, real-time subtask synchronization, modular blocks & split resizer, 3-way theme (VSCode dark / pastel light / system), global `UserPrefs` customization, Labs Kanban view, and mobile bottom sheet.
|
||||
- Enhanced CalDAV / DAVx⁵ synchronization endpoint (`src/app/api/dav/[...path]/route.ts`) with full RFC 4791 support: `OPTIONS`, `PROPFIND` (principal & calendar collection discovery), `REPORT` (calendar-query/multiget), `PUT` (task upsert from VTODO), and `DELETE`.
|
||||
- Upgraded Settings Modal "Integrations (CalDAV)" tab with multi-platform interactive guides (Android/DAVx⁵, Apple Reminders, Thunderbird), one-click URL copy feedback, ICS feed direct download, and live endpoint test ping.
|
||||
- Implemented full-fidelity standard Task Export pair (`/api/export`) supporting RFC 4180 / TickTick-compatible CSV with UTF-8 BOM, and RFC 5545 iCalendar (`.ics` VTODO) format.
|
||||
- Added Export modal in Sidebar user popover menu supporting format selection, target list filtering, completed tasks inclusion toggle, and seamless Demo mode client-side Blob generation.
|
||||
- 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.
|
||||
- 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.
|
||||
- Added Admin Console direct entry in Sidebar user popover menu for swift navigation.
|
||||
- Fixed Docker container runtime Prisma migration dependencies by adding `effect` and ensuring full `node_modules` availability in the production runner stage of `Dockerfile` for seamless entrypoint `prisma migrate deploy`.
|
||||
- Refined `docker-compose.yml` and `.env.example` to support standalone 2-file deployment (`docker-compose.yml` + `.env`) without cloning source code, public registry images (`CHECKFLOW_IMAGE`), Watchtower auto-updating labels, and automated database migrations.
|
||||
- Automated Gitea Actions CI workflow (`.gitea/workflows/docker-build.yaml`) for building & pushing container images to Gitea Container Registry with lowercase repository name normalization (`env.REPO`) and OCI source labels (`org.opencontainers.image.source`) for automatic repository package linkage.
|
||||
- Completely revamped `README.md` to reflect universal 2-file Docker deployment for general users, Watchtower auto-update, architecture, and open-standard CalDAV sync guidelines.
|
||||
- Enhanced Task Row UX & Interactive Undo Toast (v0.6.3): Notion-style 6-dot drag handle (`⋮⋮`), DND reordering, `F2` inline editing, right-aligned subtask button, and 6-second floating Undo toast banner.
|
||||
- Completely redesigned Left Sidebar UI/UX (v0.7.0): Brand checkmark SVG logo, centered matte search box (Ctrl+K), bottom-docked user card, fixed list redirect bug on trash selection, and synchronized live trash counter.
|
||||
- Minimal & Matte Kanban View Switcher (v0.7.0): Compact single toggle visible only when Kanban is enabled in Labs, with seamless real-time switching.
|
||||
- Markdown Note Editor defaulting to Preview mode with one-touch toggle button (v0.7.0).
|
||||
- Real-time Optimistic UI updates for subtask inline addition & checklist checkbox clicks without requiring page refreshes (v0.7.0).
|
||||
- Auto-promotion of the first-ever registered user to ADMIN role on self-hosted instances (v0.7.0).
|
||||
- Full N-depth tree drag-and-drop (DND) reordering for main tasks and subtasks with LocalStorage persistence (v0.7.0).
|
||||
- Recursive soft-delete cascading (deleting a parent task automatically soft-deletes all descendant subtasks) in both Prisma database and LocalStorage mock store (v0.7.0).
|
||||
- Real-time Admin console API endpoints (`/api/admin/stats`, `/api/admin/users`) with Live/Demo duality (v0.7.0).
|
||||
- Added localized i18n strings for Export across English, Korean, and Japanese.
|
||||
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
||||
|
||||
- **Next Steps (Todo):**
|
||||
- Continuous refinement of user experience based on feedback.
|
||||
- Enhance keyboard accessibility & global shortcuts (shortcuts for switching list/kanban view, quick task navigation, Command Palette bindings).
|
||||
- Add optional automatic synchronization background polling / webhook integration if requested.
|
||||
- Plan next feature iterations or custom integrations as requested by user.
|
||||
@@ -60,5 +34,5 @@
|
||||
- **Sanitization**: Never render raw HTML without DOMPurify; always sanitize CSV/ICS user inputs against formula injection (`sanitizeFormula`).
|
||||
- **Admin Gatekeeping**: Admin routes must strictly require `role === "ADMIN"` both on server/middleware and client.
|
||||
- **User Customization Priority**: Any new UI preferences (sizes, positions, order) must be persisted through `userPrefs.ts` and respect the "Reset to Defaults" Labs option.
|
||||
- **i18n Consistency**: Any newly added UI string must be translated across all 3 language dictionaries (`en`, `ko`, `ja`) in `src/lib/i18n/locales/`.
|
||||
- **i18n Consistency**: Any newly added UI string must be translated across all 3 language dictionaries (`en`, `ko`, `ja`) in `src/lib/i18n/translations.ts` and `src/lib/i18n/index.tsx`.
|
||||
- **Mobile Bottom-Sheet Convention**: Maintain touch gestures (`mobile-open` class, Y-axis swipe threshold > 120px) for the detail panel on mobile screens.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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
|
||||
+6
-14
@@ -1,19 +1,11 @@
|
||||
# ==============================================================================
|
||||
# CheckFlow — Environment Variables Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# [필수] PostgreSQL 데이터베이스 접속 비밀번호 (프로덕션 환경에서는 안전한 난수로 변경)
|
||||
# Database password (change in production!)
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
# [필수] 웹 서비스 외부 접속 기본 URL (리버스 프록시/도메인 또는 IP 및 포트)
|
||||
# App URL (set to your domain or server IP)
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# [필수] NextAuth 세션 암호화 키 — 생성 명령: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET=change-this-to-a-secure-random-secret
|
||||
# Auth secret — generate with: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET=change-this-to-a-random-string
|
||||
|
||||
# [선택] 호스트 머신에 노출할 포트 번호 (기본값: 3000)
|
||||
PORT=3000
|
||||
|
||||
# [선택/배포] Gitea 또는 Docker 레지스트리 컨테이너 이미지 주소 (Watchtower 자동 갱신 지원)
|
||||
# 예: CHECKFLOW_IMAGE=git.yourdomain.com/neru_han/checkflow:latest
|
||||
CHECKFLOW_IMAGE=git.nrh.kr/neru_han/checkflow:latest
|
||||
# Port to expose (default 3000)
|
||||
PORT=3000
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.DOCKER_REGISTRY || 'gitea.example.com' }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://${{ secrets.DOCKER_REGISTRY || 'gitea.example.com' }}/${{ gitea.repository }}
|
||||
tags: |
|
||||
${{ secrets.DOCKER_REGISTRY || 'gitea.example.com' }}/${{ env.REPO }}:latest
|
||||
${{ secrets.DOCKER_REGISTRY || 'gitea.example.com' }}/${{ env.REPO }}:${{ gitea.sha }}
|
||||
@@ -17,6 +17,3 @@ out/
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
chamgojaryo/
|
||||
|
||||
# local memory & ai context
|
||||
.clinecontext
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
3. **외부 플랫폼 연동 & Import/Export**:
|
||||
- TickTick 등 외부 플랫폼과의 호환을 위한 **CSV 및 ICS(iCalendar) 파일 Import/Export 완벽 지원** (하단 프로필 메뉴 내 배치).
|
||||
- Formula Injection 방어(`sanitizeFormula`) 및 Excel 호환 UTF-8 BOM 지원.
|
||||
- Android 모바일 연동: DAVx⁵ 앱을 통한 CalDAV (`/api/dav`) 표준 양방향 동기화 지원.
|
||||
- Galaxy(Android) 폰 연동: DAVx⁵ 앱을 통한 CalDAV/CardDAV (`/api/dav`) 동기화 지원.
|
||||
4. **PWA (Progressive Web App)**:
|
||||
- 모바일/데스크톱 설치 가능 및 오프라인 캐싱 지원 (`manifest.json`, `sw.js`).
|
||||
5. **모바일 바텀시트 드로어**:
|
||||
@@ -81,12 +81,7 @@ src/
|
||||
| `checkpoint-v0.5.0` | VSCode 무채색 다크 테마, 에디터 조잡한 툴바 제거 & 순수 캔버스 전환, 모듈형 블록 커스텀 (서브태스크 ↔ 노트 순서 스왑 및 스플릿 리사이저 높이 조절), CheckFlow Labs (3컬럼 칸반 보드 뷰), 기본값 복원(Reset to Defaults), 보안 패치 완료 | 2026-08-21 |
|
||||
| `checkpoint-v0.6.0` | 글로벌 `UserPrefs` 시스템(`userPrefs.ts` / `useUserPrefs.ts`) 도입, 사이드바 드래그 리사이저, 디테일 패널 드래그 리사이저, Labs 탭 확장(밀도/폰트/라운드/애니메이션/색조/채도/패널 너비), 뷰 전환(리스트↔칸반) 실시간 반응, 파스텔 매트 라이트 팔레트 적용 | 2026-08-21 |
|
||||
| `checkpoint-v0.6.1` | CalDAV / DAVx⁵ 양방향 동기화 프로토콜 고도화(OPTIONS, PROPFIND, REPORT, PUT, DELETE), 설정 모달 내 플랫폼별(Android DAVx⁵ / Apple Reminders / Thunderbird) 인터랙티브 가이드 및 실시간 엔드포인트 테스트 핑, 다국어 i18n 동기화 | 2026-08-21 |
|
||||
| `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
|
||||
| `checkpoint-v0.6.3` | 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 |
|
||||
| `checkpoint-v0.6.4` | i18n 모듈러 사전 아키텍처(`types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`) 분리 및 번역 완성도 100%, 어드민 콘솔(`/admin`) 데모 프리뷰 및 멀티유저 게이트 연동, 설정 모달 Admin 탭 정보 고도화, 사이드바 프로필 메뉴 내 Admin Console 진입점 추가 | 2026-08-21 |
|
||||
| `checkpoint-v0.7.0` | 좌측 사이드바 UI/UX 전면 리디자인(브랜드 로고 SVG, 중앙정렬 매트 검색창, 바닥 밀착 프로필), 뷰 전환(리스트/칸반) 미니멀 원터치 토글 및 실험실 연동 버그 수정, 휴지통 카운트 실시간 동기화 & 기본화면 튕김 버그 수정, 첫 가입 계정 자동 ADMIN 승격 및 실시간 Admin API(/api/admin/users, /api/admin/stats) 구축, 서브태스크 인라인 추가 및 체크박스 Optimistic UI 실시간 렌더링, 메모장 기본 Preview 모드 & 원터치 토글, N-depth 계층형 DND 드래그 앤 드롭 재정렬 및 재귀 하위 태스크 캐스케이드 삭제 지원 | 2026-08-21 |
|
||||
| `checkpoint-v0.7.1` | 설정 모달(5개 탭 및 가이드/프리뷰 전면) i18n 3개국어(EN/KO/JA) 100% 완전 번역 및 언어 로컬스토리지 영구 기억/자동 감지 적용, 태스크 목록 제목 인라인 수정 시 완료항목 토글 버튼 밀림 flexbox 레이아웃 버그 수정, 메모장 프리뷰/에디터 높이 및 플레이스홀더 타이포그래피 일관성 보정, 우측 상세 패널 우선순위 브라우저 기본 드롭다운을 중앙 퀵애드와 동일한 세련된 컬러 팝오버 칩 UI로 통일 | 2026-08-22 |
|
||||
| `checkpoint-v0.7.2` | **(현재 최신)** 태그(Tag) 사용자 계정별 데이터베이스 격리 API 구축(/api/tags), DND 드래그앤드롭 상위 태스크 하위 편입 단일화 및 빈 배경 드롭 1단계 승격 지원, 완료항목 토글 시 로딩 UI 밀림 버그 수정, 인라인 제목 수정 더블클릭 영역 텍스트 한정, 어드민 콘솔 다국어(i18n) 언어 선택기 지원, 친절하고 간결한 톤앤매너 플레이스홀더 전면 적용 | 2026-08-22 |
|
||||
| `checkpoint-v0.6.2` | **(현재 최신)** 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,7 +119,7 @@ src/
|
||||
- 목록별 필터링 및 완료 태스크 포함 여부 선택 모달
|
||||
- [x] **CalDAV 양방향 동기화 및 가이드 고도화**:
|
||||
- RFC 4791 표준 준수: `OPTIONS`, `PROPFIND` (Principal & Collection 탐색), `REPORT` (VTODO 쿼리), `PUT` (태스크 업서트), `DELETE`
|
||||
- 설정 모달 내 플랫폼별 인터랙티브 가이드 (Android / DAVx⁵, Apple 미리알림, Thunderbird)
|
||||
- 설정 모달 내 플랫폼별 인터랙티브 가이드 (Samsung Galaxy / DAVx⁵, Apple 미리알림, Thunderbird)
|
||||
- 실시간 엔드포인트 응답 상태 테스트 및 ICS 피드 다운로드 기능
|
||||
- [x] **TickTick 스타일 스마트 퀵애드 툴바** (날짜, 우선순위 프리셋)
|
||||
- [x] **상단 프로젝트 브레드크럼 & 리스트 이동기** (`📁 프로젝트명 ▾`)
|
||||
|
||||
+7
-21
@@ -1,43 +1,29 @@
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
# 1. Install dependencies based on package-lock.json
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# 2. Rebuild the source code and generate Prisma client
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# 3. Production runner
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
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/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
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/package.json ./package.json
|
||||
|
||||
COPY --chown=nextjs:nodejs docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
RUN chmod +x ./docker-entrypoint.sh
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,251 +1,117 @@
|
||||
<div align="center">
|
||||
# CheckFlow
|
||||
|
||||
# ⚡ CheckFlow
|
||||
> 셀프호스팅 TickTick-like Todo 앱 — 계층형 체크리스트 + 메모장 + CardDAV + PWA
|
||||
|
||||
**프라이버시 중심의 독립형 스마트 태스크 & 마크다운 워크스페이스**
|
||||
*A modern, self-hosted, privacy-first task management & note workspace built for focus, speed, and open-standard freedom.*
|
||||

|
||||
|
||||
<br/>
|
||||
## 주요 기능
|
||||
|
||||
[](https://nextjs.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.prisma.io/)
|
||||
[](https://www.docker.com/)
|
||||
[](https://tools.ietf.org/html/rfc4791)
|
||||
[](https://web.dev/progressive-web-apps/)
|
||||
- ✅ **계층형 체크리스트** — 메인 태스크 + 하위 태스크
|
||||
- 📝 **태스크 메모장** — 넓은 노트 영역 (Markdown 지원)
|
||||
- 👥 **멀티유저** — 각 사용자 데이터 완전 격리
|
||||
- 📱 **PWA** — Android 홈 화면 추가, 오프라인 지원
|
||||
- 📲 **CardDAV** — DAVx⁵ 앱으로 Galaxy 동기화
|
||||
- 📥 **TickTick Import** — CSV/ICS 내보내기 파일 import
|
||||
- 🌙 **다크모드** — 시스템/수동 전환
|
||||
- 🐳 **Docker** — 원클릭 배포
|
||||
|
||||
<br/>
|
||||
## 빠른 시작
|
||||
|
||||
[✨ 핵심 기능](#-핵심-기능--디자인-철학) •
|
||||
[🚀 빠른 시작 (Docker)](#-빠른-시작-docker-배포) •
|
||||
[📱 CalDAV 모바일 연동](#-caldav-모바일-및-외부-앱-동기화) •
|
||||
[📦 데이터 호환성](#-데이터-가져오기--내보내기) •
|
||||
[🧪 Labs 커스터마이징](#-checkflow-labs--디자인-시스템) •
|
||||
[🛠️ 개발 환경](#️-로컬-개발-환경)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 💡 프로젝트 소개 & 디자인 철학
|
||||
|
||||
**CheckFlow**는 상용 클라우드 서비스(TickTick, Notion, Todoist 등)의 복잡성과 구독 모델, 데이터 종속에서 벗어나 **사용자 개인의 완벽한 데이터 주권**과 **군더더기 없는 미니멀리즘 작업 경험**을 제공하기 위해 설계된 독립형 웹 애플리케이션입니다.
|
||||
|
||||
- 🎯 **순수한 몰입감**: 산만한 툴바와 불필요한 장식을 배제하고, 에디터 본연의 텍스트 타이핑과 정갈한 마크다운 렌더링에 집중합니다.
|
||||
- 🌲 **자유로운 계층화**: 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개를 내려받습니다:
|
||||
### 1. 환경 변수 설정
|
||||
|
||||
```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
|
||||
# .env 파일에서 NEXTAUTH_SECRET 및 POSTGRES_PASSWORD 변경
|
||||
```
|
||||
|
||||
### 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. 서비스 실행 및 자동 마이그레이션
|
||||
최신 패키지 이미지를 다운로드하고 서비스를 실행합니다:
|
||||
### 2. Docker로 실행
|
||||
|
||||
```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
|
||||
# DB 마이그레이션 (최초 1회)
|
||||
docker compose exec app npx prisma migrate deploy
|
||||
```
|
||||
|
||||
브라우저에서 설정한 주소(`https://todo.yourdomain.com` 또는 `http://서버IP:3000`)에 접속하여 첫 계정(관리자)을 등록하고 바로 사용을 시작하세요.
|
||||
`http://localhost: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)
|
||||
- PostgreSQL (또는 Docker)
|
||||
|
||||
```bash
|
||||
# 1. 저장소 클론 및 패키지 설치
|
||||
git clone https://git.nrh.kr/Neru_Han/checkflow.git
|
||||
cd checkflow
|
||||
# 의존성 설치
|
||||
npm install
|
||||
|
||||
# 2. 환경 변수 설정
|
||||
# DB 설정
|
||||
cp .env.example .env.local
|
||||
# .env.local의 DATABASE_URL을 DB에 맞게 수정
|
||||
|
||||
# 3. Prisma DB 스키마 생성 및 클라이언트 빌드
|
||||
npx prisma db push
|
||||
# 또는 SQLite 개발 모드: npm run dev:sqlite
|
||||
# DB 마이그레이션
|
||||
npx prisma migrate dev
|
||||
|
||||
# 4. 로컬 개발 서버 시작 (Turbopack)
|
||||
# 개발 서버 시작
|
||||
npm run dev
|
||||
```
|
||||
|
||||
브라우저에서 `http://localhost:3000` (체험 모드는 `http://localhost:3000/demo`)으로 접속합니다.
|
||||
→ `http://localhost:3000`
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 시스템 아키텍처 & 기술 스택
|
||||
## DAVx⁵로 Galaxy 연동
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── page.tsx ← 메인 앱 진입점 (NextAuth 세션 기반)
|
||||
│ ├── demo/page.tsx ← DB 연결 없이 LocalStorage 기반 완전 구동 데모
|
||||
│ ├── 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 스토리지 레이어
|
||||
```
|
||||
1. Play Store에서 **[DAVx⁵](https://play.google.com/store/apps/details?id=at.bitfire.davdroid)** 설치 (무료)
|
||||
2. DAVx⁵ 앱 → **+** → Login with URL and user name
|
||||
- **URL**: `http://your-server:3000/api/dav`
|
||||
- **Username**: CheckFlow 이메일
|
||||
- **Password**: CheckFlow 비밀번호
|
||||
3. Task 목록 동기화 → Samsung Reminder 또는 Tasks 앱에서 확인
|
||||
|
||||
---
|
||||
|
||||
## 🔒 보안 및 개인정보 원칙
|
||||
## TickTick에서 가져오기
|
||||
|
||||
1. **엄격한 데이터 격리**: 모든 API 요청은 세션 소유권(`userId === session.user.id`)을 검증하며 리스트/태스크 간 IDOR(비인가 접근)를 원천 차단합니다.
|
||||
2. **XSS 및 수식 주입 방어**: 모든 사용자 입력과 마크다운 렌더링은 `DOMPurify`를 거치며, CSV 가져오기 시 Formula Injection 방어(`sanitizeFormula`)가 적용됩니다.
|
||||
3. **독립 멀티유저 & 관리자 통제**: 일반 사용자와 관리자(`ADMIN`) 권한이 분리되어 안전하게 셀프호스팅 환경을 운영할 수 있습니다.
|
||||
1. TickTick 앱 → Settings → Export
|
||||
2. **Export as CSV** 또는 **Export as iCalendar** 선택
|
||||
3. CheckFlow → 사이드바 → **Import Tasks**
|
||||
4. 파일 선택 후 대상 목록 지정 → Import
|
||||
|
||||
---
|
||||
|
||||
## 📄 라이선스 (License)
|
||||
## NPM (Nginx Proxy Manager) 연동
|
||||
|
||||
This project is licensed under the **MIT License** — see the LICENSE file for details.
|
||||
Docker compose가 기본으로 `3000` 포트에서 실행됩니다.
|
||||
NPM에서 Proxy Host 추가:
|
||||
- **Destination**: `http://checkflow-app:3000`
|
||||
- SSL 인증서 설정
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수
|
||||
|
||||
| 변수 | 설명 | 기본값 |
|
||||
|------|------|--------|
|
||||
| `DATABASE_URL` | PostgreSQL 연결 문자열 | — |
|
||||
| `NEXTAUTH_URL` | 외부 접근 URL | `http://localhost:3000` |
|
||||
| `NEXTAUTH_SECRET` | JWT 시크릿 (32자 이상 랜덤) | — |
|
||||
| `POSTGRES_PASSWORD` | DB 비밀번호 | `changeme` |
|
||||
| `PORT` | 노출 포트 | `3000` |
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **Frontend**: Next.js 16 (App Router) + TypeScript + Vanilla CSS
|
||||
- **Backend**: Next.js API Routes
|
||||
- **DB**: PostgreSQL + Prisma ORM
|
||||
- **Auth**: NextAuth.js (JWT)
|
||||
- **PWA**: Service Worker + Web App Manifest
|
||||
- **CardDAV**: 커스텀 iCalendar VTODO 구현
|
||||
- **Container**: Docker + docker-compose
|
||||
+18
-11
@@ -17,17 +17,24 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
image: ${CHECKFLOW_IMAGE:-checkflow:latest}
|
||||
# 로컬 소스코드로 직접 빌드하여 실행하려면 아래 build 블록의 주석을 해제하세요:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
container_name: checkflow-app
|
||||
restart: unless-stopped
|
||||
migrate:
|
||||
build: .
|
||||
container_name: checkflow-migrate
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://checkflow:${POSTGRES_PASSWORD:-changeme}@postgres:5432/checkflow
|
||||
command: npx prisma migrate deploy
|
||||
restart: "no"
|
||||
|
||||
app:
|
||||
build: .
|
||||
container_name: checkflow-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
@@ -35,8 +42,8 @@ services:
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-this-secret-in-production}
|
||||
NODE_ENV: production
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
volumes:
|
||||
- ./prisma:/app/prisma
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
postgres_data:
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Run Prisma database migrations if DATABASE_URL is set and not explicitly skipped
|
||||
if [ -n "$DATABASE_URL" ] && [ "$SKIP_DB_MIGRATE" != "true" ]; then
|
||||
echo "[CheckFlow] Checking and applying database migrations..."
|
||||
if [ -f "./node_modules/prisma/build/index.js" ]; then
|
||||
node ./node_modules/prisma/build/index.js migrate deploy --schema=./prisma/schema.prisma || echo "[CheckFlow] Warning: Prisma migration step skipped or encountered non-fatal error."
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
Generated
+5
-15
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "checkflow",
|
||||
"version": "0.5.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "checkflow",
|
||||
"version": "0.5.0",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -16,7 +16,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"csv-parse": "^7.0.2",
|
||||
"dompurify": "^3.4.14",
|
||||
"effect": "^3.22.1",
|
||||
"marked": "^18.0.10",
|
||||
"next": "16.3.1",
|
||||
"next-auth": "^4.24.15",
|
||||
@@ -1346,15 +1345,6 @@
|
||||
"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": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||
@@ -3233,9 +3223,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.22.1",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.22.1.tgz",
|
||||
"integrity": "sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ==",
|
||||
"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"
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"csv-parse": "^7.0.2",
|
||||
"dompurify": "^3.4.14",
|
||||
"effect": "^3.22.1",
|
||||
"marked": "^18.0.10",
|
||||
"next": "16.3.1",
|
||||
"next-auth": "^4.24.15",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Task" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Task_isDeleted_idx" ON "Task"("isDeleted");
|
||||
@@ -63,9 +63,6 @@ model Task {
|
||||
priority Int @default(0)
|
||||
sortOrder Int @default(0)
|
||||
|
||||
isDeleted Boolean @default(false)
|
||||
deletedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -78,7 +75,6 @@ model Task {
|
||||
@@index([userId])
|
||||
@@index([listId])
|
||||
@@index([parentId])
|
||||
@@index([isDeleted])
|
||||
}
|
||||
|
||||
model Tag {
|
||||
|
||||
+9
-32
@@ -1,9 +1,9 @@
|
||||
const CACHE_NAME = "checkflow-v2";
|
||||
const STATIC_ASSETS = ["/manifest.json", "/icons/icon.svg"];
|
||||
const CACHE_NAME = "checkflow-v1";
|
||||
const STATIC_ASSETS = ["/", "/login", "/register", "/manifest.json", "/icons/icon.svg"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).catch(() => {})
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
@@ -21,37 +21,14 @@ self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle http/https GET requests to the same origin
|
||||
if (request.method !== "GET" || !url.protocol.startsWith("http") || url.origin !== self.location.origin) {
|
||||
// Network-first for API
|
||||
if (url.pathname.startsWith("/api")) {
|
||||
event.respondWith(fetch(request).catch(() => new Response(JSON.stringify({ error: "Offline" }), { headers: { "Content-Type": "application/json" } })));
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for API & navigation routes to avoid stale/failed page loads
|
||||
if (url.pathname.startsWith("/api") || request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => {
|
||||
if (url.pathname.startsWith("/api")) {
|
||||
return new Response(JSON.stringify({ error: "Offline or service unavailable" }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return caches.match(request).then((cached) => cached || Response.error());
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets with safe network fallback
|
||||
// Cache-first for static
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(request).then((response) => {
|
||||
if (response && response.status === 200 && response.type === "basic") {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)).catch(() => {});
|
||||
}
|
||||
return response;
|
||||
}).catch(() => Response.error());
|
||||
})
|
||||
caches.match(request).then((cached) => cached || fetch(request))
|
||||
);
|
||||
});
|
||||
});
|
||||
+133
-231
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { getDemoStore } from "@/lib/mockData";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { LanguageSelector } from "@/components/ui/LanguageSelector";
|
||||
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
@@ -16,7 +15,7 @@ interface AdminUser {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_DEMO_USERS: AdminUser[] = [
|
||||
const INITIAL_ADMIN_USERS: AdminUser[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Admin Master",
|
||||
@@ -47,93 +46,59 @@ const INITIAL_DEMO_USERS: AdminUser[] = [
|
||||
];
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
const isDemo = status === "unauthenticated" || !session;
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>(INITIAL_DEMO_USERS);
|
||||
const [stats, setStats] = useState({
|
||||
totalUsers: 3,
|
||||
totalLists: 9,
|
||||
totalTasks: 67,
|
||||
caldavStatus: "Active",
|
||||
});
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
|
||||
const [search, setSearch] = useState("");
|
||||
const [totalTasks] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
return store.tasks ? store.tasks.length : 67;
|
||||
}
|
||||
return 67;
|
||||
});
|
||||
const [totalLists] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
return store.lists ? store.lists.length : 9;
|
||||
}
|
||||
return 9;
|
||||
});
|
||||
|
||||
// 어드민 권한 판별
|
||||
const userRole = session?.user?.role;
|
||||
const userEmail = session?.user?.email;
|
||||
const isAuthorized =
|
||||
isDemo ||
|
||||
userRole === "ADMIN" ||
|
||||
userEmail?.endsWith("@checkflow.local") ||
|
||||
userEmail?.startsWith("admin@") ||
|
||||
userEmail === "admin@checkflow.local";
|
||||
|
||||
const fetchAdminData = useCallback(async () => {
|
||||
if (isDemo) {
|
||||
if (typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
setStats({
|
||||
totalUsers: INITIAL_DEMO_USERS.length,
|
||||
totalLists: store.lists.length,
|
||||
totalTasks: store.tasks.length,
|
||||
caldavStatus: "Active",
|
||||
});
|
||||
setUsers(INITIAL_DEMO_USERS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingUsers(true);
|
||||
try {
|
||||
const [usersRes, statsRes] = await Promise.all([
|
||||
fetch("/api/admin/users"),
|
||||
fetch("/api/admin/stats"),
|
||||
]);
|
||||
|
||||
if (usersRes.ok) {
|
||||
const uData = await usersRes.json();
|
||||
setUsers(uData);
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
const sData = await statsRes.json();
|
||||
setStats(sData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load admin data:", err);
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
}, [isDemo]);
|
||||
const isAdmin = userRole === "ADMIN" || userEmail?.endsWith("@checkflow.local") || userEmail?.startsWith("admin@");
|
||||
|
||||
// 인증 게이트: 비로그인 시 /login으로 리디렉션
|
||||
useEffect(() => {
|
||||
if (status !== "loading") {
|
||||
fetchAdminData();
|
||||
if (status === "unauthenticated") {
|
||||
router.replace("/login?callbackUrl=/admin");
|
||||
}
|
||||
}, [status, fetchAdminData]);
|
||||
}, [status, router]);
|
||||
|
||||
if (status === "loading") {
|
||||
// 로딩 중 스피너
|
||||
if (status === "loading" || status === "unauthenticated") {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
|
||||
<p style={{ color: "var(--text-secondary)" }}>{t("loading") || "Checking administrator credentials..."}</p>
|
||||
<p style={{ color: "var(--text-secondary)" }}>Checking administrator credentials...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 비인가 사용자(일반 유저) 차단 화면
|
||||
if (!isAuthorized) {
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)", color: "var(--text-primary)", padding: 24 }}>
|
||||
<div style={{ maxWidth: 460, width: "100%", background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: 32, textAlign: "center" }}>
|
||||
<div style={{ maxWidth: 440, width: "100%", background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: 32, textAlign: "center" }}>
|
||||
<div style={{ fontSize: 44, marginBottom: 16 }}>🛡️</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>{t("accessRestricted") || "Access Restricted"}</h2>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>Access Restricted</h2>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.6, margin: "0 0 24px" }}>
|
||||
{t("adminRequired") || "This console requires ADMIN privileges."} (<code>{session?.user?.email}</code>)
|
||||
This console requires <strong>ADMIN</strong> privileges. Your account (<code>{session?.user?.email}</code>) does not have authorization to view multi-user system telemetry.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
||||
<Link href="/demo" className="btn btn-ghost">
|
||||
@@ -148,27 +113,10 @@ export default function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const toggleRole = async (user: AdminUser) => {
|
||||
const newRole = user.role === "ADMIN" ? "USER" : "ADMIN";
|
||||
if (isDemo) {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === user.id ? { ...u, role: newRole } : u))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/users", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: user.id, role: newRole }),
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchAdminData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle role:", err);
|
||||
}
|
||||
const toggleRole = (id: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u))
|
||||
);
|
||||
};
|
||||
|
||||
const toggleStatus = (id: string) => {
|
||||
@@ -177,26 +125,9 @@ export default function AdminPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const deleteUser = async (user: AdminUser) => {
|
||||
if (!confirm(`Are you sure you want to delete ${user.name} (${user.email}) and all their tasks?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDemo) {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== user.id));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users?userId=${user.id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
fetchAdminData();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || "Failed to delete user");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete user:", err);
|
||||
const deleteUser = (id: string) => {
|
||||
if (confirm("Are you sure you want to delete this user and all their tasks?")) {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,43 +140,25 @@ export default function AdminPage() {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--bg-primary)", color: "var(--text-primary)", padding: "24px 32px" }}>
|
||||
{/* Top Navbar */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid var(--border)", paddingBottom: 16, marginBottom: 24, flexWrap: "wrap", gap: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid var(--border)", paddingBottom: 16, marginBottom: 24 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div className="sidebar-logo">👑</div>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
||||
CheckFlow Admin Console
|
||||
</h1>
|
||||
{isDemo ? (
|
||||
<span className="demo-badge" style={{ fontSize: 11, padding: "2px 8px" }}>
|
||||
Demo Preview
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-primary" style={{ fontSize: 11, padding: "2px 8px" }}>
|
||||
Live Instance
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
||||
CheckFlow Admin Console
|
||||
</h1>
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: 0 }}>
|
||||
Multi-user platform & self-hosted instance management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<LanguageSelector />
|
||||
{isDemo ? (
|
||||
<Link href="/demo" className="btn btn-ghost btn-sm">
|
||||
← {lang === "ko" ? "데모로 돌아가기" : lang === "ja" ? "デモに戻る" : "Back to Demo"}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={fetchAdminData}>
|
||||
🔄 {lang === "ko" ? "새로고침" : lang === "ja" ? "更新" : "Refresh"}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Link href="/demo" className="btn btn-ghost btn-sm">
|
||||
← Back to App (Demo)
|
||||
</Link>
|
||||
<Link href="/" className="btn btn-primary btn-sm">
|
||||
🚀 {lang === "ko" ? "할 일 대시보드" : lang === "ja" ? "タスク一覧" : "Open Dashboard"}
|
||||
🚀 Open Main Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,25 +167,25 @@ export default function AdminPage() {
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16, marginBottom: 28 }}>
|
||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Users</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "var(--accent)", marginTop: 4 }}>{stats.totalUsers}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "var(--accent)", marginTop: 4 }}>{users.length}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}>● 100% active instances</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Projects / Lists</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#10B981", marginTop: 4 }}>{stats.totalLists}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#10B981", marginTop: 4 }}>{totalLists}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Across all users</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Tasks & Notes</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#8B5CF6", marginTop: 4 }}>{stats.totalTasks}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#8B5CF6", marginTop: 4 }}>{totalTasks}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Recursive subtasks included</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}>
|
||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>CalDAV / DAVx⁵ Status</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#F59E0B", marginTop: 4 }}>{stats.caldavStatus}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#F59E0B", marginTop: 4 }}>Active</div>
|
||||
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}>● Basic Auth Bypass Verified</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,12 +193,7 @@ export default function AdminPage() {
|
||||
{/* User Management Section */}
|
||||
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-md)", padding: 20 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16, flexWrap: "wrap", gap: 12 }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>Registered Users & Privacy Isolation</h2>
|
||||
<p style={{ fontSize: 12, color: "var(--text-secondary)", margin: "4px 0 0" }}>
|
||||
Control multi-user accounts, promote administrators, and enforce workspace separation.
|
||||
</p>
|
||||
</div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>Registered Users & Privacy Isolation</h2>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="Search users by name or email..."
|
||||
@@ -297,93 +205,87 @@ export default function AdminPage() {
|
||||
|
||||
{/* Users Table */}
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
{loadingUsers ? (
|
||||
<div style={{ padding: "30px", textAlign: "center", color: "var(--text-tertiary)" }}>
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-tertiary)" }}>
|
||||
<th style={{ padding: "10px 12px" }}>User</th>
|
||||
<th style={{ padding: "10px 12px" }}>Role</th>
|
||||
<th style={{ padding: "10px 12px" }}>Joined Date</th>
|
||||
<th style={{ padding: "10px 12px" }}>Tasks</th>
|
||||
<th style={{ padding: "10px 12px" }}>Status</th>
|
||||
<th style={{ padding: "10px 12px", textAlign: "right" }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((u) => (
|
||||
<tr key={u.id} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<div style={{ fontWeight: 600 }}>{u.name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{u.email}</div>
|
||||
</td>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<span
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
background: u.role === "ADMIN" ? "rgba(75, 123, 245, 0.15)" : "var(--bg-primary)",
|
||||
color: u.role === "ADMIN" ? "var(--accent)" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-tertiary)" }}>
|
||||
<th style={{ padding: "10px 12px" }}>User</th>
|
||||
<th style={{ padding: "10px 12px" }}>Role</th>
|
||||
<th style={{ padding: "10px 12px" }}>Joined Date</th>
|
||||
<th style={{ padding: "10px 12px" }}>Tasks</th>
|
||||
<th style={{ padding: "10px 12px" }}>Status</th>
|
||||
<th style={{ padding: "10px 12px", textAlign: "right" }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((u) => (
|
||||
<tr key={u.id} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<div style={{ fontWeight: 600 }}>{u.name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{u.email}</div>
|
||||
</td>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<span
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
background: u.role === "ADMIN" ? "rgba(75, 123, 245, 0.15)" : "var(--bg-primary)",
|
||||
color: u.role === "ADMIN" ? "var(--accent)" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
||||
{new Date(u.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
||||
{u.taskCount} tasks
|
||||
</td>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<span style={{ color: u.active ? "var(--success)" : "var(--danger)", fontWeight: 600, fontSize: 12 }}>
|
||||
{u.active ? "● Active" : "○ Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: "12px", textAlign: "right" }}>
|
||||
<div style={{ display: "inline-flex", gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => toggleRole(u.id)}
|
||||
title="Change role"
|
||||
style={{ fontSize: 11, padding: "3px 8px" }}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
||||
{new Date(u.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td style={{ padding: "12px", color: "var(--text-secondary)" }}>
|
||||
{u.taskCount} tasks
|
||||
</td>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<span style={{ color: u.active ? "var(--success)" : "var(--danger)", fontWeight: 600, fontSize: 12 }}>
|
||||
{u.active ? "● Active" : "○ Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: "12px", textAlign: "right" }}>
|
||||
<div style={{ display: "inline-flex", gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => toggleRole(u)}
|
||||
title="Change role"
|
||||
style={{ fontSize: 11, padding: "3px 8px" }}
|
||||
>
|
||||
{u.role === "ADMIN" ? "Demote" : "Promote Admin"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => toggleStatus(u.id)}
|
||||
title="Toggle active status"
|
||||
style={{ fontSize: 11, padding: "3px 8px" }}
|
||||
>
|
||||
{u.active ? "Deactivate" : "Activate"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => deleteUser(u)}
|
||||
title="Delete user"
|
||||
style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{u.role === "ADMIN" ? "Demote" : "Promote Admin"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => toggleStatus(u.id)}
|
||||
title="Toggle active status"
|
||||
style={{ fontSize: 11, padding: "3px 8px" }}
|
||||
>
|
||||
{u.active ? "Deactivate" : "Activate"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => deleteUser(u.id)}
|
||||
title="Delete user"
|
||||
style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = session.user.role;
|
||||
const userEmail = session.user.email;
|
||||
const isAuthorized =
|
||||
userRole === "ADMIN" ||
|
||||
userEmail?.startsWith("admin@") ||
|
||||
userEmail?.endsWith("@checkflow.local") ||
|
||||
userEmail === "admin@checkflow.local";
|
||||
|
||||
if (!isAuthorized) {
|
||||
return NextResponse.json({ error: "Forbidden: Admin required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const [totalUsers, totalLists, totalTasks] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.list.count(),
|
||||
prisma.task.count(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
totalUsers,
|
||||
totalLists,
|
||||
totalTasks,
|
||||
caldavStatus: "Active",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API/Admin/Stats GET] Error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function checkAdminAuth(session: any): boolean {
|
||||
if (!session?.user) return false;
|
||||
const userRole = session.user.role;
|
||||
const userEmail = session.user.email;
|
||||
return (
|
||||
userRole === "ADMIN" ||
|
||||
userEmail?.startsWith("admin@") ||
|
||||
userEmail?.endsWith("@checkflow.local") ||
|
||||
userEmail === "admin@checkflow.local"
|
||||
);
|
||||
}
|
||||
|
||||
// GET /api/admin/users - List all registered users with task count
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!checkAdminAuth(session)) {
|
||||
return NextResponse.json({ error: "Forbidden: Admin required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
tasks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
const formattedUsers = users.map((u, idx) => {
|
||||
const isAdmin =
|
||||
idx === 0 ||
|
||||
u.email.startsWith("admin@") ||
|
||||
u.email.endsWith("@checkflow.local") ||
|
||||
u.email === "admin@checkflow.local";
|
||||
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name || "User",
|
||||
email: u.email,
|
||||
role: (isAdmin ? "ADMIN" : "USER") as "USER" | "ADMIN",
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
taskCount: u._count.tasks,
|
||||
active: true,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(formattedUsers);
|
||||
} catch (error) {
|
||||
console.error("[API/Admin/Users GET] Error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/admin/users - Delete a user
|
||||
export async function DELETE(req: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!checkAdminAuth(session)) {
|
||||
return NextResponse.json({ error: "Forbidden: Admin required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const userId = searchParams.get("userId");
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (userId === session?.user?.id) {
|
||||
return NextResponse.json({ error: "Cannot delete own admin account" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("[API/Admin/Users DELETE] Error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export async function GET() {
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
_count: { select: { tasks: { where: { isDeleted: false, completed: false, parentId: null } } } },
|
||||
_count: { select: { tasks: { where: { completed: false, parentId: null } } } },
|
||||
},
|
||||
});
|
||||
return NextResponse.json(lists);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
tasks: {
|
||||
where: {
|
||||
task: {
|
||||
isDeleted: false,
|
||||
completed: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(tags);
|
||||
} catch (err) {
|
||||
console.error("[tags:GET]", err);
|
||||
return NextResponse.json({ error: "Failed to fetch tags" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body || !body.name?.trim()) {
|
||||
return NextResponse.json({ error: "Tag name required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = body.name.trim();
|
||||
const color = body.color || "#4B7BF5";
|
||||
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: {
|
||||
userId_name: {
|
||||
userId: session.user.id,
|
||||
name,
|
||||
},
|
||||
},
|
||||
update: { color },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
name,
|
||||
color,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(tag, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[tags:POST]", err);
|
||||
return NextResponse.json({ error: "Failed to create/update tag" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
|
||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// Sanitize allowed fields
|
||||
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId", "isDeleted", "deletedAt"];
|
||||
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId"];
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (key in body) data[key] = body[key];
|
||||
@@ -71,25 +71,9 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
|
||||
data.dueDate = data.dueDate ? new Date(data.dueDate as string) : null;
|
||||
}
|
||||
|
||||
// If restoring a child task (isDeleted: false), also restore ancestor parents
|
||||
if (data.isDeleted === false) {
|
||||
let currentParentId = task.parentId;
|
||||
while (currentParentId) {
|
||||
const parentTask: any = await prisma.task.findUnique({ where: { id: currentParentId } });
|
||||
if (!parentTask) break;
|
||||
if (parentTask.isDeleted) {
|
||||
await prisma.task.update({
|
||||
where: { id: currentParentId },
|
||||
data: { isDeleted: false, deletedAt: null } as any,
|
||||
});
|
||||
}
|
||||
currentParentId = parentTask.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.task.update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
data,
|
||||
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
@@ -99,7 +83,7 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: Ctx) {
|
||||
export async function DELETE(_: NextRequest, { params }: Ctx) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -108,38 +92,10 @@ export async function DELETE(req: NextRequest, { params }: Ctx) {
|
||||
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
|
||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const permanent = searchParams.get("permanent") === "true";
|
||||
|
||||
if (permanent) {
|
||||
await prisma.task.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
// Soft delete task and all its descendants recursively
|
||||
const now = new Date();
|
||||
const collectDescendantIds = async (parentIds: string[]): Promise<string[]> => {
|
||||
if (parentIds.length === 0) return [];
|
||||
const children = await prisma.task.findMany({
|
||||
where: { parentId: { in: parentIds }, userId: session.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
const childIds = children.map((c) => c.id);
|
||||
const grandChildIds = await collectDescendantIds(childIds);
|
||||
return [...childIds, ...grandChildIds];
|
||||
};
|
||||
|
||||
const allDescendants = await collectDescendantIds([id]);
|
||||
const allIdsToDelete = [id, ...allDescendants];
|
||||
|
||||
await prisma.task.updateMany({
|
||||
where: { id: { in: allIdsToDelete }, userId: session.user.id },
|
||||
data: { isDeleted: true, deletedAt: now } as any,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, softDeleted: true });
|
||||
await prisma.task.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[tasks/id:DELETE]", err);
|
||||
return NextResponse.json({ error: "Failed to delete task" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,53 +11,19 @@ export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const listId = searchParams.get("listId");
|
||||
const showCompleted = searchParams.get("showCompleted") === "true";
|
||||
const tagName = searchParams.get("tag");
|
||||
const isTrash = searchParams.get("isTrash") === "true";
|
||||
const countTrash = searchParams.get("countTrash") === "true";
|
||||
|
||||
if (countTrash) {
|
||||
const trashCount = await prisma.task.count({
|
||||
where: { userId: session.user.id, isDeleted: true } as any,
|
||||
});
|
||||
return NextResponse.json({ count: trashCount });
|
||||
}
|
||||
|
||||
if (isTrash) {
|
||||
// Return all deleted tasks (top-level or subtasks)
|
||||
const trashTasks = await prisma.task.findMany({
|
||||
where: { userId: session.user.id, isDeleted: true } as any,
|
||||
orderBy: [{ deletedAt: "desc" } as any, { createdAt: "desc" }],
|
||||
include: {
|
||||
children: {
|
||||
orderBy: [{ sortOrder: "asc" }],
|
||||
include: { tags: { include: { tag: true } } },
|
||||
},
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
});
|
||||
return NextResponse.json(trashTasks);
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
userId: session.user.id,
|
||||
isDeleted: false,
|
||||
...(listId ? { listId, parentId: null } : tagName ? {} : { parentId: null }),
|
||||
parentId: null,
|
||||
...(listId ? { listId } : {}),
|
||||
...(showCompleted ? {} : { completed: false }),
|
||||
...(tagName ? { tags: { some: { tag: { name: tagName } } } } : {}),
|
||||
};
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: where as any,
|
||||
where,
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
include: {
|
||||
children: {
|
||||
where: { isDeleted: false } as any,
|
||||
orderBy: [{ sortOrder: "asc" }],
|
||||
include: {
|
||||
children: { where: { isDeleted: false } as any, orderBy: [{ sortOrder: "asc" }] },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
},
|
||||
children: { orderBy: [{ sortOrder: "asc" }] },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
});
|
||||
@@ -68,29 +34,6 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const trash = searchParams.get("trash") === "true";
|
||||
|
||||
if (trash) {
|
||||
// Empty all trash permanently
|
||||
await prisma.task.deleteMany({
|
||||
where: { userId: session.user.id, isDeleted: true } as any,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid delete request" }, { status: 400 });
|
||||
} catch (err) {
|
||||
console.error("[tasks:DELETE_BATCH]", err);
|
||||
return NextResponse.json({ error: "Failed to empty trash" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
+30
-220
@@ -157,212 +157,48 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TOAST / NOTIFICATIONS & UNDO BANNER
|
||||
SIDEBAR
|
||||
============================================ */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
transition: width var(--dur-slow) var(--ease-out),
|
||||
transform var(--dur-slow) var(--ease-out);
|
||||
z-index: 20;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-md);
|
||||
.sidebar.collapsed {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
animation: toastIn var(--dur-normal) var(--ease-out);
|
||||
pointer-events: all;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: var(--header-height);
|
||||
}
|
||||
|
||||
.toast-btn {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
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;
|
||||
.sidebar-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0;
|
||||
cursor: grab;
|
||||
transition: opacity var(--dur-fast), color var(--dur-fast), transform var(--dur-fast);
|
||||
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);
|
||||
}
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
@@ -459,30 +295,9 @@ a { color: inherit; text-decoration: none; }
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-w, var(--sidebar-width));
|
||||
min-width: var(--sidebar-w, var(--sidebar-width));
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: width var(--dur-normal) var(--ease-out);
|
||||
}
|
||||
|
||||
.sidebar-search-box:hover {
|
||||
border-color: var(--accent) !important;
|
||||
background: var(--bg-primary) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-light);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 8px 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.user-card {
|
||||
@@ -492,15 +307,10 @@ a { color: inherit; text-decoration: none; }
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
border: 1px solid transparent;
|
||||
transition: background var(--dur-fast);
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.user-card:hover { background: var(--bg-hover); }
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TaskList, Task, List, User } from "../tasks/TaskList";
|
||||
import { useUserPrefs, usePrefsStyle } from "@/lib/useUserPrefs";
|
||||
import { TaskDetail } from "../tasks/TaskDetail";
|
||||
import { CommandPalette } from "../ui/CommandPalette";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import {
|
||||
getDemoStore,
|
||||
saveDemoStore,
|
||||
@@ -21,8 +20,6 @@ import {
|
||||
findTaskInTree,
|
||||
getAllTrashTasks,
|
||||
filterTasksByTag,
|
||||
filterActiveTree,
|
||||
removeTaskFromTree,
|
||||
} from "@/lib/mockData";
|
||||
|
||||
interface AppShellProps {
|
||||
@@ -59,7 +56,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Global user preferences (reactive)
|
||||
const { prefs, updatePrefs } = useUserPrefs();
|
||||
@@ -120,9 +116,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
return () => document.removeEventListener("checkflow:openCommandPalette", handler);
|
||||
}, []);
|
||||
|
||||
const [apiTrashCount, setApiTrashCount] = useState(0);
|
||||
|
||||
// Task filter & reconstruct hierarchical structure for Demo and API modes
|
||||
// Demo tasks filter & reconstruct hierarchical structure
|
||||
useEffect(() => {
|
||||
if (isDemo) {
|
||||
const store = getDemoStore();
|
||||
@@ -132,36 +126,10 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
} else if (selectedTag) {
|
||||
setTasks(filterTasksByTag(store.tasks, selectedTag) as Task[]);
|
||||
} else if (selectedListId) {
|
||||
const listTasks = filterActiveTree(store.tasks as MockTask[], showCompleted).filter(
|
||||
(t) => t.listId === selectedListId
|
||||
const listTasks = (store.tasks as Task[]).filter(
|
||||
(t) => !t.isDeleted && t.listId === selectedListId && (showCompleted ? true : !t.completed)
|
||||
);
|
||||
setTasks(listTasks as Task[]);
|
||||
} else {
|
||||
setTasks([]);
|
||||
}
|
||||
} else {
|
||||
// Authenticated API mode
|
||||
// Also fetch trash count for sidebar
|
||||
fetch("/api/tasks?countTrash=true")
|
||||
.then((r) => (r.ok ? r.json() : { count: 0 }))
|
||||
.then((data) => setApiTrashCount(data.count || 0))
|
||||
.catch(() => {});
|
||||
|
||||
if (isTrashActive) {
|
||||
fetch("/api/tasks?isTrash=true")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => setTasks(Array.isArray(data) ? data : []))
|
||||
.catch((err) => console.error("Failed to load trash tasks", err));
|
||||
} else if (selectedTag) {
|
||||
fetch(`/api/tasks?tag=${encodeURIComponent(selectedTag)}&showCompleted=${showCompleted}`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => setTasks(Array.isArray(data) ? data : []))
|
||||
.catch((err) => console.error("Failed to load tag tasks", err));
|
||||
} else if (selectedListId) {
|
||||
fetch(`/api/tasks?listId=${selectedListId}&showCompleted=${showCompleted}`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => setTasks(Array.isArray(data) ? data : []))
|
||||
.catch((err) => console.error("Failed to load list tasks", err));
|
||||
setTasks(listTasks);
|
||||
}
|
||||
}
|
||||
}, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]);
|
||||
@@ -193,28 +161,18 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
|
||||
const handleTagSelect = useCallback((tagName: string | null) => {
|
||||
setIsTrashActive(false);
|
||||
setSelectedListId(null);
|
||||
setSelectedTag(tagName);
|
||||
setSelectedTask(null);
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleTrashSelect = useCallback(() => {
|
||||
setSelectedListId(null);
|
||||
setSelectedTag(null);
|
||||
setIsTrashActive(true);
|
||||
setSelectedTag(null);
|
||||
setSelectedListId(null);
|
||||
setSelectedTask(null);
|
||||
setSidebarOpen(false);
|
||||
if (isDemo) {
|
||||
const store = getDemoStore();
|
||||
setTasks(getAllTrashTasks(store.tasks) as Task[]);
|
||||
} else {
|
||||
fetch("/api/tasks?isTrash=true")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => setTasks(Array.isArray(data) ? data : []))
|
||||
.catch((err) => console.error("Failed to load trash tasks", err));
|
||||
}
|
||||
}, [isDemo]);
|
||||
}, []);
|
||||
|
||||
// Update List Name
|
||||
const handleUpdateListName = async (id: string, newName: string) => {
|
||||
@@ -341,140 +299,42 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
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 (recursively removes task & all descendants)
|
||||
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);
|
||||
if (isTrashActive) {
|
||||
setTasks(getAllTrashTasks(newTasks) as Task[]);
|
||||
} else if (selectedListId) {
|
||||
setTasks(filterActiveTree(newTasks, showCompleted).filter((t) => t.listId === selectedListId) as Task[]);
|
||||
} else if (selectedTag) {
|
||||
setTasks(filterTasksByTag(newTasks, selectedTag) as Task[]);
|
||||
}
|
||||
if (selectedTask?.id === task.id) setSelectedTask(null);
|
||||
} else {
|
||||
// API Mode: Optimistically strip target task and all its subtasks from active tree immediately
|
||||
setTasks((prev) => removeTaskFromTree(prev as MockTask[], task.id) as Task[]);
|
||||
setApiTrashCount((c) => c + 1);
|
||||
if (selectedTask?.id === task.id) setSelectedTask(null);
|
||||
|
||||
fetch(`/api/tasks/${task.id}`, { method: "DELETE" })
|
||||
.then(() => {
|
||||
// Re-sync trash count in background
|
||||
fetch("/api/tasks?countTrash=true")
|
||||
.then((r) => (r.ok ? r.json() : { count: 0 }))
|
||||
.then((data) => setApiTrashCount(data.count || 0))
|
||||
.catch(() => {});
|
||||
})
|
||||
.catch((e) => console.error(e));
|
||||
}
|
||||
|
||||
undoTimerRef.current = setTimeout(() => {
|
||||
setUndoToast(null);
|
||||
}, 6000);
|
||||
}, [isDemo, selectedTask, isTrashActive, selectedListId, selectedTag, showCompleted]);
|
||||
|
||||
// Undo delete (Restores exact soft-deleted task hierarchy)
|
||||
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/${task.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isDeleted: false, deletedAt: null }),
|
||||
});
|
||||
if (res.ok) {
|
||||
refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to restore task via Undo", err);
|
||||
}
|
||||
}
|
||||
}, [undoToast, isDemo, selectedListId, refresh]);
|
||||
|
||||
// Move to Trash (Soft Delete)
|
||||
const handleDemoDeleteTask = (id: string) => {
|
||||
const store = getDemoStore();
|
||||
const target = findTaskInTree(store.tasks, id);
|
||||
if (target) {
|
||||
handleDeleteTaskWithUndo(target as Task);
|
||||
} else {
|
||||
const newTasks = moveToTrashInTree(store.tasks, id);
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
if (selectedTask?.id === id) setSelectedTask(null);
|
||||
const newTasks = moveToTrashInTree(store.tasks, id);
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
if (selectedTask?.id === id) {
|
||||
setSelectedTask(null);
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Restore from Trash (Restores task and all its nested subtasks)
|
||||
const handleRestoreTask = async (id: string) => {
|
||||
// Restore from Trash
|
||||
const handleRestoreTask = (id: string) => {
|
||||
if (isDemo) {
|
||||
const store = getDemoStore();
|
||||
const newTasks = restoreTaskInTree(store.tasks, id);
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks(getAllTrashTasks(newTasks) as Task[]);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
refresh();
|
||||
} else {
|
||||
try {
|
||||
await fetch(`/api/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isDeleted: false, deletedAt: null }),
|
||||
});
|
||||
refresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to restore task", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Permanent Delete
|
||||
const handlePermanentDeleteTask = async (id: string) => {
|
||||
const handlePermanentDeleteTask = (id: string) => {
|
||||
if (isDemo) {
|
||||
const store = getDemoStore();
|
||||
const newTasks = deleteTaskInTree(store.tasks, id);
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks(getAllTrashTasks(newTasks) as Task[]);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
refresh();
|
||||
} else {
|
||||
try {
|
||||
await fetch(`/api/tasks/${id}?permanent=true`, { method: "DELETE" });
|
||||
refresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to permanently delete task", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Empty Trash
|
||||
const handleEmptyTrash = async () => {
|
||||
const handleEmptyTrash = () => {
|
||||
if (!confirm("Permanently empty all items in trash?")) return;
|
||||
if (isDemo) {
|
||||
const store = getDemoStore();
|
||||
@@ -482,13 +342,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks([]);
|
||||
refresh();
|
||||
} else {
|
||||
try {
|
||||
await fetch("/api/tasks?trash=true", { method: "DELETE" });
|
||||
refresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to empty trash", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -555,7 +408,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
onTagSelect={handleTagSelect}
|
||||
isTrashActive={isTrashActive}
|
||||
onTrashSelect={handleTrashSelect}
|
||||
trashCount={isDemo && typeof window !== "undefined" ? getAllTrashTasks(getDemoStore().tasks).length : apiTrashCount}
|
||||
mobileOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
isDemo={isDemo}
|
||||
@@ -583,7 +435,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
|
||||
<div className="main-content">
|
||||
<TaskList
|
||||
key={`${selectedListId}-${isTrashActive}-${selectedTag}`}
|
||||
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
|
||||
user={user}
|
||||
listId={selectedListId}
|
||||
lists={lists}
|
||||
@@ -605,7 +457,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
onDemoToggleTask={handleDemoToggleTask}
|
||||
onUpdateTaskTitle={handleUpdateTaskTitle}
|
||||
onUpdateListName={handleUpdateListName}
|
||||
onDeleteTaskWithUndo={handleDeleteTaskWithUndo}
|
||||
onDeleteTask={isDemo ? handleDemoDeleteTask : async (id: string) => {
|
||||
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
||||
refresh();
|
||||
@@ -673,34 +524,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
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 || t("tasks")}</strong> {t("taskUndoHint")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="undo-toast-actions">
|
||||
<button type="button" className="undo-toast-btn" onClick={handleUndoDelete}>
|
||||
{t("undoDelete")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="undo-toast-close"
|
||||
onClick={() => {
|
||||
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
|
||||
setUndoToast(null);
|
||||
}}
|
||||
title="닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<button
|
||||
className="fab"
|
||||
@@ -716,4 +539,4 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+125
-163
@@ -7,11 +7,10 @@ import { useTheme } from "@/app/providers";
|
||||
import { LanguageSelector } from "@/components/ui/LanguageSelector";
|
||||
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
||||
import { SettingsModal } from "@/components/settings/SettingsModal";
|
||||
import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore, getTagTaskCount, filterActiveTree, MockTask } from "@/lib/mockData";
|
||||
import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore } from "@/lib/mockData";
|
||||
|
||||
interface User { id: string; name?: string | null; email?: string | null }
|
||||
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
||||
interface APITag { id: string; name: string; color: string; _count?: { tasks: number } }
|
||||
|
||||
const LIST_COLORS = ["#5B8DEF", "#E05252", "#3DAD84", "#E8931A", "#8B6CF7", "#E4609B", "#0ABAD1", "#F17A3B", "#6875F5", "#14B8A6"];
|
||||
|
||||
@@ -25,7 +24,6 @@ interface SidebarProps {
|
||||
onTagSelect: (tag: string | null) => void;
|
||||
isTrashActive: boolean;
|
||||
onTrashSelect: () => void;
|
||||
trashCount?: number;
|
||||
mobileOpen: boolean;
|
||||
onClose: () => void;
|
||||
isDemo?: boolean;
|
||||
@@ -50,7 +48,6 @@ export function Sidebar({
|
||||
onTagSelect,
|
||||
isTrashActive,
|
||||
onTrashSelect,
|
||||
trashCount: externalTrashCount,
|
||||
mobileOpen,
|
||||
onClose: _onClose,
|
||||
isDemo = false,
|
||||
@@ -75,10 +72,14 @@ export function Sidebar({
|
||||
const [exportListId, setExportListId] = useState<string>("all");
|
||||
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [tags, setTags] = useState<Array<MockTag | APITag>>(() => (isDemo && typeof window !== "undefined" ? getCustomTags() : []));
|
||||
const [internalTrashCount, setInternalTrashCount] = useState(0);
|
||||
|
||||
const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount;
|
||||
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
||||
const [trashCount, setTrashCount] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
return getAllTrashTasks(store.tasks).length;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Context Menu for Lists
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
|
||||
@@ -90,56 +91,45 @@ export function Sidebar({
|
||||
const [renamingListId, setRenamingListId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
|
||||
// Search expansion
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const initialListSelectedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
setTags(getCustomTags());
|
||||
const store = getDemoStore();
|
||||
const trashed = getAllTrashTasks(store.tasks);
|
||||
setTrashCount(trashed.length);
|
||||
}, [lists]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDemo) {
|
||||
setTags(getCustomTags());
|
||||
const store = getDemoStore();
|
||||
const trashed = getAllTrashTasks(store.tasks);
|
||||
setInternalTrashCount(trashed.length);
|
||||
} else {
|
||||
// Load lists
|
||||
if (!isDemo) {
|
||||
fetch("/api/lists")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setLists(data);
|
||||
if (!initialListSelectedRef.current && !selectedListId && !isTrashActive && !selectedTag && data.length > 0) {
|
||||
initialListSelectedRef.current = true;
|
||||
onListSelect(data[0].id);
|
||||
}
|
||||
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Failed to load lists", err));
|
||||
|
||||
// Load tags
|
||||
fetch("/api/tags")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setTags(data);
|
||||
else setTags([]);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load tags", err);
|
||||
setTags([]);
|
||||
});
|
||||
|
||||
// Load trash count
|
||||
fetch("/api/tasks?countTrash=true")
|
||||
.then((r) => (r.ok ? r.json() : { count: 0 }))
|
||||
.then((data) => setInternalTrashCount(data.count || 0))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isDemo, onListSelect, selectedListId, isTrashActive, selectedTag, setLists, lists.length]);
|
||||
}, [isDemo, onListSelect, selectedListId, setLists]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}, [showNewList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchExpanded) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 60);
|
||||
}
|
||||
}, [searchExpanded]);
|
||||
|
||||
// Dismiss undo toast on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -432,41 +422,91 @@ export function Sidebar({
|
||||
|
||||
return (
|
||||
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
|
||||
{/* Brand Header with Logo & Controls */}
|
||||
<div className="sidebar-header" style={{ padding: "14px 14px 10px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
|
||||
<div
|
||||
className="sidebar-brand-logo"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "var(--radius-md)",
|
||||
background: "linear-gradient(135deg, var(--accent) 0%, #3a62d0 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 6px rgba(75, 123, 245, 0.3)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ffffff" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
<span className="sidebar-title" style={{ fontSize: 15, fontWeight: 700, letterSpacing: "-0.3px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{t("appName")}
|
||||
{/* Header with Logo & Controls */}
|
||||
<div className="sidebar-header" style={{ padding: "12px 12px 8px", gap: 6 }}>
|
||||
<div className="sidebar-logo">✓</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
<span className="sidebar-title" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{t("appName")}
|
||||
</span>
|
||||
{isDemo && (
|
||||
<span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}>
|
||||
{t("demoBadge")}
|
||||
</span>
|
||||
{isDemo && (
|
||||
<span style={{ fontSize: 9.5, color: "var(--accent)", fontWeight: 700, letterSpacing: "0.4px", marginTop: -2 }}>
|
||||
{t("demoBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Controls: Language & Theme with Clear Spacing */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||
{/* Top Controls: Hover-expand Search, Language & Theme */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>
|
||||
{/* Hover-expand Search */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
transition: "all var(--dur-normal) var(--ease-out)",
|
||||
}}
|
||||
onMouseEnter={() => setSearchExpanded(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!searchQuery) setSearchExpanded(false);
|
||||
}}
|
||||
>
|
||||
{searchExpanded ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "var(--bg-primary)",
|
||||
border: "1.5px solid var(--accent)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: "3px 8px",
|
||||
animation: "expandSearch var(--dur-normal) var(--ease-out)",
|
||||
width: 180,
|
||||
boxShadow: "0 0 0 3px var(--accent-light)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "Escape") {
|
||||
document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"));
|
||||
setSearchExpanded(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
}}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
fontSize: 12,
|
||||
color: "var(--text-primary)",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="icon-btn"
|
||||
id="quick-search-btn"
|
||||
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
|
||||
title={t("searchPlaceholder")}
|
||||
style={{ width: 28, height: 28, flexShrink: 0 }}
|
||||
type="button"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LanguageSelector />
|
||||
|
||||
{/* Theme 3-Way Toggle Button */}
|
||||
@@ -475,7 +515,7 @@ export function Sidebar({
|
||||
id="theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
|
||||
style={{ width: 28, height: 28, flexShrink: 0, borderRadius: "var(--radius-sm)" }}
|
||||
style={{ width: 28, height: 28, flexShrink: 0 }}
|
||||
type="button"
|
||||
>
|
||||
{theme === "system" ? (
|
||||
@@ -499,51 +539,6 @@ export function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Centered Minimal Search Bar (Between Header and Lists) */}
|
||||
<div style={{ padding: "4px 12px 10px" }}>
|
||||
<div
|
||||
className="sidebar-search-box"
|
||||
id="quick-search-btn"
|
||||
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "6px 10px",
|
||||
background: "var(--bg-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
cursor: "pointer",
|
||||
transition: "all var(--dur-fast)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
title={`${t("searchPlaceholder")} (Ctrl+K)`}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, minWidth: 0 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.2">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<span style={{ fontSize: 12.5, color: "var(--text-tertiary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{t("searchPlaceholder")}
|
||||
</span>
|
||||
</div>
|
||||
<kbd
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
background: "var(--bg-primary)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-tertiary)",
|
||||
padding: "1px 5px",
|
||||
borderRadius: "var(--radius-xs)",
|
||||
lineHeight: "14px",
|
||||
}}
|
||||
>
|
||||
Ctrl K
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav List */}
|
||||
<nav className="sidebar-nav">
|
||||
{/* Lists Section */}
|
||||
@@ -585,16 +580,7 @@ export function Sidebar({
|
||||
<span className="item-label">{list.name}</span>
|
||||
)}
|
||||
|
||||
<span className="item-count">
|
||||
{isDemo
|
||||
? (() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
const store = getDemoStore();
|
||||
const active = filterActiveTree(store.tasks as MockTask[], false).filter((t) => t.listId === list.id);
|
||||
return active.length > 0 ? active.length : "";
|
||||
})()
|
||||
: (list._count?.tasks ? list._count.tasks : "")}
|
||||
</span>
|
||||
<span className="item-count">{list._count?.tasks || ""}</span>
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: 20, height: 20, opacity: 0, transition: "opacity var(--dur-fast)", flexShrink: 0 }}
|
||||
@@ -619,22 +605,16 @@ export function Sidebar({
|
||||
{tags.length > 0 && (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-label">🏷️ {t("tags") || "Tags"}</div>
|
||||
{tags.map((tag) => {
|
||||
const tagCount = isDemo && typeof window !== "undefined"
|
||||
? getTagTaskCount(getDemoStore().tasks, tag.name)
|
||||
: ((tag as APITag)._count?.tasks ?? 0);
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`}
|
||||
onClick={() => onTagSelect(selectedTag === tag.name ? null : tag.name)}
|
||||
>
|
||||
<span style={{ color: tag.color, fontSize: 13, fontWeight: 700 }}>#</span>
|
||||
<span className="item-label">{tag.name}</span>
|
||||
{tagCount > 0 && <span className="item-count">{tagCount}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`}
|
||||
onClick={() => onTagSelect(selectedTag === tag.name ? null : tag.name)}
|
||||
>
|
||||
<span style={{ color: tag.color, fontSize: 13, fontWeight: 700 }}>#</span>
|
||||
<span className="item-label">{tag.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -643,19 +623,14 @@ export function Sidebar({
|
||||
<div
|
||||
className={`sidebar-item${isTrashActive ? " active" : ""}`}
|
||||
id="trash-menu-btn"
|
||||
onClick={() => {
|
||||
onTagSelect(null);
|
||||
onTrashSelect();
|
||||
}}
|
||||
onClick={onTrashSelect}
|
||||
style={{ color: isTrashActive ? "var(--danger)" : "var(--text-secondary)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.7 }}>
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
|
||||
</svg>
|
||||
<span className="item-label">{t("trash") || "Trash"}</span>
|
||||
{trashCount > 0 ? (
|
||||
<span className="item-count" style={{ color: "var(--danger)", fontWeight: 700 }}>{trashCount}</span>
|
||||
) : null}
|
||||
{trashCount > 0 && <span className="item-count" style={{ color: "var(--danger)" }}>{trashCount}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -764,19 +739,6 @@ export function Sidebar({
|
||||
{t("exportTasks")}
|
||||
</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" />
|
||||
|
||||
{isDemo ? (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useUserPrefs } from "@/lib/useUserPrefs";
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: { id: string; name?: string | null; email?: string | null; role?: string | null };
|
||||
user: { id: string; name?: string | null; email?: string | null };
|
||||
isDemo?: boolean;
|
||||
}
|
||||
|
||||
@@ -125,14 +125,14 @@ function PrefSlider({
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose, user, isDemo = false }: SettingsModalProps) {
|
||||
export function SettingsModal({ isOpen, onClose, user, isDemo: _isDemo = false }: SettingsModalProps) {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile");
|
||||
const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "User"));
|
||||
const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "user@checkflow.local"));
|
||||
const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "Demo User"));
|
||||
const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "demo@checkflow.local"));
|
||||
const [password, setPassword] = useState("");
|
||||
const [trashRetention, setTrashRetention] = useState(() => (typeof window !== "undefined" ? (getUserSettings().trashRetentionDays ?? 30) : 30));
|
||||
const [savedMsg, setSavedMsg] = useState("");
|
||||
@@ -143,8 +143,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const s = getUserSettings();
|
||||
setDisplayName(user.name || s.displayName || "User");
|
||||
setEmail(user.email || s.email || "user@checkflow.local");
|
||||
setDisplayName(user.name || s.displayName);
|
||||
setEmail(user.email || s.email);
|
||||
setTrashRetention(s.trashRetentionDays ?? 30);
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
@@ -177,7 +177,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
language: lang,
|
||||
};
|
||||
saveUserSettings(newSettings);
|
||||
setSavedMsg("✓ " + t("settingsSaved"));
|
||||
setSavedMsg("✓ " + (lang === "ko" ? "설정이 저장되었습니다" : lang === "ja" ? "設定が保存されました" : "Settings saved"));
|
||||
setTimeout(() => {
|
||||
setSavedMsg("");
|
||||
onClose();
|
||||
@@ -192,11 +192,11 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
const accentPreview = `hsl(${hue}, ${sat}%, 54%)`;
|
||||
|
||||
const tabs = [
|
||||
{ id: "profile", label: `👤 ${t("profile")}` },
|
||||
{ id: "preferences", label: `⚙️ ${t("preferences")}` },
|
||||
{ id: "labs", label: `🧪 ${t("labs")}` },
|
||||
{ id: "sync", label: `📱 ${t("syncIntegrations")}` },
|
||||
{ id: "admin", label: `👑 ${t("admin")}` },
|
||||
{ id: "profile", label: `👤 ${t("profile") || "Profile"}` },
|
||||
{ id: "preferences", label: `⚙️ ${t("preferences") || "Preferences"}` },
|
||||
{ id: "labs", label: `🧪 ${t("labs") || "Labs"}` },
|
||||
{ id: "sync", label: `📱 ${t("syncIntegrations") || "Integrations"}` },
|
||||
{ id: "admin", label: `👑 ${t("admin") || "Admin"}` },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
@@ -208,7 +208,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
|
||||
<h2 className="modal-title" style={{ marginBottom: 0, fontSize: 17 }}>⚙️ {t("settingsModalTitle")}</h2>
|
||||
<h2 className="modal-title" style={{ marginBottom: 0, fontSize: 17 }}>⚙️ {t("settingsModalTitle") || "Settings"}</h2>
|
||||
<button className="icon-btn" onClick={onClose} aria-label="Close" type="button">✕</button>
|
||||
</div>
|
||||
|
||||
@@ -255,32 +255,15 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
<div className="settings-tab-content">
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("displayName")}</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={t("yourNamePlaceholder")}
|
||||
/>
|
||||
<input className="form-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your Name" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("email")}</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
<input className="form-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your.email@example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("password")}</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordChangePlaceholder")}
|
||||
/>
|
||||
<label className="form-label">{t("password")} (Change)</label>
|
||||
<input className="form-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="New password (leave blank to keep current)" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -290,16 +273,16 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
<div className="settings-tab-content">
|
||||
<div className="form-group">
|
||||
<label className="form-label" style={{ fontWeight: 600 }}>
|
||||
🗑️ {t("trashRetention")}
|
||||
🗑️ {t("trashRetention") || "Trash Auto-Delete Retention Period"}
|
||||
</label>
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 8 }}>
|
||||
{t("trashRetentionHint")}
|
||||
{t("trashRetentionHint") || "Deleted tasks will be permanently removed after the specified period."}
|
||||
</p>
|
||||
<select className="form-input" value={trashRetention} onChange={(e) => setTrashRetention(parseInt(e.target.value, 10))}>
|
||||
<option value={7}>{t("days7")}</option>
|
||||
<option value={14}>{t("days14")}</option>
|
||||
<option value={30}>{t("days30")}</option>
|
||||
<option value={0}>{t("neverDelete")}</option>
|
||||
<option value={7}>{t("days7") || "7 Days"}</option>
|
||||
<option value={14}>{t("days14") || "14 Days"}</option>
|
||||
<option value={30}>{t("days30") || "30 Days (Recommended)"}</option>
|
||||
<option value={0}>{t("neverDelete") || "Never Auto-Delete (Manual empty only)"}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -337,46 +320,46 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
<div className="settings-tab-content">
|
||||
{/* Section: View */}
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}>
|
||||
{t("labsSectionView")}
|
||||
View
|
||||
</div>
|
||||
|
||||
<LabsRow
|
||||
label={`📊 ${t("labsKanbanTitle")}`}
|
||||
desc={t("labsKanbanDesc")}
|
||||
label="📊 Kanban Board"
|
||||
desc="Switch between list view and 3-column Kanban board (To Do / In Progress / Done)."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={prefs.labs?.kanbanBoard ? "enabled" : "disabled"}
|
||||
value={prefs.viewMode}
|
||||
options={[
|
||||
{ label: t("animationOff"), value: "disabled" },
|
||||
{ label: "ON", value: "enabled" },
|
||||
{ label: "List", value: "list" },
|
||||
{ label: "Kanban", value: "kanban" },
|
||||
]}
|
||||
onChange={(v) => updatePrefs({ labs: { kanbanBoard: v === "enabled" } })}
|
||||
onChange={(v) => updatePrefs({ viewMode: v })}
|
||||
/>
|
||||
</LabsRow>
|
||||
|
||||
{/* Section: Layout */}
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
|
||||
{t("labsSectionLayout")}
|
||||
Layout
|
||||
</div>
|
||||
|
||||
<LabsRow
|
||||
label={`📐 ${t("labsDensityTitle")}`}
|
||||
desc={t("labsDensityDesc")}
|
||||
label="📐 Content Density"
|
||||
desc="Controls the vertical spacing of task items."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={prefs.density}
|
||||
options={[
|
||||
{ label: t("densityCompact"), value: "compact" },
|
||||
{ label: t("densityDefault"), value: "default" },
|
||||
{ label: t("densityComfortable"), value: "comfortable" },
|
||||
{ label: "Compact", value: "compact" },
|
||||
{ label: "Default", value: "default" },
|
||||
{ label: "Airy", value: "comfortable" },
|
||||
]}
|
||||
onChange={(v) => updatePrefs({ density: v })}
|
||||
/>
|
||||
</LabsRow>
|
||||
|
||||
<LabsRow
|
||||
label={`↔️ ${t("sidebarWidthTitle")}`}
|
||||
desc={`${t("sidebarWidthDesc")} (${prefs.sidebarWidth}px)`}
|
||||
label="↔️ Sidebar Width"
|
||||
desc={`Drag the sidebar edge or adjust here. (${prefs.sidebarWidth}px)`}
|
||||
>
|
||||
<PrefSlider
|
||||
min={160}
|
||||
@@ -388,8 +371,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
</LabsRow>
|
||||
|
||||
<LabsRow
|
||||
label={`↔️ ${t("detailWidthTitle")}`}
|
||||
desc={`${t("detailWidthDesc")} (${prefs.detailWidth}px)`}
|
||||
label="↔️ Detail Panel Width"
|
||||
desc={`Drag the panel edge or adjust here. (${prefs.detailWidth}px)`}
|
||||
>
|
||||
<PrefSlider
|
||||
min={300}
|
||||
@@ -402,27 +385,27 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
|
||||
{/* Section: Appearance */}
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
|
||||
{t("labsSectionAppearance")}
|
||||
Appearance
|
||||
</div>
|
||||
|
||||
<LabsRow
|
||||
label={`🔤 ${t("fontSizeTitle")}`}
|
||||
desc={t("fontSizeDesc")}
|
||||
label="🔤 Font Size"
|
||||
desc="Base font size across the app."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={prefs.fontSize}
|
||||
options={[
|
||||
{ label: t("fontSizeSmall"), value: "small" },
|
||||
{ label: t("fontSizeMedium"), value: "default" },
|
||||
{ label: t("fontSizeLarge"), value: "large" },
|
||||
{ label: "S", value: "small" },
|
||||
{ label: "M", value: "default" },
|
||||
{ label: "L", value: "large" },
|
||||
]}
|
||||
onChange={(v) => updatePrefs({ fontSize: v })}
|
||||
/>
|
||||
</LabsRow>
|
||||
|
||||
<LabsRow
|
||||
label={`🎨 ${t("accentColorTitle")}`}
|
||||
desc={t("accentColorDesc")}
|
||||
label="🎨 Accent Color"
|
||||
desc="Choose the hue of your accent color. Saturation controls vibrancy."
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
|
||||
{/* Hue ring preview */}
|
||||
@@ -448,7 +431,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("saturationLabel")}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Saturation</span>
|
||||
<PrefSlider
|
||||
min={20}
|
||||
max={100}
|
||||
@@ -459,7 +442,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
</div>
|
||||
{/* Custom hue input */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("hueLabel")}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Hue °</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
@@ -474,31 +457,31 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
</LabsRow>
|
||||
|
||||
<LabsRow
|
||||
label={`⬛ ${t("roundnessTitle")}`}
|
||||
desc={t("roundnessDesc")}
|
||||
label="⬛ Border Roundness"
|
||||
desc="Controls the roundness of cards, buttons, and UI elements."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={prefs.roundness}
|
||||
options={[
|
||||
{ label: t("roundnessSharp"), value: "sharp" },
|
||||
{ label: t("roundnessDefault"), value: "default" },
|
||||
{ label: t("roundnessRound"), value: "round" },
|
||||
{ label: "Sharp", value: "sharp" },
|
||||
{ label: "Default", value: "default" },
|
||||
{ label: "Round", value: "round" },
|
||||
]}
|
||||
onChange={(v) => updatePrefs({ roundness: v })}
|
||||
/>
|
||||
</LabsRow>
|
||||
|
||||
<LabsRow
|
||||
label={`⚡ ${t("animationSpeedTitle")}`}
|
||||
desc={t("animationSpeedDesc")}
|
||||
label="⚡ Animation Speed"
|
||||
desc="Controls the speed of transitions and hover effects."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={prefs.animationSpeed}
|
||||
options={[
|
||||
{ label: t("animationOff"), value: "none" },
|
||||
{ label: t("animationFast"), value: "fast" },
|
||||
{ label: t("animationDefault"), value: "default" },
|
||||
{ label: t("animationSlow"), value: "slow" },
|
||||
{ label: "Off", value: "none" },
|
||||
{ label: "Fast", value: "fast" },
|
||||
{ label: "Default", value: "default" },
|
||||
{ label: "Slow", value: "slow" },
|
||||
]}
|
||||
onChange={(v) => updatePrefs({ animationSpeed: v })}
|
||||
/>
|
||||
@@ -507,20 +490,20 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
{/* Reset to Defaults */}
|
||||
<div style={{ marginTop: 20, borderTop: "1px solid var(--border)", paddingTop: 16 }}>
|
||||
<LabsRow
|
||||
label={`🔄 ${t("resetDefaultsTitle")}`}
|
||||
desc={t("resetDefaultsDesc")}
|
||||
label="🔄 Reset to Defaults"
|
||||
desc="Restores all layout, appearance, and view settings to their factory defaults."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ color: "var(--danger)", border: "1px solid var(--danger)", background: "transparent" }}
|
||||
onClick={() => {
|
||||
if (confirm(t("resetConfirm"))) {
|
||||
if (confirm(t("resetConfirm") || "Reset all customizations to defaults?")) {
|
||||
resetToDefaults();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("resetDefaults")}
|
||||
{t("resetDefaults") || "Reset"}
|
||||
</button>
|
||||
</LabsRow>
|
||||
</div>
|
||||
@@ -531,15 +514,15 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
{activeTab === "sync" && (
|
||||
<div className="settings-tab-content">
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>
|
||||
📱 {t("syncTitle")}
|
||||
📱 {t("syncTitle") || "Galaxy & External Sync (CalDAV)"}
|
||||
</h3>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 14 }}>
|
||||
{t("syncDesc")}
|
||||
{t("syncDesc") || "CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV."}
|
||||
</p>
|
||||
|
||||
{/* Endpoint bar & Action buttons */}
|
||||
<div className="form-group" style={{ marginBottom: 14 }}>
|
||||
<label className="form-label" style={{ fontWeight: 600 }}>{t("syncBaseUrl")}</label>
|
||||
<label className="form-label" style={{ fontWeight: 600 }}>{t("syncBaseUrl") || "CalDAV Server Base URL"}</label>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<input
|
||||
className="form-input"
|
||||
@@ -558,7 +541,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
}}
|
||||
style={{ minWidth: 80, fontWeight: 600 }}
|
||||
>
|
||||
{copiedCalDav ? `✓ ${t("syncCopied")}` : `📋 ${t("syncCopyUrl")}`}
|
||||
{copiedCalDav ? `✓ ${t("syncCopied") || "Copied!"}` : `📋 ${t("syncCopyUrl") || "Copy"}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -572,7 +555,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
disabled={testSyncStatus === "testing"}
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
{testSyncStatus === "testing" ? `⏳ ${t("syncTesting")}` : `🔍 ${t("syncTestConnection")}`}
|
||||
{testSyncStatus === "testing" ? `⏳ ${t("syncTesting") || "Testing..."}` : `🔍 ${t("syncTestConnection") || "Test Endpoint"}`}
|
||||
</button>
|
||||
<a
|
||||
href="/api/dav"
|
||||
@@ -581,16 +564,16 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
className="btn btn-sm btn-ghost"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none" }}
|
||||
>
|
||||
📥 {t("syncDownloadIcs")}
|
||||
📥 {t("syncDownloadIcs") || "Download .ICS Feed"}
|
||||
</a>
|
||||
{testSyncStatus === "success" && (
|
||||
<span style={{ fontSize: 12, color: "var(--success)", fontWeight: 600 }}>
|
||||
✓ {t("syncTestSuccess")}
|
||||
{t("syncTestSuccess") || "✓ CalDAV endpoint responded successfully"}
|
||||
</span>
|
||||
)}
|
||||
{testSyncStatus === "error" && (
|
||||
<span style={{ fontSize: 12, color: "var(--danger)", fontWeight: 600 }}>
|
||||
✕ {t("syncTestError")}
|
||||
✕ Endpoint test failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -600,9 +583,9 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
<SegmentedControl
|
||||
value={syncPlatform}
|
||||
options={[
|
||||
{ label: `🤖 ${t("syncTabAndroid")}`, value: "android" },
|
||||
{ label: `🍎 ${t("syncTabApple")}`, value: "apple" },
|
||||
{ label: `🦅 ${t("syncTabThunderbird")}`, value: "thunderbird" },
|
||||
{ label: `🤖 ${t("syncTabAndroid") || "Galaxy / DAVx⁵"}`, value: "android" },
|
||||
{ label: `🍎 ${t("syncTabApple") || "Apple Reminders"}`, value: "apple" },
|
||||
{ label: `💻 ${t("syncTabThunderbird") || "Thunderbird"}`, value: "thunderbird" },
|
||||
]}
|
||||
onChange={(v) => setSyncPlatform(v)}
|
||||
/>
|
||||
@@ -623,7 +606,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
{syncPlatform === "android" && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
|
||||
📱 Android (DAVx⁵ + Tasks.org / OpenTasks)
|
||||
📱 Samsung Galaxy & Android (DAVx⁵ + Reminder / OpenTasks)
|
||||
</div>
|
||||
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<li>{t("syncAndroidStep1")}</li>
|
||||
@@ -632,7 +615,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
<li>{t("syncAndroidStep4")}</li>
|
||||
</ol>
|
||||
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
|
||||
💡 <strong>Tip:</strong> {t("syncAndroidTip")}
|
||||
💡 <strong>Tip:</strong> In DAVx⁵ account settings, set Sync Interval to <strong>15 minutes</strong> for battery efficiency and near real-time sync.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -649,7 +632,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
<li>{t("syncAppleStep4")}</li>
|
||||
</ol>
|
||||
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
|
||||
💡 <strong>Tip:</strong> {t("syncAppleTip")}
|
||||
💡 <strong>Tip:</strong> If using HTTPS behind a reverse proxy (Nginx/Caddy), ensure valid SSL certificates are trusted by Apple devices.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -666,7 +649,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
<li>{t("syncThunderbirdStep4")}</li>
|
||||
</ol>
|
||||
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
|
||||
💡 <strong>Tip:</strong> {t("syncThunderbirdTip")}
|
||||
💡 <strong>Tip:</strong> Thunderbird Tasks view will display CheckFlow priority tags, due dates, and completion status.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -677,74 +660,13 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
{/* Tab 4: Admin Quick Access */}
|
||||
{activeTab === "admin" && (
|
||||
<div className="settings-tab-content">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 24 }}>👑</span>
|
||||
<div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>
|
||||
{t("admin")}
|
||||
</h3>
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
|
||||
Multi-user administration, account control, and system telemetry
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
padding: 16,
|
||||
border: "1px solid var(--border)",
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)" }}>{t("adminCurrentAccount")}:</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)" }}>{t("adminIsolationMode")}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--success)" }}>● {t("adminMultiUserPrivacy")}</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)" }}>{t("adminCalDavEndpoint")}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--accent)" }}>● {t("adminActiveEnabled")}</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" }}
|
||||
>
|
||||
🚀 {t("adminOpenDashboard")}
|
||||
</a>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>👑 Multi-User Admin Console</h3>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 14 }}>
|
||||
Manage registered users, user roles (User/Admin), system statistics, and storage allocations.
|
||||
</p>
|
||||
<a href="/admin" className="btn btn-primary" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
🚀 Open Admin Dashboard →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -758,11 +680,11 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
|
||||
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
|
||||
<button className="btn btn-ghost" onClick={onClose} type="button">{t("cancel")}</button>
|
||||
<button className="btn btn-primary" onClick={handleSave} type="button">
|
||||
{t("save")}
|
||||
{t("save") || "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,27 +101,6 @@ export function KanbanView({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.tags && task.tags.length > 0 && task.tags.map((tg) => {
|
||||
const tagName = typeof tg === "string" ? tg : tg.tag?.name;
|
||||
const tagKey = typeof tg === "string" ? tg : tg.tag?.id || tg.tag?.name;
|
||||
if (!tagName) return null;
|
||||
return (
|
||||
<span
|
||||
key={tagKey}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
padding: "2px 6px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
background: "rgba(59, 130, 246, 0.12)",
|
||||
color: "#3B82F6",
|
||||
}}
|
||||
>
|
||||
#{tagName}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
{subtaskCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -12,16 +12,10 @@ interface MarkdownNoteEditorProps {
|
||||
|
||||
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
|
||||
const { t } = useI18n();
|
||||
// Default to preview mode as requested
|
||||
const [mode, setMode] = useState<"edit" | "preview">("preview");
|
||||
const [mode, setMode] = useState<"edit" | "preview">("edit");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset to preview whenever value or active task changes
|
||||
useEffect(() => {
|
||||
// Keep preview mode as primary
|
||||
}, []);
|
||||
|
||||
// Configure marked to open links in new tabs safely
|
||||
useEffect(() => {
|
||||
const renderer = new marked.Renderer();
|
||||
@@ -54,7 +48,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
||||
// Convert markdown to sanitized HTML with safe links
|
||||
const renderMarkdownHtml = () => {
|
||||
if (!value || !value.trim()) {
|
||||
return `<div style="color: var(--text-tertiary); font-style: italic; user-select: none; line-height: 1.6;">${t("notesPlaceholder")}</div>`;
|
||||
return `<p style="color: var(--text-tertiary); font-style: italic; padding: 8px 0;">${t("notesPlaceholder").split("\n")[0]}</p>`;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -101,57 +95,37 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "4px 8px 8px 8px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}>
|
||||
{t("notes").toUpperCase()}
|
||||
</span>
|
||||
|
||||
{/* Minimal Matte One-touch Mode Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
padding: "2px 8px",
|
||||
height: 24,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
border: "1px solid var(--border)",
|
||||
background: mode === "edit" ? "var(--accent-light)" : "var(--bg-secondary)",
|
||||
color: mode === "edit" ? "var(--accent)" : "var(--text-secondary)",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
transition: "all var(--dur-fast)",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (mode === "preview") {
|
||||
<div className="view-switcher-group" style={{ padding: 2 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-switcher-btn${mode === "edit" ? " active" : ""}`}
|
||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
} else {
|
||||
setMode("preview");
|
||||
}
|
||||
}}
|
||||
title={mode === "preview" ? "Switch to Edit" : "Switch to Preview"}
|
||||
>
|
||||
{mode === "preview" ? "✏️ Edit" : "👁️ Preview"}
|
||||
</button>
|
||||
}}
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-switcher-btn${mode === "preview" ? " active" : ""}`}
|
||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||
onClick={() => setMode("preview")}
|
||||
>
|
||||
👁️ Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor / Preview Area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
background: "var(--bg-primary)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--border)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", position: "relative" }}>
|
||||
{mode === "edit" ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -160,7 +134,8 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "10px 12px",
|
||||
minHeight: 120,
|
||||
padding: "8px 10px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
@@ -169,7 +144,6 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
||||
lineHeight: 1.6,
|
||||
resize: "none",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
placeholder={t("notesPlaceholder")}
|
||||
value={value}
|
||||
@@ -187,13 +161,11 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "10px 12px",
|
||||
padding: "8px 10px",
|
||||
overflowY: "auto",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.6,
|
||||
boxSizing: "border-box",
|
||||
cursor: "text",
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
||||
onClick={() => {
|
||||
@@ -205,4 +177,4 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,30 +40,11 @@ export function TaskDetail({
|
||||
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
|
||||
const [priority, setPriority] = useState(task.priority);
|
||||
const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []);
|
||||
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (isDemo && typeof window !== "undefined" ? getCustomTags() : []));
|
||||
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
||||
const [showTagPicker, setShowTagPicker] = useState(false);
|
||||
const [showListPicker, setShowListPicker] = useState(false);
|
||||
const [showPriorityPicker, setShowPriorityPicker] = useState(false);
|
||||
const [newTagName, setNewTagName] = useState("");
|
||||
|
||||
// Load user's actual tags in non-demo mode
|
||||
useEffect(() => {
|
||||
if (isDemo) {
|
||||
if (typeof window !== "undefined") {
|
||||
setAllAvailableTags(getCustomTags());
|
||||
}
|
||||
} else {
|
||||
fetch("/api/tags")
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setAllAvailableTags(data);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Failed to fetch user tags", err));
|
||||
}
|
||||
}, [isDemo]);
|
||||
|
||||
// Modular Blocks Customization via global prefs
|
||||
const { prefs, updatePrefs } = useUserPrefs();
|
||||
const blockOrder = prefs.detailBlockOrder;
|
||||
@@ -179,8 +160,8 @@ export function TaskDetail({
|
||||
const updatedTask: Task = {
|
||||
...task,
|
||||
...data,
|
||||
children: (data.children as Task[]) || subtasks,
|
||||
tags: (data.tags as { tag: Tag }[]) || tags,
|
||||
children: subtasks,
|
||||
tags,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Task;
|
||||
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
|
||||
@@ -262,43 +243,17 @@ export function TaskDetail({
|
||||
save(task.id, { tags: nextTags });
|
||||
};
|
||||
|
||||
const addCustomTag = async () => {
|
||||
const addCustomTag = () => {
|
||||
const trimmed = newTagName.trim().replace(/^#/, "");
|
||||
if (!trimmed) return;
|
||||
const randomColor = ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)];
|
||||
|
||||
if (isDemo) {
|
||||
const newTag: MockTag = {
|
||||
id: "tag-" + Date.now(),
|
||||
name: trimmed,
|
||||
color: randomColor,
|
||||
};
|
||||
const updatedAvailable = [...allAvailableTags, newTag];
|
||||
setAllAvailableTags(updatedAvailable);
|
||||
if (typeof window !== "undefined") {
|
||||
const { saveCustomTags } = require("@/lib/mockData");
|
||||
saveCustomTags(updatedAvailable);
|
||||
}
|
||||
toggleTag(newTag);
|
||||
setNewTagName("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: trimmed, color: randomColor }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const createdTag = await res.json();
|
||||
setAllAvailableTags((prev) => [...prev.filter((tItem) => tItem.id !== createdTag.id), createdTag]);
|
||||
toggleTag(createdTag);
|
||||
setNewTagName("");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create tag", err);
|
||||
}
|
||||
const newTag: MockTag = {
|
||||
id: "tag-" + Date.now(),
|
||||
name: trimmed,
|
||||
color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)],
|
||||
};
|
||||
setAllAvailableTags((p) => [...p, newTag]);
|
||||
toggleTag(newTag);
|
||||
setNewTagName("");
|
||||
};
|
||||
|
||||
const addSubtask = useCallback(async () => {
|
||||
@@ -746,63 +701,31 @@ export function TaskDetail({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Priority custom popover chip */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tick-meta-chip${priority > 0 ? " active" : ""}`}
|
||||
onClick={() => setShowPriorityPicker((p) => !p)}
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
padding: "3px 8px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
border: "1px solid var(--border)",
|
||||
color: priority > 0 ? priorityMap[priority].color : "var(--text-tertiary)",
|
||||
borderColor: priority > 0 ? priorityMap[priority].color : "var(--border)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title={t("selectPriority")}
|
||||
>
|
||||
<span>🚩</span>
|
||||
<span>{priorityMap[priority].label}</span>
|
||||
</button>
|
||||
|
||||
{showPriorityPicker && (
|
||||
<div
|
||||
className="dropdown"
|
||||
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 130, padding: 4, zIndex: 130 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{priorityMap.map((p, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="context-menu-item"
|
||||
style={{
|
||||
padding: "5px 8px",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: idx > 0 ? p.color : "inherit",
|
||||
fontWeight: priority === idx ? 700 : 400,
|
||||
}}
|
||||
onClick={() => {
|
||||
setPriority(idx);
|
||||
save(task.id, { priority: idx });
|
||||
setShowPriorityPicker(false);
|
||||
}}
|
||||
>
|
||||
<span>{idx === 0 ? "○" : "●"}</span>
|
||||
<span>{p.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Priority selector */}
|
||||
<select
|
||||
className="tick-meta-chip"
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
padding: "3px 8px",
|
||||
cursor: "pointer",
|
||||
border: "1px solid var(--border)",
|
||||
color: priority > 0 ? priorityMap[priority].color : "inherit",
|
||||
fontWeight: priority > 0 ? 700 : 500,
|
||||
}}
|
||||
value={priority}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
setPriority(val);
|
||||
save(task.id, { priority: val });
|
||||
}}
|
||||
>
|
||||
{priorityMap.map((p, idx) => (
|
||||
<option key={idx} value={idx}>
|
||||
{p.icon ? `${p.icon} ` : ""}
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Tag Selector */}
|
||||
<div style={{ position: "relative" }}>
|
||||
|
||||
+143
-487
@@ -4,7 +4,6 @@ import { useI18n } from "@/lib/i18n";
|
||||
import { ContextMenu } from "@/components/ui/ContextMenu";
|
||||
import { KanbanView } from "./KanbanView";
|
||||
import { useUserPrefs } from "@/lib/useUserPrefs";
|
||||
import { getDemoStore, saveDemoStore, MockTask } from "@/lib/mockData";
|
||||
|
||||
export interface Tag { id: string; name: string; color: string }
|
||||
|
||||
@@ -44,7 +43,6 @@ interface TaskItemProps {
|
||||
isTrashMode?: boolean;
|
||||
onRestore?: (id: string) => void;
|
||||
onPermanentDelete?: (id: string) => void;
|
||||
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after" | "inside" | "root") => void;
|
||||
}
|
||||
|
||||
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
|
||||
@@ -60,7 +58,6 @@ function RecursiveTaskItem({
|
||||
isTrashMode = false,
|
||||
onRestore,
|
||||
onPermanentDelete,
|
||||
onDragTask,
|
||||
}: TaskItemProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
@@ -157,21 +154,11 @@ function RecursiveTaskItem({
|
||||
touchStartX.current = null;
|
||||
};
|
||||
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
||||
<div
|
||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}${isDragOver ? " drag-over-nested" : ""}`}
|
||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelect(task)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2" && !isTrashMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
}
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
@@ -180,65 +167,14 @@ function RecursiveTaskItem({
|
||||
e.stopPropagation();
|
||||
onContextMenu(e.clientX, e.clientY, task);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (isTrashMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (!isDragOver) setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (isTrashMode) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
const draggedId = e.dataTransfer.getData("text/plain");
|
||||
if (draggedId && draggedId !== task.id && onDragTask) {
|
||||
// Unified interaction: drop on task to nest as subtask
|
||||
onDragTask(draggedId, task.id, "inside");
|
||||
}
|
||||
}}
|
||||
id={`task-${task.id}`}
|
||||
style={{
|
||||
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
|
||||
marginLeft: depth > 0 ? 8 : 0,
|
||||
transform: `translateX(${swipeOffset}px)`,
|
||||
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
||||
outline: isDragOver ? "2px dashed var(--accent)" : undefined,
|
||||
outlineOffset: -2,
|
||||
background: isDragOver ? "var(--accent-light, rgba(75, 123, 245, 0.12))" : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Notion-style 6-dot Drag Handle */}
|
||||
{!isTrashMode && (
|
||||
<div
|
||||
className="task-drag-handle"
|
||||
draggable="true"
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.setData("application/task-id", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
title="Drag to reorder"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
||||
<circle cx="5" cy="3" r="1.5" />
|
||||
<circle cx="11" cy="3" r="1.5" />
|
||||
<circle cx="5" cy="8" r="1.5" />
|
||||
<circle cx="11" cy="8" r="1.5" />
|
||||
<circle cx="5" cy="13" r="1.5" />
|
||||
<circle cx="11" cy="13" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle Expand Arrow if has children */}
|
||||
{totalChildren > 0 ? (
|
||||
<button
|
||||
@@ -303,24 +239,17 @@ function RecursiveTaskItem({
|
||||
style={{ padding: "2px 6px", fontSize: depth > 0 ? 13 : 14, fontWeight: 500, height: 26, width: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: "inline-flex", alignItems: "center", maxWidth: "100%" }}>
|
||||
<span
|
||||
className="task-title"
|
||||
onDoubleClick={(e) => {
|
||||
if (isTrashMode) return;
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
title="Double click or press F2 to edit"
|
||||
style={{
|
||||
fontSize: depth > 0 ? 13 : 14,
|
||||
fontWeight: depth === 0 ? 600 : 500,
|
||||
cursor: "text",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
<div
|
||||
className="task-title"
|
||||
onDoubleClick={(e) => {
|
||||
if (isTrashMode) return;
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
title="Double click to edit"
|
||||
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
|
||||
>
|
||||
{task.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -381,6 +310,23 @@ function RecursiveTaskItem({
|
||||
))}
|
||||
</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>
|
||||
|
||||
{task.note && !task.completed && depth === 0 && (
|
||||
@@ -388,9 +334,9 @@ function RecursiveTaskItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Actions: Subtask Add Button & Trash Mode Controls */}
|
||||
{/* Trash Mode Actions or Edit Icon */}
|
||||
{isTrashMode ? (
|
||||
<div style={{ display: "flex", gap: 6, marginLeft: "auto" }}>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||
@@ -417,27 +363,20 @@ function RecursiveTaskItem({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="task-actions-right">
|
||||
<button
|
||||
className="badge badge-neutral"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingSubtask(true);
|
||||
setExpanded(true);
|
||||
}}
|
||||
title="Add subtask"
|
||||
type="button"
|
||||
>
|
||||
+ {t("subtasks")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
title="Edit title"
|
||||
type="button"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -458,7 +397,6 @@ function RecursiveTaskItem({
|
||||
isTrashMode={isTrashMode}
|
||||
onRestore={onRestore}
|
||||
onPermanentDelete={onPermanentDelete}
|
||||
onDragTask={onDragTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -522,7 +460,6 @@ interface Props {
|
||||
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
||||
onUpdateTaskTitle?: (id: string, title: string) => void;
|
||||
onUpdateListName?: (id: string, name: string) => void;
|
||||
onDeleteTaskWithUndo?: (task: Task) => void;
|
||||
onDeleteTask?: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -548,7 +485,6 @@ export function TaskList({
|
||||
onDemoToggleTask,
|
||||
onUpdateTaskTitle,
|
||||
onUpdateListName,
|
||||
onDeleteTaskWithUndo,
|
||||
onDeleteTask,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
@@ -560,12 +496,11 @@ export function TaskList({
|
||||
|
||||
const { prefs, updatePrefs } = useUserPrefs();
|
||||
// Only show kanban if the Labs flag is explicitly enabled
|
||||
const kanbanEnabled = Boolean(prefs.labs?.kanbanBoard);
|
||||
const viewMode = kanbanEnabled ? (prefs.viewMode || "list") : "list";
|
||||
const kanbanEnabled = prefs.labs?.kanbanBoard ?? false;
|
||||
const viewMode = kanbanEnabled ? prefs.viewMode : "list";
|
||||
|
||||
const handleToggleViewMode = () => {
|
||||
const nextMode = viewMode === "kanban" ? "list" : "kanban";
|
||||
updatePrefs({ viewMode: nextMode });
|
||||
const handleViewModeChange = (mode: "list" | "kanban") => {
|
||||
updatePrefs({ viewMode: mode });
|
||||
};
|
||||
|
||||
// TickTick-style Quick Add Preset states
|
||||
@@ -616,35 +551,8 @@ export function TaskList({
|
||||
return () => document.removeEventListener("checkflow:addTask", handler);
|
||||
}, []);
|
||||
|
||||
// Recursive update helper for local tree optimistic state
|
||||
const updateTaskCompletedInTree = (tree: Task[], targetId: string, isComp: boolean): Task[] => {
|
||||
return tree.map((node) => {
|
||||
if (node.id === targetId) {
|
||||
return {
|
||||
...node,
|
||||
completed: isComp,
|
||||
completedAt: isComp ? new Date().toISOString() : null,
|
||||
children: node.children ? updateTaskCompletedInTree(node.children, targetId, isComp) : [],
|
||||
};
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: updateTaskCompletedInTree(node.children, targetId, isComp),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (id: string, completed: boolean) => {
|
||||
// 1. Optimistic UI update immediately
|
||||
setTasks((prev) => {
|
||||
const updated = updateTaskCompletedInTree(prev, id, completed);
|
||||
return showCompleted ? updated : updated.filter((t) => t.id !== id || !completed);
|
||||
});
|
||||
|
||||
if (isDemo) {
|
||||
if (onDemoToggleTask) onDemoToggleTask(id, completed);
|
||||
return;
|
||||
@@ -660,11 +568,9 @@ export function TaskList({
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("[TaskList] handleToggle failed", err);
|
||||
// Rollback on failure
|
||||
fetchTasks();
|
||||
}
|
||||
},
|
||||
[isDemo, onDemoToggleTask, onRefresh, showCompleted, setTasks, fetchTasks]
|
||||
[isDemo, onDemoToggleTask, onRefresh]
|
||||
);
|
||||
|
||||
const handleAddTask = async (e: React.FormEvent) => {
|
||||
@@ -703,43 +609,8 @@ export function TaskList({
|
||||
}
|
||||
};
|
||||
|
||||
const insertSubtaskInTree = (tree: Task[], pId: string, subtask: Task): Task[] => {
|
||||
return tree.map((node) => {
|
||||
if (node.id === pId) {
|
||||
const currentChildren = node.children || [];
|
||||
return { ...node, children: [...currentChildren, subtask] };
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
return { ...node, children: insertSubtaskInTree(node.children, pId, subtask) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddSubtaskInline = async (title: string, parentId: string) => {
|
||||
if (!listId) return;
|
||||
|
||||
const tempId = "temp-subtask-" + Date.now();
|
||||
const tempSubtask: Task = {
|
||||
id: tempId,
|
||||
listId,
|
||||
parentId,
|
||||
title: title.trim(),
|
||||
note: null,
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
dueDate: null,
|
||||
priority: 0,
|
||||
sortOrder: 999,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
children: [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
// Optimistic insert
|
||||
setTasks((prev) => insertSubtaskInTree(prev, parentId, tempSubtask));
|
||||
|
||||
if (isDemo) {
|
||||
if (onDemoAddTask) onDemoAddTask(title, listId, parentId);
|
||||
return;
|
||||
@@ -752,27 +623,10 @@ export function TaskList({
|
||||
body: JSON.stringify({ title, listId, parentId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const createdTask = await res.json();
|
||||
// Replace temp subtask with actual created task
|
||||
setTasks((prev) => {
|
||||
const replaceTemp = (nodes: Task[]): Task[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === tempId) return createdTask;
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: replaceTemp(n.children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
};
|
||||
return replaceTemp(prev);
|
||||
});
|
||||
onRefresh();
|
||||
} else {
|
||||
fetchTasks();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to add subtask inline", err);
|
||||
fetchTasks();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -786,173 +640,6 @@ export function TaskList({
|
||||
setEditingHeader(false);
|
||||
};
|
||||
|
||||
// Helper: check if targetId is inside the subtree of ancestorId (prevents circular nesting / node disappearing)
|
||||
const isDescendantNode = (nodes: Task[], ancestorId: string, targetId: string): boolean => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === ancestorId) {
|
||||
const checkChildren = (children: Task[]): boolean => {
|
||||
for (const c of children) {
|
||||
if (c.id === targetId) return true;
|
||||
if (c.children && c.children.length > 0 && checkChildren(c.children)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return checkChildren(node.children || []);
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
if (isDescendantNode(node.children, ancestorId, targetId)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Robust Tree-aware reordering function (1st, 2nd, 3rd depth task reordering, promoting to top-level, and demoting into subtask)
|
||||
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after" | "inside" | "root") => {
|
||||
// Prevent dropping onto itself or into its own subtree (which causes loops/disappearing tasks)
|
||||
if (draggedId === targetId && position !== "root") return;
|
||||
|
||||
setTasks((prev) => {
|
||||
if (isDescendantNode(prev, draggedId, targetId)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// 1. Extract and remove the dragged node from its current position
|
||||
let extractedNode: Task | null = null;
|
||||
const removeNode = (nodes: Task[]): Task[] => {
|
||||
const result: Task[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.id === draggedId) {
|
||||
extractedNode = { ...node };
|
||||
} else {
|
||||
const updatedNode = { ...node };
|
||||
if (updatedNode.children && updatedNode.children.length > 0) {
|
||||
updatedNode.children = removeNode(updatedNode.children);
|
||||
}
|
||||
result.push(updatedNode);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const treeWithoutDragged = removeNode(prev);
|
||||
if (!extractedNode) return prev;
|
||||
const safeExtractedNode: Task = extractedNode;
|
||||
|
||||
let determinedParentId: string | null = null;
|
||||
|
||||
if (position === "root") {
|
||||
// Promote directly to top-level (1st depth root task)
|
||||
determinedParentId = null;
|
||||
const nodeAsRoot: Task = { ...safeExtractedNode, parentId: null };
|
||||
const finalTree = [...treeWithoutDragged, nodeAsRoot];
|
||||
|
||||
if (isDemo && typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
||||
} else {
|
||||
fetch(`/api/tasks/${draggedId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parentId: null }),
|
||||
}).catch((err) => console.error("Failed to promote task to root", err));
|
||||
}
|
||||
return finalTree;
|
||||
}
|
||||
|
||||
// 2. Insert into the target position
|
||||
if (position === "inside") {
|
||||
determinedParentId = targetId;
|
||||
const insertInside = (nodes: Task[]): { list: Task[]; inserted: boolean } => {
|
||||
let hasInserted = false;
|
||||
const newNodes = nodes.map((node) => {
|
||||
if (node.id === targetId) {
|
||||
hasInserted = true;
|
||||
const nodeWithNewParent: Task = { ...safeExtractedNode, parentId: targetId };
|
||||
return {
|
||||
...node,
|
||||
children: [...(node.children || []), nodeWithNewParent],
|
||||
};
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const res = insertInside(node.children);
|
||||
if (res.inserted) {
|
||||
hasInserted = true;
|
||||
return { ...node, children: res.list };
|
||||
}
|
||||
}
|
||||
return node;
|
||||
});
|
||||
return { list: newNodes, inserted: hasInserted };
|
||||
};
|
||||
|
||||
const result = insertInside(treeWithoutDragged);
|
||||
const finalTree = result.inserted ? result.list : [...treeWithoutDragged, { ...safeExtractedNode, parentId: null }];
|
||||
|
||||
if (isDemo && typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
||||
} else {
|
||||
fetch(`/api/tasks/${draggedId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parentId: determinedParentId }),
|
||||
}).catch((err) => console.error("Failed to update parentId", err));
|
||||
}
|
||||
return finalTree;
|
||||
} else {
|
||||
// Drop before/after as sibling (if target is at root level, parentId becomes null -> promoted to 1st level)
|
||||
const insertSibling = (nodes: Task[], currentParentId: string | null): { list: Task[]; inserted: boolean } => {
|
||||
const idx = nodes.findIndex((n) => n.id === targetId);
|
||||
if (idx !== -1) {
|
||||
determinedParentId = currentParentId;
|
||||
const insertIdx = position === "before" ? idx : idx + 1;
|
||||
const nodeWithNewParent: Task = { ...safeExtractedNode, parentId: currentParentId };
|
||||
const newNodes = [...nodes];
|
||||
newNodes.splice(insertIdx, 0, nodeWithNewParent);
|
||||
return { list: newNodes, inserted: true };
|
||||
}
|
||||
|
||||
let hasInserted = false;
|
||||
const newNodes = nodes.map((node) => {
|
||||
if (!hasInserted && node.children && node.children.length > 0) {
|
||||
const res = insertSibling(node.children, node.id);
|
||||
if (res.inserted) {
|
||||
hasInserted = true;
|
||||
return { ...node, children: res.list };
|
||||
}
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return { list: newNodes, inserted: hasInserted };
|
||||
};
|
||||
|
||||
const result = insertSibling(treeWithoutDragged, null);
|
||||
const finalTree = result.inserted ? result.list : [...treeWithoutDragged, { ...safeExtractedNode, parentId: null }];
|
||||
|
||||
if (isDemo && typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
||||
} else {
|
||||
fetch(`/api/tasks/${draggedId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parentId: determinedParentId }),
|
||||
}).catch((err) => console.error("Failed to update sibling parentId", err));
|
||||
}
|
||||
return finalTree;
|
||||
}
|
||||
});
|
||||
}, [isDemo, setTasks]);
|
||||
|
||||
const handleDeleteTask = useCallback((task: Task) => {
|
||||
if (onDeleteTaskWithUndo) {
|
||||
onDeleteTaskWithUndo(task);
|
||||
} else if (onDeleteTask) {
|
||||
onDeleteTask(task.id);
|
||||
}
|
||||
}, [onDeleteTaskWithUndo, onDeleteTask]);
|
||||
|
||||
if (!listId && !isTrashActive && !selectedTag) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
||||
@@ -971,105 +658,90 @@ export function TaskList({
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header */}
|
||||
<div className="main-header" style={{ justifyContent: "space-between" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flex: 1, minWidth: 0, marginRight: 12 }}>
|
||||
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu" type="button">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="main-header">
|
||||
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu" type="button">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header Title / Trash Header / Tag Header */}
|
||||
{isTrashActive ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--danger)" }}>🗑️ {t("trash") || "Trash"}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
||||
</div>
|
||||
) : selectedTag ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--accent)" }}>🏷️ #{selectedTag}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
||||
</div>
|
||||
) : editingHeader ? (
|
||||
<input
|
||||
ref={headerInputRef}
|
||||
id="header-rename-input"
|
||||
className="form-input"
|
||||
value={headerTitle}
|
||||
onChange={(e) => setHeaderTitle(e.target.value)}
|
||||
onBlur={handleSaveHeaderTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveHeaderTitle();
|
||||
if (e.key === "Escape") {
|
||||
if (currentList) setHeaderTitle(currentList.name);
|
||||
setEditingHeader(false);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
style={{ fontSize: 18, fontWeight: 700, height: 36, padding: "2px 10px", width: "100%", maxWidth: 320 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="main-header-title"
|
||||
id="main-header-title"
|
||||
style={{
|
||||
color: currentList?.color,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "4px 8px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
transition: "background var(--dur-fast)",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingHeader(true);
|
||||
}}
|
||||
title="Click to rename list"
|
||||
>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{currentList?.name || t("tasks")}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 400, flexShrink: 0 }}>
|
||||
({incompleteTasks.length})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Header Title / Trash Header / Tag Header */}
|
||||
{isTrashActive ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--danger)" }}>🗑️ {t("trash") || "Trash"}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
||||
</div>
|
||||
) : selectedTag ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--accent)" }}>🏷️ #{selectedTag}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
||||
</div>
|
||||
) : editingHeader ? (
|
||||
<input
|
||||
ref={headerInputRef}
|
||||
id="header-rename-input"
|
||||
className="form-input"
|
||||
value={headerTitle}
|
||||
onChange={(e) => setHeaderTitle(e.target.value)}
|
||||
onBlur={handleSaveHeaderTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveHeaderTitle();
|
||||
if (e.key === "Escape") {
|
||||
if (currentList) setHeaderTitle(currentList.name);
|
||||
setEditingHeader(false);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
style={{ fontSize: 18, fontWeight: 700, height: 36, padding: "2px 10px", maxWidth: 360 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="main-header-title"
|
||||
id="main-header-title"
|
||||
style={{
|
||||
color: currentList?.color,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "4px 8px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
transition: "background var(--dur-fast)",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingHeader(true);
|
||||
}}
|
||||
title="Click to rename list"
|
||||
>
|
||||
<span>{currentList?.name || t("tasks")}</span>
|
||||
<span style={{ fontSize: 13, opacity: 0.5 }}>✏️</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Actions */}
|
||||
<div className="main-header-actions" style={{ flexShrink: 0, marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{/* View Switcher: Minimal & Matte Single Toggle (Only shown when Kanban is enabled in Labs) */}
|
||||
{!isTrashActive && !selectedTag && kanbanEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`view-switcher-toggle-btn${viewMode === "kanban" ? " active" : ""}`}
|
||||
onClick={handleToggleViewMode}
|
||||
title={viewMode === "kanban" ? `${t("listView")} (Switch to List)` : `${t("kanbanView")} (Switch to Kanban)`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "4px 8px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: viewMode === "kanban" ? "var(--accent-light)" : "var(--bg-secondary)",
|
||||
color: viewMode === "kanban" ? "var(--accent)" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
cursor: "pointer",
|
||||
transition: "all var(--dur-fast)",
|
||||
}}
|
||||
>
|
||||
<span>{viewMode === "kanban" ? "📊" : "📋"}</span>
|
||||
<span style={{ fontSize: 11.5 }}>
|
||||
{viewMode === "kanban" ? t("kanbanView") : t("listView")}
|
||||
</span>
|
||||
</button>
|
||||
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{/* View Switcher: List vs Kanban (Hidden in Trash/Tag mode) */}
|
||||
{!isTrashActive && !selectedTag && (
|
||||
<div className="view-switcher-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-switcher-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => handleViewModeChange("list")}
|
||||
title={t("listView")}
|
||||
>
|
||||
📋 <span className="desktop-only">{t("listView")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-switcher-btn${viewMode === "kanban" ? " active" : ""}`}
|
||||
onClick={() => handleViewModeChange("kanban")}
|
||||
title={t("kanbanView")}
|
||||
>
|
||||
📊 <span className="desktop-only">{t("kanbanView")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTrashActive ? (
|
||||
@@ -1122,38 +794,17 @@ export function TaskList({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Task list container with background drop support for promoting subtask to 1st depth */
|
||||
/* Task list container */
|
||||
<div
|
||||
className="task-list-container"
|
||||
onDragOver={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData("text/plain");
|
||||
if (draggedId) {
|
||||
// Promote to 1st depth root task
|
||||
handleReorderTasks(draggedId, "", "root");
|
||||
}
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading && tasks.length === 0 && (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: "40px", color: "var(--text-tertiary)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ width: 24, height: 24, border: "2px solid var(--border)", borderTopColor: "var(--accent)", borderRadius: "50%", animation: "spin 0.8s linear infinite" }} />
|
||||
<span style={{ fontSize: 13 }}>{t("loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{loading && (
|
||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
||||
)}
|
||||
{!loading && tasks.length === 0 && (
|
||||
<div className="task-list-empty">
|
||||
@@ -1165,7 +816,7 @@ export function TaskList({
|
||||
)}
|
||||
|
||||
{/* Tasks Tree */}
|
||||
{(isTrashActive ? tasks : incompleteTasks).map((task) => (
|
||||
{incompleteTasks.map((task) => (
|
||||
<RecursiveTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
@@ -1179,7 +830,6 @@ export function TaskList({
|
||||
isTrashMode={isTrashActive}
|
||||
onRestore={onRestoreTask}
|
||||
onPermanentDelete={onPermanentDeleteTask}
|
||||
onDragTask={handleReorderTasks}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1212,7 +862,6 @@ export function TaskList({
|
||||
isTrashMode={isTrashActive}
|
||||
onRestore={onRestoreTask}
|
||||
onPermanentDelete={onPermanentDeleteTask}
|
||||
onDragTask={handleReorderTasks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1248,7 +897,14 @@ export function TaskList({
|
||||
label: t("deleteTaskConfirm").split("?")[0],
|
||||
icon: "🗑️",
|
||||
danger: true,
|
||||
onClick: () => handleDeleteTask(contextMenu.task),
|
||||
onClick: async () => {
|
||||
if (onDeleteTask) {
|
||||
onDeleteTask(contextMenu.task.id);
|
||||
} else if (!isDemo) {
|
||||
await fetch(`/api/tasks/${contextMenu.task.id}`, { method: "DELETE" });
|
||||
onRefresh();
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
onClose={() => setContextMenu(null)}
|
||||
|
||||
+3
-21
@@ -25,28 +25,10 @@ export const authOptions: NextAuthOptions = {
|
||||
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
||||
if (!valid) return null;
|
||||
|
||||
// Admin identification logic:
|
||||
// 1. Explicit admin env match
|
||||
// 2. Email format match (admin@... or ...@checkflow.local)
|
||||
// 3. First-ever registered user in the database is automatically granted ADMIN
|
||||
// Admin identification logic
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
const isEmailAdmin = adminEmail && user.email.toLowerCase() === adminEmail.toLowerCase();
|
||||
const isPrefixAdmin = user.email.endsWith("@checkflow.local") || user.email.toLowerCase().startsWith("admin@");
|
||||
|
||||
let isFirstUser = false;
|
||||
try {
|
||||
const firstUser = await prisma.user.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (firstUser && firstUser.id === user.id) {
|
||||
isFirstUser = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[auth] failed to check first user", e);
|
||||
}
|
||||
|
||||
const isAdmin = isEmailAdmin || isPrefixAdmin || isFirstUser;
|
||||
const isEmailAdmin = adminEmail && user.email === adminEmail;
|
||||
const isAdmin = isEmailAdmin || user.email.endsWith("@checkflow.local") || user.email.startsWith("admin@");
|
||||
const role = isAdmin ? "ADMIN" : "USER";
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name, role };
|
||||
|
||||
+554
-36
@@ -1,16 +1,556 @@
|
||||
"use client";
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import { Language, TranslationKey, TranslationDict } from "./types";
|
||||
import { translations, en, ko, ja } from "./locales";
|
||||
import { getUserSettings, saveUserSettings } from "@/lib/mockData";
|
||||
|
||||
export type { Language, TranslationKey, TranslationDict };
|
||||
export { translations, en, ko, ja };
|
||||
export type Language = "en" | "ko" | "ja";
|
||||
|
||||
export const translations = {
|
||||
en: {
|
||||
// Auth
|
||||
appName: "CheckFlow",
|
||||
tagline: "Your Personal Todo",
|
||||
welcomeBack: "Welcome back",
|
||||
signInSubtitle: "Sign in to your account",
|
||||
createAccount: "Create account",
|
||||
createAccountSubtitle: "Start organizing your tasks today",
|
||||
displayName: "Display Name",
|
||||
email: "Email",
|
||||
password: "Password",
|
||||
min8Chars: "Min. 8 characters",
|
||||
signIn: "Sign in",
|
||||
signingIn: "Signing in...",
|
||||
createBtn: "Create account",
|
||||
creatingBtn: "Creating account...",
|
||||
alreadyHaveAccount: "Already have an account?",
|
||||
dontHaveAccount: "Don't have an account?",
|
||||
tryDemoMode: "Try Demo Mode (No DB Required)",
|
||||
demoBadge: "Demo Mode",
|
||||
signOut: "Sign out",
|
||||
|
||||
// Sidebar
|
||||
lists: "Lists",
|
||||
newList: "New List",
|
||||
listNamePlaceholder: "List name",
|
||||
create: "Create",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
deleteListConfirm: "Delete this list and all its tasks?",
|
||||
undoDelete: "Undo",
|
||||
listDeleted: "List deleted",
|
||||
importTasks: "Import Tasks",
|
||||
importModalTitle: "Import Tasks",
|
||||
targetList: "Target List",
|
||||
fileSelectLabel: "File (CSV or ICS from TickTick)",
|
||||
tickTickExportHint: "TickTick: Settings → Export → Export as CSV or iCalendar",
|
||||
importBtn: "Import",
|
||||
importing: "Importing...",
|
||||
exportTasks: "Export Tasks",
|
||||
exportModalTitle: "Export Tasks",
|
||||
exportFormat: "Format",
|
||||
exportScope: "Target List",
|
||||
allLists: "All Lists (Entire Tasks)",
|
||||
includeCompleted: "Include Completed Tasks",
|
||||
exportBtn: "Export & Download",
|
||||
exportSuccess: "Tasks exported successfully!",
|
||||
theme: "Theme",
|
||||
themeSystem: "System",
|
||||
themeLight: "Light",
|
||||
themeDark: "Dark",
|
||||
language: "Language",
|
||||
tags: "Tags",
|
||||
trash: "Trash",
|
||||
emptyTrash: "Empty Trash",
|
||||
searchPlaceholder: "Search tasks, notes, tags...",
|
||||
searchExpandedPlaceholder: "Search anything — tasks, notes, tags (Ctrl+K)...",
|
||||
|
||||
// Settings Modal
|
||||
settingsModalTitle: "Settings",
|
||||
profile: "Profile",
|
||||
preferences: "Preferences",
|
||||
syncIntegrations: "Integrations (DAVx⁵)",
|
||||
admin: "Admin",
|
||||
trashRetention: "Trash Retention Period",
|
||||
trashRetentionHint: "Deleted tasks will be permanently removed after the specified period.",
|
||||
days7: "7 Days",
|
||||
days14: "14 Days",
|
||||
days30: "30 Days (Recommended)",
|
||||
neverDelete: "Never Auto-Delete",
|
||||
|
||||
// CalDAV & Sync
|
||||
syncTitle: "Galaxy & External Sync (CalDAV)",
|
||||
syncDesc: "CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV.",
|
||||
syncBaseUrl: "CalDAV Server Base URL",
|
||||
syncCopyUrl: "Copy URL",
|
||||
syncCopied: "Copied!",
|
||||
syncDownloadIcs: "Download .ICS Feed",
|
||||
syncTestConnection: "Test Endpoint",
|
||||
syncTesting: "Testing...",
|
||||
syncTestSuccess: "✓ CalDAV endpoint responded successfully (200 OK)",
|
||||
syncTabAndroid: "Galaxy / Android (DAVx⁵)",
|
||||
syncTabApple: "Apple Reminders (iOS / macOS)",
|
||||
syncTabThunderbird: "Thunderbird (Desktop)",
|
||||
syncAndroidStep1: "Install DAVx⁵ from Google Play Store or F-Droid.",
|
||||
syncAndroidStep2: "Open DAVx⁵ → Add Account → Select 'Login with URL and user name'.",
|
||||
syncAndroidStep3: "Paste the Base URL above, then enter your CheckFlow email & password.",
|
||||
syncAndroidStep4: "Enable 'VTODO (Tasks)' collection to sync with Samsung Reminder or OpenTasks.",
|
||||
syncAppleStep1: "Open Settings → Reminders → Accounts → Add Account.",
|
||||
syncAppleStep2: "Select 'Other' → Add CalDAV Account.",
|
||||
syncAppleStep3: "Server: paste base URL without protocol, User/Pass: CheckFlow credentials.",
|
||||
syncAppleStep4: "Toggle 'Reminders' ON and tap Save.",
|
||||
syncThunderbirdStep1: "Open Thunderbird → Switch to Calendar view → New Calendar.",
|
||||
syncThunderbirdStep2: "Select 'On the Network' → Format: CalDAV.",
|
||||
syncThunderbirdStep3: "Location: paste the Base URL, Username: CheckFlow email.",
|
||||
syncThunderbirdStep4: "Enter your password when prompted to finalize sync.",
|
||||
|
||||
// Task List
|
||||
tasks: "Tasks",
|
||||
hideDone: "Hide done",
|
||||
showDone: "Show done",
|
||||
completedSection: "Completed",
|
||||
addTaskPlaceholder: "Add a task...",
|
||||
add: "Add",
|
||||
noTasksYet: "No tasks yet. Add one below!",
|
||||
selectListToStart: "Select a list to get started",
|
||||
loading: "Loading...",
|
||||
today: "Today",
|
||||
tomorrow: "Tomorrow",
|
||||
|
||||
// Task Detail
|
||||
taskTitlePlaceholder: "Task title",
|
||||
priority: "Priority",
|
||||
priorityNone: "None",
|
||||
priorityLow: "Low",
|
||||
priorityMedium: "Medium",
|
||||
priorityHigh: "High",
|
||||
dueDate: "Due Date",
|
||||
clear: "Clear",
|
||||
notes: "Notes",
|
||||
notesPlaceholder: "Add notes, details, or anything you need to remember…\n\nMarkdown supported: **bold**, *italic*, # heading, - list, - [ ] checkbox",
|
||||
subtasks: "Sub-tasks",
|
||||
addSubtaskPlaceholder: "Add sub-task…",
|
||||
deleteTaskConfirm: "Delete this task?",
|
||||
autoSaved: "Auto-saved",
|
||||
saving: "Saving…",
|
||||
created: "Created",
|
||||
edited: "Edited",
|
||||
|
||||
// Toolbars
|
||||
bold: "Bold",
|
||||
italic: "Italic",
|
||||
heading: "Heading",
|
||||
bulletList: "Bullet list",
|
||||
numberedList: "Numbered list",
|
||||
checkbox: "Checkbox",
|
||||
code: "Code",
|
||||
|
||||
// TickTick Mode Switcher & Quick Add
|
||||
textMode: "Text Note Mode",
|
||||
subtaskMode: "Checklist Mode",
|
||||
switchToTextMode: "Switch to Markdown Note",
|
||||
switchToSubtaskMode: "Switch to Checklist Sub-tasks",
|
||||
moveToList: "Move to List",
|
||||
quickAdd: "Quick Add",
|
||||
quickToday: "Today",
|
||||
quickTomorrow: "Tomorrow",
|
||||
quickNextWeek: "Next Week",
|
||||
selectDate: "Date",
|
||||
selectPriority: "Priority",
|
||||
selectTag: "Tag",
|
||||
copied: "Copied!",
|
||||
|
||||
// Context Menu
|
||||
editTask: "Edit",
|
||||
duplicateTask: "Duplicate",
|
||||
moveTask: "Move to…",
|
||||
deleteTask: "Delete",
|
||||
renameList: "Rename",
|
||||
deleteList: "Delete list",
|
||||
taskUndoHint: "Deleted. Tap to undo.",
|
||||
listUndoHint: "List deleted. Tap to undo.",
|
||||
|
||||
// Labs, Kanban & Customization
|
||||
labs: "Labs",
|
||||
labsDesc: "Enable or disable experimental features individually.",
|
||||
kanbanView: "Kanban Board",
|
||||
listView: "List View",
|
||||
enableKanban: "Kanban Board View",
|
||||
enableKanbanDesc: "Switch between list and 3-column board (To Do / Doing / Done).",
|
||||
swapBlocks: "Swap Position",
|
||||
resetDefaults: "Reset to Defaults",
|
||||
resetConfirm: "Reset all UI customizations to factory defaults?",
|
||||
todoCol: "To Do",
|
||||
inProgressCol: "In Progress",
|
||||
doneCol: "Done",
|
||||
accessRestricted: "Access Restricted",
|
||||
adminRequired: "This console requires ADMIN role.",
|
||||
},
|
||||
|
||||
ko: {
|
||||
// Auth
|
||||
appName: "CheckFlow",
|
||||
tagline: "나만의 할 일 관리, 심플하게",
|
||||
welcomeBack: "다시 돌아오셨네요 👋",
|
||||
signInSubtitle: "이메일로 로그인해주세요",
|
||||
createAccount: "새 계정 만들기",
|
||||
createAccountSubtitle: "오늘부터 할 일을 깔끔하게 정리해볼까요?",
|
||||
displayName: "이름 (닉네임)",
|
||||
email: "이메일",
|
||||
password: "비밀번호",
|
||||
min8Chars: "8자 이상 입력해주세요",
|
||||
signIn: "로그인",
|
||||
signingIn: "로그인 중이에요...",
|
||||
createBtn: "계정 만들기",
|
||||
creatingBtn: "계정 만드는 중...",
|
||||
alreadyHaveAccount: "이미 계정이 있으신가요?",
|
||||
dontHaveAccount: "아직 계정이 없으신가요?",
|
||||
tryDemoMode: "로그인 없이 체험해보기",
|
||||
demoBadge: "체험 중",
|
||||
signOut: "로그아웃",
|
||||
|
||||
// Sidebar
|
||||
lists: "목록",
|
||||
newList: "새 목록",
|
||||
listNamePlaceholder: "목록 이름을 입력해주세요",
|
||||
create: "만들기",
|
||||
cancel: "취소",
|
||||
save: "저장",
|
||||
deleteListConfirm: "이 목록을 삭제할까요? 안에 있는 할 일도 모두 사라져요.",
|
||||
undoDelete: "되돌리기",
|
||||
listDeleted: "목록이 삭제됐어요",
|
||||
importTasks: "가져오기",
|
||||
importModalTitle: "할 일 가져오기",
|
||||
targetList: "가져올 목록 선택",
|
||||
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)",
|
||||
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar",
|
||||
importBtn: "가져오기",
|
||||
importing: "가져오는 중이에요...",
|
||||
exportTasks: "내보내기",
|
||||
exportModalTitle: "할 일 내보내기",
|
||||
exportFormat: "내보내기 형식",
|
||||
exportScope: "내보낼 목록",
|
||||
allLists: "모든 목록 (전체 할 일)",
|
||||
includeCompleted: "완료된 항목 포함",
|
||||
exportBtn: "내보내기 및 다운로드",
|
||||
exportSuccess: "할 일을 성공적으로 내보냈어요!",
|
||||
theme: "테마",
|
||||
themeSystem: "시스템",
|
||||
themeLight: "라이트",
|
||||
themeDark: "다크",
|
||||
language: "언어",
|
||||
tags: "태그",
|
||||
trash: "휴지통",
|
||||
emptyTrash: "휴지통 비우기",
|
||||
searchPlaceholder: "검색...",
|
||||
searchExpandedPlaceholder: "할 일, 메모, 태그 무엇이든 검색해보세요 (Ctrl+K)...",
|
||||
|
||||
// Settings Modal
|
||||
settingsModalTitle: "설정",
|
||||
profile: "프로필",
|
||||
preferences: "환경설정",
|
||||
syncIntegrations: "외부 연동 (CalDAV)",
|
||||
admin: "관리자",
|
||||
trashRetention: "휴지통 자동 비우기",
|
||||
trashRetentionHint: "설정한 기간이 지나면 휴지통이 자동으로 비워져요.",
|
||||
days7: "7일 후 자동 삭제",
|
||||
days14: "14일 후 자동 삭제",
|
||||
days30: "30일 후 자동 삭제 (권장)",
|
||||
neverDelete: "자동 삭제 안 함",
|
||||
|
||||
// CalDAV & Sync
|
||||
syncTitle: "갤럭시 및 외부 캘린더 연동 (CalDAV)",
|
||||
syncDesc: "삼성 갤럭시 리마인더, 애플 미리알림, Thunderbird 등 CalDAV 표준을 지원하는 모든 기기와 양방향으로 동기화됩니다.",
|
||||
syncBaseUrl: "CalDAV 기본 주소 (Base URL)",
|
||||
syncCopyUrl: "URL 복사",
|
||||
syncCopied: "복사 완료!",
|
||||
syncDownloadIcs: ".ICS 피드 다운로드",
|
||||
syncTestConnection: "엔드포인트 점검",
|
||||
syncTesting: "점검 중...",
|
||||
syncTestSuccess: "✓ CalDAV 엔드포인트가 정상적으로 응답합니다 (200 OK)",
|
||||
syncTabAndroid: "갤럭시 / 안드로이드 (DAVx⁵)",
|
||||
syncTabApple: "애플 미리알림 (iOS / macOS)",
|
||||
syncTabThunderbird: "Thunderbird (PC)",
|
||||
syncAndroidStep1: "구글 플레이 스토어 또는 F-Droid에서 'DAVx⁵' 앱을 설치합니다.",
|
||||
syncAndroidStep2: "DAVx⁵ 실행 → 계정 추가(+) → 'URL 및 사용자 이름으로 로그인'을 선택합니다.",
|
||||
syncAndroidStep3: "위 기본 주소를 붙여넣고, CheckFlow 로그인 이메일과 비밀번호를 입력합니다.",
|
||||
syncAndroidStep4: "'VTODO (할 일)'을 켜면 삼성 리마인더 또는 OpenTasks와 자동으로 실시간 동기화됩니다.",
|
||||
syncAppleStep1: "기기 설정 → 미리알림(또는 캘린더) → 계정 → '계정 추가'를 탭합니다.",
|
||||
syncAppleStep2: "'기타' 선택 → 'CalDAV 계정 추가'를 누릅니다.",
|
||||
syncAppleStep3: "서버에 위 주소를 입력하고, 사용자 이름/비밀번호에 CheckFlow 계정 정보를 입력합니다.",
|
||||
syncAppleStep4: "'미리알림' 항목을 활성화하고 저장합니다.",
|
||||
syncThunderbirdStep1: "Thunderbird 실행 → 캘린더 탭으로 이동 → '새 캘린더'를 클릭합니다.",
|
||||
syncThunderbirdStep2: "'네트워크에 저장' 선택 → 형식: 'CalDAV'를 선택합니다.",
|
||||
syncThunderbirdStep3: "위치에 위 기본 주소를 붙여넣고, 사용자 이름에 CheckFlow 이메일을 입력합니다.",
|
||||
syncThunderbirdStep4: "인증 팝업이 뜨면 비밀번호를 입력하여 완료합니다.",
|
||||
|
||||
// Task List
|
||||
tasks: "할 일",
|
||||
hideDone: "완료 숨기기",
|
||||
showDone: "완료 보기",
|
||||
completedSection: "완료한 항목",
|
||||
addTaskPlaceholder: "할 일을 추가해보세요...",
|
||||
add: "추가",
|
||||
noTasksYet: "아직 할 일이 없어요. 아래에서 추가해볼까요? 😊",
|
||||
selectListToStart: "왼쪽에서 목록을 선택해주세요",
|
||||
loading: "불러오는 중...",
|
||||
today: "오늘",
|
||||
tomorrow: "내일",
|
||||
|
||||
// Task Detail
|
||||
taskTitlePlaceholder: "할 일 제목을 입력하세요",
|
||||
priority: "우선순위",
|
||||
priorityNone: "없음",
|
||||
priorityLow: "낮음",
|
||||
priorityMedium: "보통",
|
||||
priorityHigh: "높음",
|
||||
dueDate: "마감일",
|
||||
clear: "지우기",
|
||||
notes: "메모",
|
||||
notesPlaceholder: "여기에 자유롭게 메모하세요…\n\n마크다운도 쓸 수 있어요: **굵게**, *기울임*, # 제목, - 리스트, - [ ] 체크박스",
|
||||
subtasks: "하위 할 일",
|
||||
addSubtaskPlaceholder: "하위 할 일 추가…",
|
||||
deleteTaskConfirm: "이 할 일을 삭제할까요?",
|
||||
autoSaved: "자동 저장됨",
|
||||
saving: "저장 중…",
|
||||
created: "만든 날",
|
||||
edited: "수정한 날",
|
||||
|
||||
// Toolbars
|
||||
bold: "굵게",
|
||||
italic: "기울임",
|
||||
heading: "제목",
|
||||
bulletList: "글머리 기호",
|
||||
numberedList: "번호 매기기",
|
||||
checkbox: "체크박스",
|
||||
code: "코드 블록",
|
||||
|
||||
// TickTick Mode Switcher & Quick Add
|
||||
textMode: "텍스트 메모 모드",
|
||||
subtaskMode: "체크리스트 모드",
|
||||
switchToTextMode: "마크다운 메모 모드로 전환",
|
||||
switchToSubtaskMode: "체크리스트 모드로 전환",
|
||||
moveToList: "다른 목록으로 이동",
|
||||
quickAdd: "빠른 추가",
|
||||
quickToday: "오늘",
|
||||
quickTomorrow: "내일",
|
||||
quickNextWeek: "다음 주",
|
||||
selectDate: "날짜",
|
||||
selectPriority: "우선순위",
|
||||
selectTag: "태그",
|
||||
copied: "복사했어요!",
|
||||
|
||||
// Context Menu
|
||||
editTask: "수정",
|
||||
duplicateTask: "복제",
|
||||
moveTask: "이동…",
|
||||
deleteTask: "삭제",
|
||||
renameList: "이름 변경",
|
||||
deleteList: "목록 삭제",
|
||||
taskUndoHint: "삭제했어요. 되돌릴 수 있어요.",
|
||||
listUndoHint: "목록이 삭제됐어요. 되돌릴 수 있어요.",
|
||||
|
||||
// Labs, Kanban & Customization
|
||||
labs: "실험실 🧪",
|
||||
labsDesc: "아직 정식 출시 전인 기능들이에요. 원하는 걸 골라서 켜보세요!",
|
||||
kanbanView: "칸반 보드",
|
||||
listView: "리스트",
|
||||
enableKanban: "칸반 보드 뷰",
|
||||
enableKanbanDesc: "할 일을 보드 형태로 볼 수 있어요 (할 일 / 진행 중 / 완료).",
|
||||
swapBlocks: "순서 바꾸기",
|
||||
resetDefaults: "기본값으로 되돌리기",
|
||||
resetConfirm: "모든 커스텀 설정을 기본값으로 되돌릴까요?",
|
||||
todoCol: "할 일",
|
||||
inProgressCol: "진행 중",
|
||||
doneCol: "완료",
|
||||
accessRestricted: "접근 권한 없음",
|
||||
adminRequired: "이 페이지는 관리자만 볼 수 있어요.",
|
||||
},
|
||||
|
||||
ja: {
|
||||
// Auth
|
||||
appName: "CheckFlow",
|
||||
tagline: "シンプルに、毎日のタスクを",
|
||||
welcomeBack: "おかえりなさい 👋",
|
||||
signInSubtitle: "メールアドレスでサインインしてください",
|
||||
createAccount: "アカウントを作成",
|
||||
createAccountSubtitle: "さあ、今日からタスクを整理しましょう!",
|
||||
displayName: "お名前",
|
||||
email: "メールアドレス",
|
||||
password: "パスワード",
|
||||
min8Chars: "8文字以上で入力してください",
|
||||
signIn: "サインイン",
|
||||
signingIn: "サインイン中...",
|
||||
createBtn: "アカウントを作成する",
|
||||
creatingBtn: "作成中...",
|
||||
alreadyHaveAccount: "すでにアカウントをお持ちですか?",
|
||||
dontHaveAccount: "アカウントをお持ちでないですか?",
|
||||
tryDemoMode: "ログインなしで試してみる",
|
||||
demoBadge: "体験中",
|
||||
signOut: "サインアウト",
|
||||
|
||||
// Sidebar
|
||||
lists: "リスト",
|
||||
newList: "新しいリスト",
|
||||
listNamePlaceholder: "リスト名を入力してください",
|
||||
create: "作成する",
|
||||
cancel: "キャンセル",
|
||||
save: "保存",
|
||||
deleteListConfirm: "このリストを削除しますか?中のタスクもすべて消えます。",
|
||||
undoDelete: "元に戻す",
|
||||
listDeleted: "リストを削除しました",
|
||||
importTasks: "インポート",
|
||||
importModalTitle: "タスクをインポート",
|
||||
targetList: "インポート先のリスト",
|
||||
fileSelectLabel: "ファイルを選択(TickTick CSV または ICS)",
|
||||
tickTickExportHint: "TickTick: 設定 → エクスポート → CSV または iCalendar",
|
||||
importBtn: "インポートする",
|
||||
importing: "インポート中...",
|
||||
exportTasks: "エクスポート",
|
||||
exportModalTitle: "タスクをエクスポート",
|
||||
exportFormat: "エクスポート形式",
|
||||
exportScope: "対象リスト",
|
||||
allLists: "すべてのリスト(全体)",
|
||||
includeCompleted: "完了したタスクを含める",
|
||||
exportBtn: "エクスポートして保存",
|
||||
exportSuccess: "タスクをエクスポートしました!",
|
||||
theme: "テーマ",
|
||||
themeSystem: "システム",
|
||||
themeLight: "ライト",
|
||||
themeDark: "ダーク",
|
||||
language: "言語",
|
||||
tags: "タグ",
|
||||
trash: "ゴミ箱",
|
||||
emptyTrash: "ゴミ箱を空にする",
|
||||
searchPlaceholder: "検索...",
|
||||
searchExpandedPlaceholder: "タスク、メモ、タグなど何でも検索できます (Ctrl+K)...",
|
||||
|
||||
// Settings Modal
|
||||
settingsModalTitle: "設定",
|
||||
profile: "プロフィール",
|
||||
preferences: "環境設定",
|
||||
syncIntegrations: "外部連携 (CalDAV)",
|
||||
admin: "管理者",
|
||||
trashRetention: "ゴミ箱の自動削除",
|
||||
trashRetentionHint: "設定した期間が過ぎると、ゴミ箱が自動的に空になります。",
|
||||
days7: "7日後に自動削除",
|
||||
days14: "14日後に自動削除",
|
||||
days30: "30日後に自動削除(推奨)",
|
||||
neverDelete: "自動削除しない",
|
||||
|
||||
// CalDAV & Sync
|
||||
syncTitle: "Galaxy & 外部カレンダー連携 (CalDAV)",
|
||||
syncDesc: "Samsung Galaxy リマインダー、Apple リマインダー、Thunderbird など CalDAV 対応アプリと双方向同期できます。",
|
||||
syncBaseUrl: "CalDAV サーバー基本 URL",
|
||||
syncCopyUrl: "URL をコピー",
|
||||
syncCopied: "コピーしました!",
|
||||
syncDownloadIcs: ".ICS フィードをダウンロード",
|
||||
syncTestConnection: "接続テスト",
|
||||
syncTesting: "テスト中...",
|
||||
syncTestSuccess: "✓ CalDAV エンドポイントは正常に応答しています (200 OK)",
|
||||
syncTabAndroid: "Galaxy / Android (DAVx⁵)",
|
||||
syncTabApple: "Apple リマインダー (iOS / macOS)",
|
||||
syncTabThunderbird: "Thunderbird (PC)",
|
||||
syncAndroidStep1: "Google Play または F-Droid から「DAVx⁵」アプリをインストールします。",
|
||||
syncAndroidStep2: "DAVx⁵ を起動 → アカウント追加 →「URL とユーザー名でログイン」を選択します。",
|
||||
syncAndroidStep3: "上記の基本 URL を貼り付け、CheckFlow のメールアドレスとパスワードを入力します。",
|
||||
syncAndroidStep4: "「VTODO (タスク)」を有効にすると、Samsung リマインダーや OpenTasks と連携されます。",
|
||||
syncAppleStep1: "設定 → リマインダー → アカウント →「アカウントを追加」を開きます。",
|
||||
syncAppleStep2: "「その他」を選択 →「CalDAV アカウントを追加」を選択します。",
|
||||
syncAppleStep3: "サーバーに上記の URL、ユーザー名/パスワードに CheckFlow のアカウント情報を入力します。",
|
||||
syncAppleStep4: "「リマインダー」を有効にして保存します。",
|
||||
syncThunderbirdStep1: "Thunderbird を起動 → カレンダータブ → 新しいカレンダーを作成します。",
|
||||
syncThunderbirdStep2: "「ネットワーク上」を選択 → 形式: CalDAV を選択します。",
|
||||
syncThunderbirdStep3: "場所に上記の URL、ユーザー名に CheckFlow のメールアドレスを入力します。",
|
||||
syncThunderbirdStep4: "ログイン画面でパスワードを入力して完了します。",
|
||||
|
||||
// Task List
|
||||
tasks: "タスク",
|
||||
hideDone: "完了を非表示",
|
||||
showDone: "完了を表示",
|
||||
completedSection: "完了済み",
|
||||
addTaskPlaceholder: "タスクを追加してみましょう...",
|
||||
add: "追加",
|
||||
noTasksYet: "まだタスクがありません。下から追加してみてください 😊",
|
||||
selectListToStart: "左側からリストを選んでください",
|
||||
loading: "読み込み中...",
|
||||
today: "今日",
|
||||
tomorrow: "明日",
|
||||
|
||||
// Task Detail
|
||||
taskTitlePlaceholder: "タスク名を入力してください",
|
||||
priority: "優先度",
|
||||
priorityNone: "なし",
|
||||
priorityLow: "低め",
|
||||
priorityMedium: "普通",
|
||||
priorityHigh: "高め",
|
||||
dueDate: "期限",
|
||||
clear: "クリア",
|
||||
notes: "メモ",
|
||||
notesPlaceholder: "ここに自由にメモしてください…\n\nMarkdownも使えます: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
|
||||
subtasks: "サブタスク",
|
||||
addSubtaskPlaceholder: "サブタスクを追加…",
|
||||
deleteTaskConfirm: "このタスクを削除しますか?",
|
||||
autoSaved: "自動保存しました",
|
||||
saving: "保存中…",
|
||||
created: "作成日",
|
||||
edited: "更新日",
|
||||
|
||||
// Toolbars
|
||||
bold: "太字",
|
||||
italic: "斜体",
|
||||
heading: "見出し",
|
||||
bulletList: "箇条書き",
|
||||
numberedList: "番号付きリスト",
|
||||
checkbox: "チェックボックス",
|
||||
code: "コードブロック",
|
||||
|
||||
// TickTick Mode Switcher & Quick Add
|
||||
textMode: "テキストメモ",
|
||||
subtaskMode: "チェックリスト",
|
||||
switchToTextMode: "Markdownメモに切り替え",
|
||||
switchToSubtaskMode: "チェックリストに切り替え",
|
||||
moveToList: "別のリストに移動",
|
||||
quickAdd: "クイック追加",
|
||||
quickToday: "今日",
|
||||
quickTomorrow: "明日",
|
||||
quickNextWeek: "来週",
|
||||
selectDate: "日付",
|
||||
selectPriority: "優先度",
|
||||
selectTag: "タグ",
|
||||
copied: "コピーしました!",
|
||||
accessRestricted: "アクセス制限",
|
||||
adminRequired: "このページは管理者のみ閲覧できます。",
|
||||
|
||||
// Context Menu
|
||||
editTask: "編集",
|
||||
duplicateTask: "複製",
|
||||
moveTask: "移動…",
|
||||
deleteTask: "削除",
|
||||
renameList: "名前を変更",
|
||||
deleteList: "リストを削除",
|
||||
taskUndoHint: "削除しました。元に戻せます。",
|
||||
listUndoHint: "リストを削除しました。元に戻せます。",
|
||||
|
||||
// Labs, Kanban & Customization
|
||||
labs: "実験室 🧪",
|
||||
labsDesc: "まだ正式リリース前の機能です。好きなものを選んでオンにしてみてください!",
|
||||
kanbanView: "カンバンボード",
|
||||
listView: "リスト",
|
||||
enableKanban: "カンバンボードビュー",
|
||||
enableKanbanDesc: "タスクをボード形式で表示できます(未着手 / 進行中 / 完了)。",
|
||||
swapBlocks: "順番を入れ替え",
|
||||
resetDefaults: "デフォルトに戻す",
|
||||
resetConfirm: "すべてのカスタム設定をデフォルトに戻しますか?",
|
||||
todoCol: "未着手",
|
||||
inProgressCol: "進行中",
|
||||
doneCol: "完了",
|
||||
},
|
||||
};
|
||||
|
||||
type TranslationKeys = keyof typeof translations.en;
|
||||
|
||||
interface I18nContextType {
|
||||
lang: Language;
|
||||
setLang: (l: Language) => void;
|
||||
t: (key: TranslationKey) => string;
|
||||
t: (key: TranslationKeys) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextType>({
|
||||
@@ -21,35 +561,15 @@ const I18nContext = createContext<I18nContextType>({
|
||||
|
||||
const LANG_STORAGE_KEY = "checkflow_lang";
|
||||
|
||||
function getInitialLanguage(): Language {
|
||||
if (typeof window === "undefined") return "en";
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(LANG_STORAGE_KEY) as Language | null;
|
||||
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
|
||||
return saved;
|
||||
}
|
||||
const userSettings = getUserSettings();
|
||||
if (userSettings?.language && (userSettings.language === "en" || userSettings.language === "ko" || userSettings.language === "ja")) {
|
||||
return userSettings.language;
|
||||
}
|
||||
const navLang = navigator.language?.toLowerCase() || "";
|
||||
if (navLang.startsWith("ko")) return "ko";
|
||||
if (navLang.startsWith("ja")) return "ja";
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lang, setLangState] = useState<Language>(getInitialLanguage);
|
||||
const [lang, setLangState] = useState<Language>("en");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const initLang = getInitialLanguage();
|
||||
setLangState(initLang);
|
||||
const saved = localStorage.getItem(LANG_STORAGE_KEY) as Language | null;
|
||||
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
|
||||
setLangState(saved);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -59,19 +579,17 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
setLangState(l);
|
||||
try {
|
||||
localStorage.setItem(LANG_STORAGE_KEY, l);
|
||||
const cur = getUserSettings();
|
||||
saveUserSettings({ ...cur, language: l });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const t = (key: TranslationKey): string => {
|
||||
const dict = translations[lang] || translations.en;
|
||||
const t = (key: TranslationKeys): string => {
|
||||
const dict = (translations[lang] || translations.en) as Record<string, string>;
|
||||
return dict[key] || translations.en[key] || key;
|
||||
};
|
||||
|
||||
return <I18nContext.Provider value={{ lang, setLang, t }}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export const useI18n = () => useContext(I18nContext);
|
||||
export const useI18n = () => useContext(I18nContext);
|
||||
@@ -1,236 +0,0 @@
|
||||
import { TranslationDict } from "../types";
|
||||
|
||||
export const en: TranslationDict = {
|
||||
// Auth
|
||||
appName: "CheckFlow",
|
||||
tagline: "TickTick-style independent task manager",
|
||||
welcomeBack: "Welcome back",
|
||||
signInSubtitle: "Sign in to manage your tasks securely",
|
||||
createAccount: "Create account",
|
||||
createAccountSubtitle: "Get started with your self-hosted tasks",
|
||||
displayName: "Display Name",
|
||||
email: "Email",
|
||||
password: "Password",
|
||||
min8Chars: "At least 8 characters",
|
||||
signIn: "Sign In",
|
||||
signingIn: "Signing in...",
|
||||
createBtn: "Create Account",
|
||||
creatingBtn: "Creating account...",
|
||||
alreadyHaveAccount: "Already have an account? Sign in",
|
||||
dontHaveAccount: "Don't have an account? Create one",
|
||||
tryDemoMode: "Try Demo Mode (No DB)",
|
||||
demoBadge: "Demo Mode",
|
||||
signOut: "Sign Out",
|
||||
|
||||
// Sidebar
|
||||
lists: "Lists",
|
||||
newList: "New List",
|
||||
listNamePlaceholder: "List name...",
|
||||
create: "Create",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
deleteListConfirm: "Are you sure you want to delete this list?",
|
||||
undoDelete: "Undo",
|
||||
listDeleted: "List deleted",
|
||||
importTasks: "Import Tasks",
|
||||
importModalTitle: "Import Tasks (CSV / ICS)",
|
||||
targetList: "Target List",
|
||||
fileSelectLabel: "Select .csv or .ics file (TickTick export supported)",
|
||||
tickTickExportHint: "TickTick export: Settings → Backup → Export CSV",
|
||||
importBtn: "Import",
|
||||
importing: "Importing...",
|
||||
exportTasks: "Export Tasks",
|
||||
exportModalTitle: "Export Tasks (CSV / ICS)",
|
||||
exportFormat: "Format",
|
||||
exportScope: "Scope",
|
||||
allLists: "All Lists",
|
||||
includeCompleted: "Include completed tasks",
|
||||
exportBtn: "Export",
|
||||
exportSuccess: "Tasks exported successfully",
|
||||
theme: "Theme",
|
||||
themeSystem: "System",
|
||||
themeLight: "Light",
|
||||
themeDark: "Dark",
|
||||
language: "Language",
|
||||
tags: "Tags",
|
||||
trash: "Trash",
|
||||
emptyTrash: "Empty Trash",
|
||||
searchPlaceholder: "Search tasks...",
|
||||
searchExpandedPlaceholder: "Type to search tasks, tags, notes...",
|
||||
|
||||
// Settings Modal - General & Tabs
|
||||
settingsModalTitle: "Settings",
|
||||
profile: "Profile",
|
||||
preferences: "Preferences",
|
||||
syncIntegrations: "Integrations",
|
||||
admin: "Admin",
|
||||
trashRetention: "Trash Auto-Delete 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 (Manual empty only)",
|
||||
settingsSaved: "Settings saved",
|
||||
passwordChangePlaceholder: "New password (leave blank to keep current)",
|
||||
yourNamePlaceholder: "Your Name",
|
||||
|
||||
// Settings Modal - Labs & Customization
|
||||
labsSectionView: "View",
|
||||
labsSectionLayout: "Layout",
|
||||
labsSectionAppearance: "Appearance",
|
||||
labsKanbanTitle: "Kanban Board",
|
||||
labsKanbanDesc: "Switch between list view and 3-column Kanban board (To Do / In Progress / Done).",
|
||||
labsDensityTitle: "Content Density",
|
||||
labsDensityDesc: "Controls the vertical spacing of task items.",
|
||||
densityCompact: "Compact",
|
||||
densityDefault: "Default",
|
||||
densityComfortable: "Airy",
|
||||
sidebarWidthTitle: "Sidebar Width",
|
||||
sidebarWidthDesc: "Drag the sidebar edge or adjust here.",
|
||||
detailWidthTitle: "Detail Panel Width",
|
||||
detailWidthDesc: "Drag the panel edge or adjust here.",
|
||||
fontSizeTitle: "Font Size",
|
||||
fontSizeDesc: "Base font size across the app.",
|
||||
fontSizeSmall: "S",
|
||||
fontSizeMedium: "M",
|
||||
fontSizeLarge: "L",
|
||||
accentColorTitle: "Accent Color",
|
||||
accentColorDesc: "Choose the hue of your accent color. Saturation controls vibrancy.",
|
||||
saturationLabel: "Saturation",
|
||||
hueLabel: "Hue °",
|
||||
roundnessTitle: "Border Roundness",
|
||||
roundnessDesc: "Controls the roundness of cards, buttons, and UI elements.",
|
||||
roundnessSharp: "Sharp",
|
||||
roundnessDefault: "Default",
|
||||
roundnessRound: "Round",
|
||||
animationSpeedTitle: "Animation Speed",
|
||||
animationSpeedDesc: "Controls the speed of transitions and hover effects.",
|
||||
animationOff: "Off",
|
||||
animationFast: "Fast",
|
||||
animationDefault: "Default",
|
||||
animationSlow: "Slow",
|
||||
resetDefaultsTitle: "Reset to Defaults",
|
||||
resetDefaultsDesc: "Restores all layout, appearance, and view settings to their factory defaults.",
|
||||
|
||||
// Settings Modal - Admin tab
|
||||
adminCurrentAccount: "Current Account",
|
||||
adminIsolationMode: "Isolation Mode",
|
||||
adminMultiUserPrivacy: "Multi-User Privacy",
|
||||
adminCalDavEndpoint: "CalDAV Endpoint",
|
||||
adminActiveEnabled: "Active / Enabled",
|
||||
adminOpenDashboard: "Open Admin Dashboard →",
|
||||
|
||||
// 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",
|
||||
syncTestError: "Endpoint test failed",
|
||||
syncTabAndroid: "Android (DAVx⁵)",
|
||||
syncTabApple: "Apple Reminders",
|
||||
syncTabThunderbird: "Thunderbird",
|
||||
syncAndroidStep1: "Install DAVx⁵ from Google Play Store or F-Droid.",
|
||||
syncAndroidStep2: "Choose 'Login with URL and user name' and enter the CalDAV URL above.",
|
||||
syncAndroidStep3: "Enter your CheckFlow email and password.",
|
||||
syncAndroidStep4: "Open your favorite To-Do app (e.g. Tasks.org, OpenTasks) to manage your synchronized tasks.",
|
||||
syncAppleStep1: "Open System Settings (macOS) or Settings (iOS/iPadOS) → Internet Accounts.",
|
||||
syncAppleStep2: "Tap 'Add Account' → 'Add CalDAV Account'.",
|
||||
syncAppleStep3: "Set Account Type to 'Manual', Server Address to the CalDAV URL, with your email & password.",
|
||||
syncAppleStep4: "Tasks will automatically sync inside the Apple Reminders app.",
|
||||
syncThunderbirdStep1: "Open Thunderbird → Calendar / Tasks tab.",
|
||||
syncThunderbirdStep2: "Right-click Calendar list → 'New Calendar' → 'On the Network'.",
|
||||
syncThunderbirdStep3: "Select 'CalDAV', paste the CalDAV URL, and enter your credentials.",
|
||||
syncThunderbirdStep4: "Your CheckFlow task lists will appear under Tasks.",
|
||||
syncAndroidTip: "In DAVx⁵ account settings, set Sync Interval to 15 minutes for battery efficiency and near real-time sync.",
|
||||
syncAppleTip: "If using HTTPS behind a reverse proxy (Nginx/Caddy), ensure valid SSL certificates are trusted by Apple devices.",
|
||||
syncThunderbirdTip: "Thunderbird Tasks view will display CheckFlow priority tags, due dates, and completion status.",
|
||||
|
||||
// Task List
|
||||
tasks: "Tasks",
|
||||
hideDone: "Hide Done",
|
||||
showDone: "Show Done",
|
||||
completedSection: "Completed",
|
||||
addTaskPlaceholder: "What would you like to accomplish?",
|
||||
add: "Add",
|
||||
noTasksYet: "No tasks in this list",
|
||||
selectListToStart: "Select a list to get started",
|
||||
loading: "Loading tasks...",
|
||||
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 details, markdown supported...",
|
||||
subtasks: "Subtasks",
|
||||
addSubtaskPlaceholder: "Add a subtask...",
|
||||
deleteTaskConfirm: "Delete this task?",
|
||||
autoSaved: "Saved",
|
||||
saving: "Saving...",
|
||||
created: "Created",
|
||||
edited: "Edited",
|
||||
previewTab: "Preview",
|
||||
editTab: "Edit",
|
||||
|
||||
// Toolbars
|
||||
bold: "Bold",
|
||||
italic: "Italic",
|
||||
heading: "Heading",
|
||||
bulletList: "Bullet List",
|
||||
numberedList: "Numbered List",
|
||||
checkbox: "Checkbox",
|
||||
code: "Code",
|
||||
|
||||
// TickTick Mode Switcher & Quick Add
|
||||
textMode: "Text Mode",
|
||||
subtaskMode: "Subtask Mode",
|
||||
switchToTextMode: "Switch to Text Mode",
|
||||
switchToSubtaskMode: "Switch to Subtask Mode",
|
||||
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 Task",
|
||||
duplicateTask: "Duplicate",
|
||||
moveTask: "Move To...",
|
||||
deleteTask: "Delete Task",
|
||||
renameList: "Rename",
|
||||
deleteList: "Delete List",
|
||||
taskUndoHint: "Task deleted",
|
||||
listUndoHint: "List deleted",
|
||||
|
||||
// Labs, Kanban & Customization
|
||||
labs: "Labs",
|
||||
labsDesc: "Experimental features and UI personalization.",
|
||||
kanbanView: "Kanban",
|
||||
listView: "List",
|
||||
enableKanban: "Kanban Board View",
|
||||
enableKanbanDesc: "Enable 3-column Kanban board view (To Do, In Progress, Done) switchable in task lists.",
|
||||
swapBlocks: "Swap Blocks",
|
||||
resetDefaults: "Reset",
|
||||
resetConfirm: "Reset all customizations to defaults?",
|
||||
todoCol: "To Do",
|
||||
inProgressCol: "In Progress",
|
||||
doneCol: "Done",
|
||||
accessRestricted: "Access Restricted",
|
||||
adminRequired: "This console requires ADMIN privileges.",
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
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 };
|
||||
@@ -1,236 +0,0 @@
|
||||
import { TranslationDict } from "../types";
|
||||
|
||||
export const ja: TranslationDict = {
|
||||
// Auth
|
||||
appName: "CheckFlow",
|
||||
tagline: "TickTickスタイルのセルフホスト型タスク管理",
|
||||
welcomeBack: "おかえりなさい",
|
||||
signInSubtitle: "安全にログインしてタスクを管理します",
|
||||
createAccount: "アカウント作成",
|
||||
createAccountSubtitle: "セルフホストToDoプラットフォームを始めましょう",
|
||||
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: "タスクのインポート (CSV / ICS)",
|
||||
targetList: "インポート先リスト",
|
||||
fileSelectLabel: ".csv または .ics ファイルを選択 (TickTickエクスポート対応)",
|
||||
tickTickExportHint: "TickTickエクスポート手順: 設定 → バックアップ → CSVエクスポート",
|
||||
importBtn: "インポート",
|
||||
importing: "インポート中...",
|
||||
exportTasks: "タスクをエクスポート",
|
||||
exportModalTitle: "タスクのエクスポート (CSV / ICS)",
|
||||
exportFormat: "ファイル形式",
|
||||
exportScope: "対象範囲",
|
||||
allLists: "すべてのリスト",
|
||||
includeCompleted: "完了したタスクを含める",
|
||||
exportBtn: "エクスポート",
|
||||
exportSuccess: "タスクのエクスポートが完了しました",
|
||||
theme: "テーマ",
|
||||
themeSystem: "システム",
|
||||
themeLight: "ライト",
|
||||
themeDark: "ダーク",
|
||||
language: "言語",
|
||||
tags: "タグ",
|
||||
trash: "ゴミ箱",
|
||||
emptyTrash: "ゴミ箱を空にする",
|
||||
searchPlaceholder: "タスクを検索...",
|
||||
searchExpandedPlaceholder: "タスク名、タグ、メモを検索...",
|
||||
|
||||
// Settings Modal - General & Tabs
|
||||
settingsModalTitle: "設定",
|
||||
profile: "プロフィール",
|
||||
preferences: "環境設定",
|
||||
syncIntegrations: "外部連携",
|
||||
admin: "管理者",
|
||||
trashRetention: "ゴミ箱自動削除の保持期間",
|
||||
trashRetentionHint: "削除されたタスクは指定期間を過ぎると自動的に完全に削除されます。",
|
||||
days7: "7日間",
|
||||
days14: "14日間",
|
||||
days30: "30日間 (推奨)",
|
||||
neverDelete: "自動削除しない (手動で空にするのみ)",
|
||||
settingsSaved: "設定が正常に保存されました",
|
||||
passwordChangePlaceholder: "新しいパスワード (変更しない場合は空白)",
|
||||
yourNamePlaceholder: "お名前",
|
||||
|
||||
// Settings Modal - Labs & Customization
|
||||
labsSectionView: "ビュー設定",
|
||||
labsSectionLayout: "レイアウト",
|
||||
labsSectionAppearance: "外観とデザイン",
|
||||
labsKanbanTitle: "カンバンボード",
|
||||
labsKanbanDesc: "リストビューと3カラムのカンバンボード (To Do / 進行中 / 完了) を切り替えます。",
|
||||
labsDensityTitle: "表示密度",
|
||||
labsDensityDesc: "タスク項目の行間および縦の余白を調整します。",
|
||||
densityCompact: "コンパクト",
|
||||
densityDefault: "標準",
|
||||
densityComfortable: "ゆったり",
|
||||
sidebarWidthTitle: "サイドバーの幅",
|
||||
sidebarWidthDesc: "サイドバーの境界線をドラッグするかスライダーで調整します。",
|
||||
detailWidthTitle: "詳細パネルの幅",
|
||||
detailWidthDesc: "右側詳細タブの境界線をドラッグするかスライダーで調整します。",
|
||||
fontSizeTitle: "フォントサイズ",
|
||||
fontSizeDesc: "アプリ全体の基準フォントサイズを設定します。",
|
||||
fontSizeSmall: "小",
|
||||
fontSizeMedium: "中",
|
||||
fontSizeLarge: "大",
|
||||
accentColorTitle: "アクセントカラー",
|
||||
accentColorDesc: "アクセントカラーの色相(Hue)と彩度(Saturation)をカスタマイズします。",
|
||||
saturationLabel: "彩度",
|
||||
hueLabel: "色相 °",
|
||||
roundnessTitle: "角の丸み",
|
||||
roundnessDesc: "カードやボタン、UI要素の角丸スタイルを設定します。",
|
||||
roundnessSharp: "シャープ",
|
||||
roundnessDefault: "標準",
|
||||
roundnessRound: "ラウンド",
|
||||
animationSpeedTitle: "アニメーション速度",
|
||||
animationSpeedDesc: "画面切り替えやホバー効果の速度を調整します。",
|
||||
animationOff: "オフ",
|
||||
animationFast: "高速",
|
||||
animationDefault: "標準",
|
||||
animationSlow: "ゆっくり",
|
||||
resetDefaultsTitle: "設定を初期状態に戻す",
|
||||
resetDefaultsDesc: "すべてのレイアウト、デザイン、ビューのカスタマイズ設定を初期値にリセットします。",
|
||||
|
||||
// Settings Modal - Admin tab
|
||||
adminCurrentAccount: "現在のアカウント",
|
||||
adminIsolationMode: "分離モード",
|
||||
adminMultiUserPrivacy: "マルチユーザー・プライバシー保護",
|
||||
adminCalDavEndpoint: "CalDAV エンドポイント",
|
||||
adminActiveEnabled: "正常に稼働中",
|
||||
adminOpenDashboard: "管理者ダッシュボードを開く →",
|
||||
|
||||
// CalDAV & Sync
|
||||
syncTitle: "CalDAV モバイル同期 (Android / Apple)",
|
||||
syncDesc: "CheckFlowは標準CalDAVプロトコルにより、Android、Appleリマインダー、Thunderbirdとの双方向同期に対応しています。",
|
||||
syncBaseUrl: "CalDAV サーバーアドレス (Base URL)",
|
||||
syncCopyUrl: "URLをコピー",
|
||||
syncCopied: "コピーしました!",
|
||||
syncDownloadIcs: ".ICS フィードをダウンロード",
|
||||
syncTestConnection: "接続テスト",
|
||||
syncTesting: "確認中...",
|
||||
syncTestSuccess: "CalDAVサーバーが正常に応答しました",
|
||||
syncTestError: "エンドポイントテスト失敗",
|
||||
syncTabAndroid: "Android (DAVx⁵)",
|
||||
syncTabApple: "Apple リマインダー",
|
||||
syncTabThunderbird: "Thunderbird",
|
||||
syncAndroidStep1: "Google Play または F-Droid から DAVx⁵ をインストールします。",
|
||||
syncAndroidStep2: "「URLとユーザー名でログイン」を選択し、上記のCalDAV URLを入力します。",
|
||||
syncAndroidStep3: "CheckFlow のメールアドレスとパスワードを入力します。",
|
||||
syncAndroidStep4: "Tasks.org や OpenTasks などのアプリを開いてタスクを管理します。",
|
||||
syncAppleStep1: "システム設定 (macOS) または 設定 (iOS/iPadOS) → インターネットアカウント を開きます。",
|
||||
syncAppleStep2: "「アカウントを追加」→「CalDAVアカウントを追加」を選択します。",
|
||||
syncAppleStep3: "アカウントの種類を「手動」にし、上記のCalDAV URLとログイン情報を入力します。",
|
||||
syncAppleStep4: "Apple リマインダー (Reminders) アプリにタスクが自動同期されます。",
|
||||
syncThunderbirdStep1: "Mozilla Thunderbird を開き、カレンダー/タスク タブを開きます。",
|
||||
syncThunderbirdStep2: "カレンダー一覧を右クリック →「新しいカレンダー」→「ネットワーク上」を選択します。",
|
||||
syncThunderbirdStep3: "「CalDAV」を選択し、上記のCalDAV URLとログイン情報を入力します。",
|
||||
syncThunderbirdStep4: "CheckFlow のタスク一覧が Thunderbird Tasks に表示されます。",
|
||||
syncAndroidTip: "DAVx⁵ の設定で同期周期を 15 分に設定すると、バッテリーを節約しながら準リアルタイム同期が可能です。",
|
||||
syncAppleTip: "Nginx/Caddy などのリバースプロキシで HTTPS を使用する場合は、有効な SSL 証明書が設定されているか確認してください。",
|
||||
syncThunderbirdTip: "Thunderbird のタスクビューで CheckFlow の優先度、期日、完了状態がそのまま表示されます。",
|
||||
|
||||
// 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: "詳細を入力 (Markdown対応)...",
|
||||
subtasks: "サブタスク",
|
||||
addSubtaskPlaceholder: "サブタスクを追加...",
|
||||
deleteTaskConfirm: "タスクを削除しますか?",
|
||||
autoSaved: "保存済み",
|
||||
saving: "保存中...",
|
||||
created: "作成日時",
|
||||
edited: "更新日時",
|
||||
previewTab: "プレビュー",
|
||||
editTab: "編集",
|
||||
|
||||
// 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: "実験的な機能とUIのカスタマイズ設定です。",
|
||||
kanbanView: "カンバン",
|
||||
listView: "リスト",
|
||||
enableKanban: "カンバンボードビュー",
|
||||
enableKanbanDesc: "タスクリストでリストビューと3カラムカンバンビューを切り替えられるようにします。",
|
||||
swapBlocks: "ブロック順序の入れ替え",
|
||||
resetDefaults: "初期状態に戻す",
|
||||
resetConfirm: "すべてのカスタマイズ設定を初期状態に戻しますか?",
|
||||
todoCol: "To Do",
|
||||
inProgressCol: "進行中",
|
||||
doneCol: "完了",
|
||||
accessRestricted: "アクセス制限",
|
||||
adminRequired: "このコンソールには ADMIN 権限が必要です。",
|
||||
};
|
||||
@@ -1,236 +0,0 @@
|
||||
import { TranslationDict } from "../types";
|
||||
|
||||
export const ko: TranslationDict = {
|
||||
// Auth
|
||||
appName: "CheckFlow",
|
||||
tagline: "TickTick 스타일의 독립형 To-Do & 노트 웹앱",
|
||||
welcomeBack: "다시 오신 것을 환영합니다",
|
||||
signInSubtitle: "안전하게 로그인하여 할 일을 관리하세요",
|
||||
createAccount: "계정 만들기",
|
||||
createAccountSubtitle: "셀프호스팅 To-Do 플랫폼을 시작하세요",
|
||||
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: "할 일 가져오기 (CSV / ICS)",
|
||||
targetList: "가져올 대상 목록",
|
||||
fileSelectLabel: ".csv 또는 .ics 파일 선택 (TickTick 백업 지원)",
|
||||
tickTickExportHint: "TickTick 내보내기 방법: 설정 → 백업 → CSV 내보내기",
|
||||
importBtn: "가져오기",
|
||||
importing: "가져오는 중...",
|
||||
exportTasks: "할 일 내보내기",
|
||||
exportModalTitle: "할 일 내보내기 (CSV / ICS)",
|
||||
exportFormat: "파일 포맷",
|
||||
exportScope: "내보낼 대상",
|
||||
allLists: "전체 목록",
|
||||
includeCompleted: "완료된 작업 포함",
|
||||
exportBtn: "내보내기",
|
||||
exportSuccess: "할 일이 성공적으로 내보내졌습니다",
|
||||
theme: "테마",
|
||||
themeSystem: "시스템",
|
||||
themeLight: "라이트",
|
||||
themeDark: "다크",
|
||||
language: "언어",
|
||||
tags: "태그",
|
||||
trash: "휴지통",
|
||||
emptyTrash: "휴지통 비우기",
|
||||
searchPlaceholder: "할 일 검색...",
|
||||
searchExpandedPlaceholder: "작업 제목, 태그, 메모 검색...",
|
||||
|
||||
// Settings Modal - General & Tabs
|
||||
settingsModalTitle: "설정",
|
||||
profile: "프로필",
|
||||
preferences: "환경설정",
|
||||
syncIntegrations: "외부 연동",
|
||||
admin: "관리자",
|
||||
trashRetention: "휴지통 자동 삭제 보관 기간",
|
||||
trashRetentionHint: "삭제된 작업은 지정된 기간이 지나면 영구적으로 자동 삭제됩니다.",
|
||||
days7: "7일",
|
||||
days14: "14일",
|
||||
days30: "30일 (권장)",
|
||||
neverDelete: "자동 삭제 안 함 (수동 비우기만)",
|
||||
settingsSaved: "설정이 성공적으로 저장되었습니다",
|
||||
passwordChangePlaceholder: "새 비밀번호 (현재 비밀번호 유지 시 비워두기)",
|
||||
yourNamePlaceholder: "사용자 이름",
|
||||
|
||||
// Settings Modal - Labs & Customization
|
||||
labsSectionView: "뷰 설정",
|
||||
labsSectionLayout: "레이아웃",
|
||||
labsSectionAppearance: "외형 및 디자인",
|
||||
labsKanbanTitle: "칸반 보드",
|
||||
labsKanbanDesc: "목록 뷰와 3개 컬럼의 칸반 보드(To Do / 진행 중 / 완료) 간 전환을 지원합니다.",
|
||||
labsDensityTitle: "화면 표시 밀도",
|
||||
labsDensityDesc: "태스크 항목의 세로 여백 및 행 높이를 조절합니다.",
|
||||
densityCompact: "컴팩트",
|
||||
densityDefault: "기본값",
|
||||
densityComfortable: "여유롭게",
|
||||
sidebarWidthTitle: "사이드바 기본 너비",
|
||||
sidebarWidthDesc: "사이드바 경계선을 드래그하거나 슬라이더로 조절할 수 있습니다.",
|
||||
detailWidthTitle: "상세 패널 기본 너비",
|
||||
detailWidthDesc: "우측 상세 탭 경계선을 드래그하거나 슬라이더로 조절할 수 있습니다.",
|
||||
fontSizeTitle: "기본 폰트 크기",
|
||||
fontSizeDesc: "앱 전체에 적용되는 텍스트 기준 크기를 설정합니다.",
|
||||
fontSizeSmall: "작게",
|
||||
fontSizeMedium: "보통",
|
||||
fontSizeLarge: "크게",
|
||||
accentColorTitle: "포인트(액센트) 색상",
|
||||
accentColorDesc: "강조 색상의 색조(Hue)와 채도(Saturation)를 사용자에 맞게 조절합니다.",
|
||||
saturationLabel: "채도",
|
||||
hueLabel: "색조 °",
|
||||
roundnessTitle: "모서리 둥글기",
|
||||
roundnessDesc: "카드, 버튼 및 UI 요소의 모서리 라운드 스타일을 설정합니다.",
|
||||
roundnessSharp: "각지게",
|
||||
roundnessDefault: "기본값",
|
||||
roundnessRound: "둥글게",
|
||||
animationSpeedTitle: "애니메이션 속도",
|
||||
animationSpeedDesc: "화면 전환 및 호버 인터랙션 효과의 속도를 조절합니다.",
|
||||
animationOff: "끄기",
|
||||
animationFast: "빠르게",
|
||||
animationDefault: "기본값",
|
||||
animationSlow: "부드럽게",
|
||||
resetDefaultsTitle: "설정 기본값 복원",
|
||||
resetDefaultsDesc: "모든 레이아웃, 디자인 및 뷰 커스터마이징 설정을 초기 순정 상태로 되돌립니다.",
|
||||
|
||||
// Settings Modal - Admin tab
|
||||
adminCurrentAccount: "현재 계정",
|
||||
adminIsolationMode: "격리 모드",
|
||||
adminMultiUserPrivacy: "완전한 멀티유저 프라이버시",
|
||||
adminCalDavEndpoint: "CalDAV 엔드포인트",
|
||||
adminActiveEnabled: "정상 동작 중",
|
||||
adminOpenDashboard: "관리자 콘솔 대시보드 열기 →",
|
||||
|
||||
// CalDAV & Sync
|
||||
syncTitle: "CalDAV 모바일 동기화 (Android / Apple)",
|
||||
syncDesc: "CheckFlow는 표준 CalDAV 프로토콜을 통해 Android, Apple 미리알림, Thunderbird와의 완전한 양방향 동기화를 지원합니다.",
|
||||
syncBaseUrl: "CalDAV 서버 주소 (Base URL)",
|
||||
syncCopyUrl: "URL 복사",
|
||||
syncCopied: "복사됨!",
|
||||
syncDownloadIcs: ".ICS 피드 다운로드",
|
||||
syncTestConnection: "엔드포인트 연결 테스트",
|
||||
syncTesting: "연결 확인 중...",
|
||||
syncTestSuccess: "CalDAV 서버가 정상적으로 응답했습니다",
|
||||
syncTestError: "엔드포인트 테스트 실패",
|
||||
syncTabAndroid: "Android (DAVx⁵)",
|
||||
syncTabApple: "Apple 미리알림",
|
||||
syncTabThunderbird: "Thunderbird",
|
||||
syncAndroidStep1: "Google Play 스토어 또는 F-Droid에서 DAVx⁵ 앱을 설치합니다.",
|
||||
syncAndroidStep2: "'URL 및 사용자 이름으로 로그인'을 선택하고 위의 CalDAV URL을 입력합니다.",
|
||||
syncAndroidStep3: "CheckFlow 이메일 계정과 비밀번호를 입력합니다.",
|
||||
syncAndroidStep4: "Tasks.org 또는 OpenTasks 등의 앱을 열어 동기화된 할 일을 관리하세요.",
|
||||
syncAppleStep1: "시스템 설정(macOS) 또는 설정(iOS/iPadOS) → 인터넷 계정으로 이동합니다.",
|
||||
syncAppleStep2: "'계정 추가' → 'CalDAV 계정 추가'를 선택합니다.",
|
||||
syncAppleStep3: "계정 유형을 '수동'으로 설정하고 위의 CalDAV 서버 주소와 로그인 정보를 입력합니다.",
|
||||
syncAppleStep4: "Apple 미리알림(Reminders) 앱에 작업 목록이 자동으로 동기화됩니다.",
|
||||
syncThunderbirdStep1: "Mozilla Thunderbird를 열고 캘린더 / 작업 탭으로 이동합니다.",
|
||||
syncThunderbirdStep2: "캘린더 목록 우클릭 → '새 캘린더' → '네트워크에서'를 선택합니다.",
|
||||
syncThunderbirdStep3: "'CalDAV'를 선택하고 위의 CalDAV 주소와 로그인 정보를 입력합니다.",
|
||||
syncThunderbirdStep4: "CheckFlow의 작업 목록이 Thunderbird Tasks에 표시됩니다.",
|
||||
syncAndroidTip: "DAVx⁵ 계정 설정에서 동기화 주기를 15분으로 설정하면 배터리 소모를 줄이면서 실시간 동기화를 유지할 수 있습니다.",
|
||||
syncAppleTip: "Nginx/Caddy 등 리버스 프록시 뒤에서 HTTPS를 사용할 경우 올바른 SSL 인증서가 구성되어 있는지 확인하세요.",
|
||||
syncThunderbirdTip: "Thunderbird 작업 뷰에서 CheckFlow의 우선순위, 마감일, 완료 상태가 그대로 표시됩니다.",
|
||||
|
||||
// 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: "상세 내용을 입력하세요 (마크다운 지원)...",
|
||||
subtasks: "하위작업",
|
||||
addSubtaskPlaceholder: "하위 작업을 적어보세요...",
|
||||
deleteTaskConfirm: "태스크를 삭제하시겠습니까?",
|
||||
autoSaved: "저장됨",
|
||||
saving: "저장 중...",
|
||||
created: "생성일",
|
||||
edited: "수정일",
|
||||
previewTab: "미리보기",
|
||||
editTab: "편집",
|
||||
|
||||
// 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: "실험적 기능 및 UI 개인화 설정입니다.",
|
||||
kanbanView: "칸반 보드",
|
||||
listView: "목록 뷰",
|
||||
enableKanban: "칸반 보드 뷰",
|
||||
enableKanbanDesc: "목록에서 리스트 뷰와 3컬럼 칸반 보드 뷰 간을 자유롭게 전환합니다.",
|
||||
swapBlocks: "블록 순서 변경",
|
||||
resetDefaults: "기본값 복원",
|
||||
resetConfirm: "모든 커스터마이징 설정을 초기 상태로 되돌리시겠습니까?",
|
||||
todoCol: "할 일",
|
||||
inProgressCol: "진행 중",
|
||||
doneCol: "완료",
|
||||
accessRestricted: "접근 제한",
|
||||
adminRequired: "이 콘솔은 ADMIN 권한이 필요합니다.",
|
||||
};
|
||||
@@ -1,238 +0,0 @@
|
||||
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 - General & Tabs
|
||||
settingsModalTitle: string;
|
||||
profile: string;
|
||||
preferences: string;
|
||||
syncIntegrations: string;
|
||||
admin: string;
|
||||
trashRetention: string;
|
||||
trashRetentionHint: string;
|
||||
days7: string;
|
||||
days14: string;
|
||||
days30: string;
|
||||
neverDelete: string;
|
||||
settingsSaved: string;
|
||||
passwordChangePlaceholder: string;
|
||||
yourNamePlaceholder: string;
|
||||
|
||||
// Settings Modal - Labs & Customization
|
||||
labsSectionView: string;
|
||||
labsSectionLayout: string;
|
||||
labsSectionAppearance: string;
|
||||
labsKanbanTitle: string;
|
||||
labsKanbanDesc: string;
|
||||
labsDensityTitle: string;
|
||||
labsDensityDesc: string;
|
||||
densityCompact: string;
|
||||
densityDefault: string;
|
||||
densityComfortable: string;
|
||||
sidebarWidthTitle: string;
|
||||
sidebarWidthDesc: string;
|
||||
detailWidthTitle: string;
|
||||
detailWidthDesc: string;
|
||||
fontSizeTitle: string;
|
||||
fontSizeDesc: string;
|
||||
fontSizeSmall: string;
|
||||
fontSizeMedium: string;
|
||||
fontSizeLarge: string;
|
||||
accentColorTitle: string;
|
||||
accentColorDesc: string;
|
||||
saturationLabel: string;
|
||||
hueLabel: string;
|
||||
roundnessTitle: string;
|
||||
roundnessDesc: string;
|
||||
roundnessSharp: string;
|
||||
roundnessDefault: string;
|
||||
roundnessRound: string;
|
||||
animationSpeedTitle: string;
|
||||
animationSpeedDesc: string;
|
||||
animationOff: string;
|
||||
animationFast: string;
|
||||
animationDefault: string;
|
||||
animationSlow: string;
|
||||
resetDefaultsTitle: string;
|
||||
resetDefaultsDesc: string;
|
||||
|
||||
// Settings Modal - Admin tab
|
||||
adminCurrentAccount: string;
|
||||
adminIsolationMode: string;
|
||||
adminMultiUserPrivacy: string;
|
||||
adminCalDavEndpoint: string;
|
||||
adminActiveEnabled: string;
|
||||
adminOpenDashboard: string;
|
||||
|
||||
// CalDAV & Sync
|
||||
syncTitle: string;
|
||||
syncDesc: string;
|
||||
syncBaseUrl: string;
|
||||
syncCopyUrl: string;
|
||||
syncCopied: string;
|
||||
syncDownloadIcs: string;
|
||||
syncTestConnection: string;
|
||||
syncTesting: string;
|
||||
syncTestSuccess: string;
|
||||
syncTestError: 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;
|
||||
syncAndroidTip: string;
|
||||
syncAppleTip: string;
|
||||
syncThunderbirdTip: 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;
|
||||
previewTab: string;
|
||||
editTab: 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;
|
||||
+32
-94
@@ -71,7 +71,7 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
||||
listId: "list-1",
|
||||
parentId: null,
|
||||
title: "Setup Self-Hosted CheckFlow on NAS",
|
||||
note: "## 🐳 Docker Deployment Guide\n\n- Deploy with `docker compose up -d`\n- Forward port 3000 to NPM (Nginx Proxy Manager)\n- Setup SSL certificate for custom domain\n\n### 🔗 Key Endpoints\n- Web App: https://todo.yourdomain.com\n- CalDAV: 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- CardDAV: https://todo.yourdomain.com/api/dav",
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
dueDate: new Date(Date.now() + 86400000).toISOString(),
|
||||
@@ -124,7 +124,7 @@ export const INITIAL_DEMO_TASKS: MockTask[] = [
|
||||
id: "sub-1-2",
|
||||
listId: "list-1",
|
||||
parentId: "task-1",
|
||||
title: "Test CalDAV sync via DAVx⁵ on Android",
|
||||
title: "Test DAVx⁵ sync on Samsung Galaxy",
|
||||
note: null,
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
@@ -258,18 +258,11 @@ export function updateTaskInTree(tree: MockTask[], updated: MockTask): MockTask[
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete to Trash (recursively mark node and its children)
|
||||
// Soft delete to Trash
|
||||
export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] {
|
||||
const markDeleted = (node: MockTask): MockTask => ({
|
||||
...node,
|
||||
isDeleted: true,
|
||||
deletedAt: new Date().toISOString(),
|
||||
children: node.children ? node.children.map(markDeleted) : [],
|
||||
});
|
||||
|
||||
return tree.map((node) => {
|
||||
if (node.id === id) {
|
||||
return markDeleted(node);
|
||||
return { ...node, isDeleted: true, deletedAt: new Date().toISOString() };
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
return { ...node, children: moveToTrashInTree(node.children, id) };
|
||||
@@ -278,73 +271,29 @@ export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] {
|
||||
});
|
||||
}
|
||||
|
||||
// Restore task (and all its subtasks) from Trash, ensuring ancestor parents are also un-deleted
|
||||
export function restoreTaskInTree(tree: MockTask[], targetId: string): MockTask[] {
|
||||
const unmarkNodeAndChildren = (node: MockTask): MockTask => ({
|
||||
...node,
|
||||
isDeleted: false,
|
||||
deletedAt: null,
|
||||
children: node.children ? node.children.map(unmarkNodeAndChildren) : [],
|
||||
});
|
||||
|
||||
function processNodes(nodes: MockTask[]): { updated: MockTask[]; found: boolean } {
|
||||
let foundInThisLevel = false;
|
||||
const updated = nodes.map((node) => {
|
||||
if (node.id === targetId) {
|
||||
foundInThisLevel = true;
|
||||
return unmarkNodeAndChildren(node);
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const res = processNodes(node.children);
|
||||
if (res.found) {
|
||||
foundInThisLevel = true;
|
||||
// If a child was restored, ancestor parent must also be restored
|
||||
return {
|
||||
...node,
|
||||
isDeleted: false,
|
||||
deletedAt: null,
|
||||
children: res.updated,
|
||||
};
|
||||
}
|
||||
return { ...node, children: res.updated };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
return { updated, found: foundInThisLevel };
|
||||
}
|
||||
|
||||
return processNodes(tree).updated;
|
||||
}
|
||||
|
||||
// Recursively filter active tree (stripping isDeleted tasks at all depths)
|
||||
export function filterActiveTree(nodes: MockTask[], showCompleted = true): MockTask[] {
|
||||
const result: MockTask[] = [];
|
||||
for (const node of nodes) {
|
||||
if (!node.isDeleted) {
|
||||
if (showCompleted || !node.completed) {
|
||||
const cleanChildren = node.children ? filterActiveTree(node.children, showCompleted) : [];
|
||||
result.push({ ...node, children: cleanChildren });
|
||||
}
|
||||
// Restore from Trash
|
||||
export function restoreTaskInTree(tree: MockTask[], id: string): MockTask[] {
|
||||
return tree.map((node) => {
|
||||
if (node.id === id) {
|
||||
return { ...node, isDeleted: false, deletedAt: null };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Completely remove task and all its descendants from active tree state
|
||||
export function removeTaskFromTree(tree: MockTask[], idToDelete: string): MockTask[] {
|
||||
return tree
|
||||
.filter((node) => node.id !== idToDelete)
|
||||
.map((node) => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
return { ...node, children: removeTaskFromTree(node.children, idToDelete) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
if (node.children && node.children.length > 0) {
|
||||
return { ...node, children: restoreTaskInTree(node.children, id) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
// Permanent delete
|
||||
export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask[] {
|
||||
return removeTaskFromTree(tree, idToDelete);
|
||||
return tree
|
||||
.filter((node) => node.id !== idToDelete)
|
||||
.map((node) => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
return { ...node, children: deleteTaskInTree(node.children, idToDelete) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
export function emptyTrashInTree(tree: MockTask[]): MockTask[] {
|
||||
@@ -385,24 +334,18 @@ export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all top-level trash tasks preserving intact nested sub-tasks hierarchy
|
||||
// Get all active tasks or all trash tasks flattened
|
||||
export function getAllTrashTasks(tree: MockTask[]): MockTask[] {
|
||||
const result: MockTask[] = [];
|
||||
|
||||
function collect(nodes: MockTask[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.isDeleted) {
|
||||
// Collect top-level deleted item with its full subtasks tree intact
|
||||
result.push(node);
|
||||
} else if (node.children && node.children.length > 0) {
|
||||
// If parent is not deleted, check if any subtask was individually deleted
|
||||
collect(node.children);
|
||||
}
|
||||
let trash: MockTask[] = [];
|
||||
for (const node of tree) {
|
||||
if (node.isDeleted) {
|
||||
trash.push(node);
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
trash = trash.concat(getAllTrashTasks(node.children));
|
||||
}
|
||||
}
|
||||
|
||||
collect(tree);
|
||||
return result;
|
||||
return trash;
|
||||
}
|
||||
|
||||
// Filter tasks by custom tag
|
||||
@@ -417,9 +360,4 @@ export function filterTasksByTag(tree: MockTask[], tagName: string): MockTask[]
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
// Get count of active tasks for a tag
|
||||
export function getTagTaskCount(tree: MockTask[], tagName: string): number {
|
||||
return filterTasksByTag(tree, tagName).filter((t) => !t.completed).length;
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -8,7 +8,6 @@ export async function middleware(request: NextRequest) {
|
||||
// Public paths:
|
||||
// - /login, /register: Auth pages
|
||||
// - /demo: Local preview without DB
|
||||
// - /admin: Admin page (handles demo preview & auth gate internally)
|
||||
// - /api/auth: NextAuth endpoints
|
||||
// - /api/dav: DAVx⁵ uses HTTP Basic Auth
|
||||
// - Static assets & metadata
|
||||
@@ -16,7 +15,6 @@ export async function middleware(request: NextRequest) {
|
||||
"/login",
|
||||
"/register",
|
||||
"/demo",
|
||||
"/admin",
|
||||
"/api/auth",
|
||||
"/api/dav",
|
||||
"/icons",
|
||||
@@ -45,6 +43,20 @@ export async function middleware(request: NextRequest) {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user