Author SHA1 Message Date
Neru_Han 8e5b9238e2 feat: enhance DND nesting & promotion, isolated tags API, fix completion bounce UI, add Admin i18n switcher, refine typography & polish UX (v0.7.2)
Build and Push Docker Image / build-and-push (push) Successful in 9m59s
2026-08-22 10:01:05 +09:00
Neru_Han 0f44090210 feat(ux): enhance n-depth tree DND reordering, full i18n localization, admin live stats & sidebar redesign
Build and Push Docker Image / build-and-push (push) Successful in 9m22s
2026-08-22 09:08:10 +09:00
Neru_Han 06d5344aef feat(core): full database soft-delete, live sidebar counts and robust subtask undo/restore across all modes
Build and Push Docker Image / build-and-push (push) Successful in 10m12s
2026-08-22 00:26:45 +09:00
Neru_Han 72fefe89b0 fix(trash): implement robust ancestor-preserving restore and filterActiveTree for trash reliability
Build and Push Docker Image / build-and-push (push) Successful in 9m29s
2026-08-21 23:34:23 +09:00
Neru_Han fc70b1e8cb fix(trash & tags): fix subtasks hierarchy restoration in trash and real-time tag counts sync
Build and Push Docker Image / build-and-push (push) Successful in 9m16s
2026-08-21 23:00:50 +09:00
Neru_Han 4d1da38085 fix(trash & sidebar): isolate trash view, sync list task counts in real-time, and fix restore/delete actions
Build and Push Docker Image / build-and-push (push) Successful in 10m24s
2026-08-21 22:32:02 +09:00
Neru_Han baf13a069a feat(ui): Redesign sidebar, one-touch toggles, fix trash & subtask optimistic UI, auto-admin, and full n-depth DND
Build and Push Docker Image / build-and-push (push) Successful in 10m30s
2026-08-21 21:53:29 +09:00
Neru_Han 09f8fdc55e fix(pwa): harden service worker fetch handler to prevent ERR_FAILED
Build and Push Docker Image / build-and-push (push) Failing after 16m52s
2026-08-21 20:34:42 +09:00
27 changed files with 2194 additions and 888 deletions
+18
View File
@@ -15,6 +15,15 @@
- `src/middleware.ts`: Route-level auth and admin role verification guard. - `src/middleware.ts`: Route-level auth and admin role verification guard.
- **Completed So Far:** - **Completed So Far:**
- 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. - Rewrote entire Git commit history with active local Git identity across all branches and tags, and force-pushed to self-hosted Gitea remote origin.
- Conducted full audit of codebase against all specification items in `AGENTS.md`. - Conducted full audit of codebase against all specification items in `AGENTS.md`.
- Verified 100% implementation of core features: n-depth recursive checklist, real-time subtask synchronization, modular blocks & split resizer, 3-way theme (VSCode dark / pastel light / system), global `UserPrefs` customization, Labs Kanban view, and mobile bottom sheet. - Verified 100% implementation of core features: n-depth recursive checklist, real-time subtask synchronization, modular blocks & split resizer, 3-way theme (VSCode dark / pastel light / system), global `UserPrefs` customization, Labs Kanban view, and mobile bottom sheet.
@@ -30,9 +39,18 @@
- 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. - 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. - 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. - 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).
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings. - Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
- **Next Steps (Todo):** - **Next Steps (Todo):**
- 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). - 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. - Add optional automatic synchronization background polling / webhook integration if requested.
- Plan next feature iterations or custom integrations as requested by user. - Plan next feature iterations or custom integrations as requested by user.
+4 -1
View File
@@ -83,7 +83,10 @@ src/
| `checkpoint-v0.6.1` | CalDAV / DAVx⁵ 양방향 동기화 프로토콜 고도화(OPTIONS, PROPFIND, REPORT, PUT, DELETE), 설정 모달 내 플랫폼별(Android DAVx⁵ / Apple Reminders / Thunderbird) 인터랙티브 가이드 및 실시간 엔드포인트 테스트 핑, 다국어 i18n 동기화 | 2026-08-21 | | `checkpoint-v0.6.1` | CalDAV / DAVx⁵ 양방향 동기화 프로토콜 고도화(OPTIONS, PROPFIND, REPORT, PUT, DELETE), 설정 모달 내 플랫폼별(Android DAVx⁵ / Apple Reminders / Thunderbird) 인터랙티브 가이드 및 실시간 엔드포인트 테스트 핑, 다국어 i18n 동기화 | 2026-08-21 |
| `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 | | `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
| `checkpoint-v0.6.3` | 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 | | `checkpoint-v0.6.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.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 |
--- ---
@@ -0,0 +1,6 @@
-- 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");
+4
View File
@@ -63,6 +63,9 @@ model Task {
priority Int @default(0) priority Int @default(0)
sortOrder Int @default(0) sortOrder Int @default(0)
isDeleted Boolean @default(false)
deletedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -75,6 +78,7 @@ model Task {
@@index([userId]) @@index([userId])
@@index([listId]) @@index([listId])
@@index([parentId]) @@index([parentId])
@@index([isDeleted])
} }
model Tag { model Tag {
+31 -8
View File
@@ -1,9 +1,9 @@
const CACHE_NAME = "checkflow-v1"; const CACHE_NAME = "checkflow-v2";
const STATIC_ASSETS = ["/", "/login", "/register", "/manifest.json", "/icons/icon.svg"]; const STATIC_ASSETS = ["/manifest.json", "/icons/icon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).catch(() => {})
); );
self.skipWaiting(); self.skipWaiting();
}); });
@@ -21,14 +21,37 @@ self.addEventListener("fetch", (event) => {
const { request } = event; const { request } = event;
const url = new URL(request.url); const url = new URL(request.url);
// Network-first for API // Only handle http/https GET requests to the same origin
if (url.pathname.startsWith("/api")) { if (request.method !== "GET" || !url.protocol.startsWith("http") || url.origin !== self.location.origin) {
event.respondWith(fetch(request).catch(() => new Response(JSON.stringify({ error: "Offline" }), { headers: { "Content-Type": "application/json" } })));
return; return;
} }
// Cache-first for static // Network-first for API & navigation routes to avoid stale/failed page loads
if (url.pathname.startsWith("/api") || request.mode === "navigate") {
event.respondWith( event.respondWith(
caches.match(request).then((cached) => cached || fetch(request)) fetch(request).catch(() => {
if (url.pathname.startsWith("/api")) {
return new Response(JSON.stringify({ error: "Offline or service unavailable" }), {
headers: { "Content-Type": "application/json" },
});
}
return caches.match(request).then((cached) => cached || Response.error());
})
);
return;
}
// Cache-first for static assets with safe network fallback
event.respondWith(
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());
})
); );
}); });
+123 -38
View File
@@ -1,9 +1,10 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { getDemoStore } from "@/lib/mockData"; import { getDemoStore } from "@/lib/mockData";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
interface AdminUser { interface AdminUser {
id: string; id: string;
@@ -15,7 +16,7 @@ interface AdminUser {
active: boolean; active: boolean;
} }
const INITIAL_ADMIN_USERS: AdminUser[] = [ const INITIAL_DEMO_USERS: AdminUser[] = [
{ {
id: "user-1", id: "user-1",
name: "Admin Master", name: "Admin Master",
@@ -47,29 +48,20 @@ const INITIAL_ADMIN_USERS: AdminUser[] = [
export default function AdminPage() { export default function AdminPage() {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const { t } = useI18n(); const { t, lang } = useI18n();
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;
});
// Is this running in Demo mode (no session or unauthenticated)
const isDemo = status === "unauthenticated" || !session; 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 [search, setSearch] = useState("");
const userRole = session?.user?.role; const userRole = session?.user?.role;
const userEmail = session?.user?.email; const userEmail = session?.user?.email;
const isAuthorized = const isAuthorized =
@@ -79,7 +71,49 @@ export default function AdminPage() {
userEmail?.startsWith("admin@") || userEmail?.startsWith("admin@") ||
userEmail === "admin@checkflow.local"; 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]);
useEffect(() => {
if (status !== "loading") {
fetchAdminData();
}
}, [status, fetchAdminData]);
if (status === "loading") { if (status === "loading") {
return ( return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}> <div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
@@ -114,10 +148,27 @@ export default function AdminPage() {
); );
} }
const toggleRole = (id: string) => { const toggleRole = async (user: AdminUser) => {
const newRole = user.role === "ADMIN" ? "USER" : "ADMIN";
if (isDemo) {
setUsers((prev) => setUsers((prev) =>
prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u)) 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 toggleStatus = (id: string) => { const toggleStatus = (id: string) => {
@@ -126,9 +177,26 @@ export default function AdminPage() {
); );
}; };
const deleteUser = (id: string) => { const deleteUser = async (user: AdminUser) => {
if (confirm("Are you sure you want to delete this user and all their tasks?")) { if (!confirm(`Are you sure you want to delete ${user.name} (${user.email}) and all their tasks?`)) {
setUsers((prev) => prev.filter((u) => u.id !== id)); 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);
} }
}; };
@@ -149,10 +217,14 @@ export default function AdminPage() {
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}> <h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
CheckFlow Admin Console CheckFlow Admin Console
</h1> </h1>
{isDemo && ( {isDemo ? (
<span className="demo-badge" style={{ fontSize: 11, padding: "2px 8px" }}> <span className="demo-badge" style={{ fontSize: 11, padding: "2px 8px" }}>
Demo Preview Demo Preview
</span> </span>
) : (
<span className="badge badge-primary" style={{ fontSize: 11, padding: "2px 8px" }}>
Live Instance
</span>
)} )}
</div> </div>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}> <p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
@@ -161,12 +233,19 @@ export default function AdminPage() {
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<LanguageSelector />
{isDemo ? (
<Link href="/demo" className="btn btn-ghost btn-sm"> <Link href="/demo" className="btn btn-ghost btn-sm">
Back to Demo {lang === "ko" ? "데모로 돌아가기" : lang === "ja" ? "デモに戻る" : "Back to Demo"}
</Link> </Link>
) : (
<button type="button" className="btn btn-ghost btn-sm" onClick={fetchAdminData}>
🔄 {lang === "ko" ? "새로고침" : lang === "ja" ? "更新" : "Refresh"}
</button>
)}
<Link href="/" className="btn btn-primary btn-sm"> <Link href="/" className="btn btn-primary btn-sm">
🚀 Open Dashboard 🚀 {lang === "ko" ? "할 일 대시보드" : lang === "ja" ? "タスク一覧" : "Open Dashboard"}
</Link> </Link>
</div> </div>
</div> </div>
@@ -175,25 +254,25 @@ export default function AdminPage() {
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16, marginBottom: 28 }}> <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={{ 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: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Users</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "var(--accent)", marginTop: 4 }}>{users.length}</div> <div style={{ fontSize: 28, fontWeight: 800, color: "var(--accent)", marginTop: 4 }}>{stats.totalUsers}</div>
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}> 100% active instances</div> <div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}> 100% active instances</div>
</div> </div>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}> <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: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Projects / Lists</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "#10B981", marginTop: 4 }}>{totalLists}</div> <div style={{ fontSize: 28, fontWeight: 800, color: "#10B981", marginTop: 4 }}>{stats.totalLists}</div>
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Across all users</div> <div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Across all users</div>
</div> </div>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}> <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: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>Total Tasks & Notes</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "#8B5CF6", marginTop: 4 }}>{totalTasks}</div> <div style={{ fontSize: 28, fontWeight: 800, color: "#8B5CF6", marginTop: 4 }}>{stats.totalTasks}</div>
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Recursive subtasks included</div> <div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 2 }}>Recursive subtasks included</div>
</div> </div>
<div style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)", padding: "16px 20px", borderRadius: "var(--radius-md)" }}> <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: 12, color: "var(--text-tertiary)", fontWeight: 600, textTransform: "uppercase" }}>CalDAV / DAVx Status</div>
<div style={{ fontSize: 28, fontWeight: 800, color: "#F59E0B", marginTop: 4 }}>Active</div> <div style={{ fontSize: 28, fontWeight: 800, color: "#F59E0B", marginTop: 4 }}>{stats.caldavStatus}</div>
<div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}> Basic Auth Bypass Verified</div> <div style={{ fontSize: 11, color: "var(--success)", marginTop: 2 }}> Basic Auth Bypass Verified</div>
</div> </div>
</div> </div>
@@ -218,6 +297,11 @@ export default function AdminPage() {
{/* Users Table */} {/* Users Table */}
<div style={{ overflowX: "auto" }}> <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 }}> <table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left", fontSize: 13 }}>
<thead> <thead>
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-tertiary)" }}> <tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-tertiary)" }}>
@@ -267,7 +351,7 @@ export default function AdminPage() {
<button <button
type="button" type="button"
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
onClick={() => toggleRole(u.id)} onClick={() => toggleRole(u)}
title="Change role" title="Change role"
style={{ fontSize: 11, padding: "3px 8px" }} style={{ fontSize: 11, padding: "3px 8px" }}
> >
@@ -285,7 +369,7 @@ export default function AdminPage() {
<button <button
type="button" type="button"
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
onClick={() => deleteUser(u.id)} onClick={() => deleteUser(u)}
title="Delete user" title="Delete user"
style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }} style={{ fontSize: 11, padding: "3px 8px", color: "var(--danger)" }}
> >
@@ -297,6 +381,7 @@ export default function AdminPage() {
))} ))}
</tbody> </tbody>
</table> </table>
)}
</div> </div>
</div> </div>
</div> </div>
+41
View File
@@ -0,0 +1,41 @@
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 });
}
}
+94
View File
@@ -0,0 +1,94 @@
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 });
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ export async function GET() {
where: { userId: session.user.id }, where: { userId: session.user.id },
orderBy: { sortOrder: "asc" }, orderBy: { sortOrder: "asc" },
include: { include: {
_count: { select: { tasks: { where: { completed: false, parentId: null } } } }, _count: { select: { tasks: { where: { isDeleted: false, completed: false, parentId: null } } } },
}, },
}); });
return NextResponse.json(lists); return NextResponse.json(lists);
+70
View File
@@ -0,0 +1,70 @@
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 });
}
}
+47 -3
View File
@@ -36,7 +36,7 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 }); if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Sanitize allowed fields // Sanitize allowed fields
const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId"]; const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId", "isDeleted", "deletedAt"];
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
for (const key of allowed) { for (const key of allowed) {
if (key in body) data[key] = body[key]; if (key in body) data[key] = body[key];
@@ -71,9 +71,25 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
data.dueDate = data.dueDate ? new Date(data.dueDate as string) : null; 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({ const updated = await prisma.task.update({
where: { id }, where: { id },
data, data: data as any,
include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } }, include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } },
}); });
return NextResponse.json(updated); return NextResponse.json(updated);
@@ -83,7 +99,7 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
} }
} }
export async function DELETE(_: NextRequest, { params }: Ctx) { export async function DELETE(req: NextRequest, { params }: Ctx) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -92,8 +108,36 @@ export async function DELETE(_: NextRequest, { params }: Ctx) {
const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } }); const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } });
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 }); 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 } }); await prisma.task.delete({ where: { id } });
return NextResponse.json({ ok: true }); 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 });
} catch (err) { } catch (err) {
console.error("[tasks/id:DELETE]", err); console.error("[tasks/id:DELETE]", err);
return NextResponse.json({ error: "Failed to delete task" }, { status: 500 }); return NextResponse.json({ error: "Failed to delete task" }, { status: 500 });
+61 -4
View File
@@ -11,19 +11,53 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const listId = searchParams.get("listId"); const listId = searchParams.get("listId");
const showCompleted = searchParams.get("showCompleted") === "true"; 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> = { const where: Record<string, unknown> = {
userId: session.user.id, userId: session.user.id,
parentId: null, isDeleted: false,
...(listId ? { listId } : {}), ...(listId ? { listId, parentId: null } : tagName ? {} : { parentId: null }),
...(showCompleted ? {} : { completed: false }), ...(showCompleted ? {} : { completed: false }),
...(tagName ? { tags: { some: { tag: { name: tagName } } } } : {}),
}; };
const tasks = await prisma.task.findMany({ const tasks = await prisma.task.findMany({
where, where: where as any,
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
include: { include: {
children: { orderBy: [{ sortOrder: "asc" }] }, children: {
where: { isDeleted: false } as any,
orderBy: [{ sortOrder: "asc" }],
include: {
children: { where: { isDeleted: false } as any, orderBy: [{ sortOrder: "asc" }] },
tags: { include: { tag: true } },
},
},
tags: { include: { tag: true } }, tags: { include: { tag: true } },
}, },
}); });
@@ -34,6 +68,29 @@ 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) { export async function POST(req: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
+28 -2
View File
@@ -459,9 +459,30 @@ a { color: inherit; text-decoration: none; }
color: var(--accent); color: var(--accent);
} }
.sidebar {
width: var(--sidebar-w, var(--sidebar-width));
min-width: var(--sidebar-w, var(--sidebar-width));
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
transition: width var(--dur-normal) var(--ease-out);
}
.sidebar-search-box:hover {
border-color: var(--accent) !important;
background: var(--bg-primary) !important;
box-shadow: 0 0 0 2px var(--accent-light);
}
.sidebar-footer { .sidebar-footer {
margin-top: auto;
padding: 8px 8px 12px; padding: 8px 8px 12px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
background: var(--bg-secondary);
} }
.user-card { .user-card {
@@ -471,10 +492,15 @@ a { color: inherit; text-decoration: none; }
padding: 8px 10px; padding: 8px 10px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
transition: background var(--dur-fast); transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
border: 1px solid transparent;
} }
.user-card:hover { background: var(--bg-hover); } .user-card:hover {
background: var(--bg-primary);
border-color: var(--border);
box-shadow: var(--shadow-xs);
}
.user-avatar { .user-avatar {
width: 32px; width: 32px;
+110 -37
View File
@@ -5,6 +5,7 @@ import { TaskList, Task, List, User } from "../tasks/TaskList";
import { useUserPrefs, usePrefsStyle } from "@/lib/useUserPrefs"; import { useUserPrefs, usePrefsStyle } from "@/lib/useUserPrefs";
import { TaskDetail } from "../tasks/TaskDetail"; import { TaskDetail } from "../tasks/TaskDetail";
import { CommandPalette } from "../ui/CommandPalette"; import { CommandPalette } from "../ui/CommandPalette";
import { useI18n } from "@/lib/i18n";
import { import {
getDemoStore, getDemoStore,
saveDemoStore, saveDemoStore,
@@ -20,6 +21,8 @@ import {
findTaskInTree, findTaskInTree,
getAllTrashTasks, getAllTrashTasks,
filterTasksByTag, filterTasksByTag,
filterActiveTree,
removeTaskFromTree,
} from "@/lib/mockData"; } from "@/lib/mockData";
interface AppShellProps { interface AppShellProps {
@@ -56,6 +59,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const [showCompleted, setShowCompleted] = useState(false); const [showCompleted, setShowCompleted] = useState(false);
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false); const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const { t } = useI18n();
// Global user preferences (reactive) // Global user preferences (reactive)
const { prefs, updatePrefs } = useUserPrefs(); const { prefs, updatePrefs } = useUserPrefs();
@@ -116,7 +120,9 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
return () => document.removeEventListener("checkflow:openCommandPalette", handler); return () => document.removeEventListener("checkflow:openCommandPalette", handler);
}, []); }, []);
// Demo tasks filter & reconstruct hierarchical structure const [apiTrashCount, setApiTrashCount] = useState(0);
// Task filter & reconstruct hierarchical structure for Demo and API modes
useEffect(() => { useEffect(() => {
if (isDemo) { if (isDemo) {
const store = getDemoStore(); const store = getDemoStore();
@@ -126,10 +132,36 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
} else if (selectedTag) { } else if (selectedTag) {
setTasks(filterTasksByTag(store.tasks, selectedTag) as Task[]); setTasks(filterTasksByTag(store.tasks, selectedTag) as Task[]);
} else if (selectedListId) { } else if (selectedListId) {
const listTasks = (store.tasks as Task[]).filter( const listTasks = filterActiveTree(store.tasks as MockTask[], showCompleted).filter(
(t) => !t.isDeleted && t.listId === selectedListId && (showCompleted ? true : !t.completed) (t) => t.listId === selectedListId
); );
setTasks(listTasks); 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));
} }
} }
}, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]); }, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]);
@@ -161,18 +193,28 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const handleTagSelect = useCallback((tagName: string | null) => { const handleTagSelect = useCallback((tagName: string | null) => {
setIsTrashActive(false); setIsTrashActive(false);
setSelectedListId(null);
setSelectedTag(tagName); setSelectedTag(tagName);
setSelectedTask(null); setSelectedTask(null);
setSidebarOpen(false); setSidebarOpen(false);
}, []); }, []);
const handleTrashSelect = useCallback(() => { const handleTrashSelect = useCallback(() => {
setIsTrashActive(true);
setSelectedTag(null);
setSelectedListId(null); setSelectedListId(null);
setSelectedTag(null);
setIsTrashActive(true);
setSelectedTask(null); setSelectedTask(null);
setSidebarOpen(false); 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 // Update List Name
const handleUpdateListName = async (id: string, newName: string) => { const handleUpdateListName = async (id: string, newName: string) => {
@@ -303,7 +345,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const [undoToast, setUndoToast] = useState<{ task: Task; title: string } | null>(null); const [undoToast, setUndoToast] = useState<{ task: Task; title: string } | null>(null);
const undoTimerRef = useRef<NodeJS.Timeout | null>(null); const undoTimerRef = useRef<NodeJS.Timeout | null>(null);
// Unified task delete handler with immediate Undo Toast // Unified task delete handler with immediate Undo Toast (recursively removes task & all descendants)
const handleDeleteTaskWithUndo = useCallback((task: Task) => { const handleDeleteTaskWithUndo = useCallback((task: Task) => {
if (undoTimerRef.current) clearTimeout(undoTimerRef.current); if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
@@ -313,20 +355,37 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
const store = getDemoStore(); const store = getDemoStore();
const newTasks = moveToTrashInTree(store.tasks, task.id); const newTasks = moveToTrashInTree(store.tasks, task.id);
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== task.id)); 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); if (selectedTask?.id === task.id) setSelectedTask(null);
} else { } else {
setTasks((prev) => prev.filter((t) => t.id !== task.id)); // 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); if (selectedTask?.id === task.id) setSelectedTask(null);
fetch(`/api/tasks/${task.id}`, { method: "DELETE" }).catch((e) => console.error(e));
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(() => { undoTimerRef.current = setTimeout(() => {
setUndoToast(null); setUndoToast(null);
}, 6000); }, 6000);
}, [isDemo, selectedTask]); }, [isDemo, selectedTask, isTrashActive, selectedListId, selectedTag, showCompleted]);
// Undo delete // Undo delete (Restores exact soft-deleted task hierarchy)
const handleUndoDelete = useCallback(async () => { const handleUndoDelete = useCallback(async () => {
if (!undoToast) return; if (!undoToast) return;
if (undoTimerRef.current) clearTimeout(undoTimerRef.current); if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
@@ -346,28 +405,16 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
refresh(); refresh();
} else { } else {
try { try {
const res = await fetch("/api/tasks", { const res = await fetch(`/api/tasks/${task.id}`, {
method: "POST", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ isDeleted: false, deletedAt: null }),
title: task.title,
listId: task.listId,
parentId: task.parentId,
note: task.note,
completed: task.completed,
dueDate: task.dueDate,
priority: task.priority,
}),
}); });
if (res.ok) { if (res.ok) {
const created = await res.json();
if (created.listId === selectedListId) {
setTasks((prev) => [...prev, created]);
}
refresh(); refresh();
} }
} catch (err) { } catch (err) {
console.error("Failed to restore task", err); console.error("Failed to restore task via Undo", err);
} }
} }
}, [undoToast, isDemo, selectedListId, refresh]); }, [undoToast, isDemo, selectedListId, refresh]);
@@ -386,30 +433,48 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
} }
}; };
// Restore from Trash // Restore from Trash (Restores task and all its nested subtasks)
const handleRestoreTask = (id: string) => { const handleRestoreTask = async (id: string) => {
if (isDemo) { if (isDemo) {
const store = getDemoStore(); const store = getDemoStore();
const newTasks = restoreTaskInTree(store.tasks, id); const newTasks = restoreTaskInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id)); setTasks(getAllTrashTasks(newTasks) as Task[]);
refresh(); 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 // Permanent Delete
const handlePermanentDeleteTask = (id: string) => { const handlePermanentDeleteTask = async (id: string) => {
if (isDemo) { if (isDemo) {
const store = getDemoStore(); const store = getDemoStore();
const newTasks = deleteTaskInTree(store.tasks, id); const newTasks = deleteTaskInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id)); setTasks(getAllTrashTasks(newTasks) as Task[]);
refresh(); 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 // Empty Trash
const handleEmptyTrash = () => { const handleEmptyTrash = async () => {
if (!confirm("Permanently empty all items in trash?")) return; if (!confirm("Permanently empty all items in trash?")) return;
if (isDemo) { if (isDemo) {
const store = getDemoStore(); const store = getDemoStore();
@@ -417,6 +482,13 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
saveDemoStore(store.lists, newTasks); saveDemoStore(store.lists, newTasks);
setTasks([]); setTasks([]);
refresh(); refresh();
} else {
try {
await fetch("/api/tasks?trash=true", { method: "DELETE" });
refresh();
} catch (err) {
console.error("Failed to empty trash", err);
}
} }
}; };
@@ -483,6 +555,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
onTagSelect={handleTagSelect} onTagSelect={handleTagSelect}
isTrashActive={isTrashActive} isTrashActive={isTrashActive}
onTrashSelect={handleTrashSelect} onTrashSelect={handleTrashSelect}
trashCount={isDemo && typeof window !== "undefined" ? getAllTrashTasks(getDemoStore().tasks).length : apiTrashCount}
mobileOpen={sidebarOpen} mobileOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)} onClose={() => setSidebarOpen(false)}
isDemo={isDemo} isDemo={isDemo}
@@ -606,12 +679,12 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
<div className="undo-toast-content"> <div className="undo-toast-content">
<span className="undo-toast-icon">🗑</span> <span className="undo-toast-icon">🗑</span>
<span className="undo-toast-msg"> <span className="undo-toast-msg">
<strong>{undoToast.title || "할 일"}</strong>() . <strong>{undoToast.title || t("tasks")}</strong> {t("taskUndoHint")}
</span> </span>
</div> </div>
<div className="undo-toast-actions"> <div className="undo-toast-actions">
<button type="button" className="undo-toast-btn" onClick={handleUndoDelete}> <button type="button" className="undo-toast-btn" onClick={handleUndoDelete}>
{t("undoDelete")}
</button> </button>
<button <button
type="button" type="button"
+134 -109
View File
@@ -7,10 +7,11 @@ import { useTheme } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector"; import { LanguageSelector } from "@/components/ui/LanguageSelector";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu"; import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
import { SettingsModal } from "@/components/settings/SettingsModal"; import { SettingsModal } from "@/components/settings/SettingsModal";
import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore } from "@/lib/mockData"; import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore, getTagTaskCount, filterActiveTree, MockTask } from "@/lib/mockData";
interface User { id: string; name?: string | null; email?: string | null } interface User { id: string; name?: string | null; email?: string | null }
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } } 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"]; const LIST_COLORS = ["#5B8DEF", "#E05252", "#3DAD84", "#E8931A", "#8B6CF7", "#E4609B", "#0ABAD1", "#F17A3B", "#6875F5", "#14B8A6"];
@@ -24,6 +25,7 @@ interface SidebarProps {
onTagSelect: (tag: string | null) => void; onTagSelect: (tag: string | null) => void;
isTrashActive: boolean; isTrashActive: boolean;
onTrashSelect: () => void; onTrashSelect: () => void;
trashCount?: number;
mobileOpen: boolean; mobileOpen: boolean;
onClose: () => void; onClose: () => void;
isDemo?: boolean; isDemo?: boolean;
@@ -48,6 +50,7 @@ export function Sidebar({
onTagSelect, onTagSelect,
isTrashActive, isTrashActive,
onTrashSelect, onTrashSelect,
trashCount: externalTrashCount,
mobileOpen, mobileOpen,
onClose: _onClose, onClose: _onClose,
isDemo = false, isDemo = false,
@@ -72,14 +75,10 @@ export function Sidebar({
const [exportListId, setExportListId] = useState<string>("all"); const [exportListId, setExportListId] = useState<string>("all");
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true); const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : [])); const [tags, setTags] = useState<Array<MockTag | APITag>>(() => (isDemo && typeof window !== "undefined" ? getCustomTags() : []));
const [trashCount, setTrashCount] = useState(() => { const [internalTrashCount, setInternalTrashCount] = useState(0);
if (typeof window !== "undefined") {
const store = getDemoStore(); const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount;
return getAllTrashTasks(store.tasks).length;
}
return 0;
});
// Context Menu for Lists // Context Menu for Lists
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
@@ -91,45 +90,56 @@ export function Sidebar({
const [renamingListId, setRenamingListId] = useState<string | null>(null); const [renamingListId, setRenamingListId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
// Search expansion
const [searchExpanded, setSearchExpanded] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const initialListSelectedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (isDemo) {
setTags(getCustomTags()); setTags(getCustomTags());
const store = getDemoStore(); const store = getDemoStore();
const trashed = getAllTrashTasks(store.tasks); const trashed = getAllTrashTasks(store.tasks);
setTrashCount(trashed.length); setInternalTrashCount(trashed.length);
}, [lists]); } else {
// Load lists
useEffect(() => {
if (!isDemo) {
fetch("/api/lists") fetch("/api/lists")
.then((r) => (r.ok ? r.json() : [])) .then((r) => (r.ok ? r.json() : []))
.then((data) => { .then((data) => {
if (Array.isArray(data)) { if (Array.isArray(data)) {
setLists(data); setLists(data);
if (!selectedListId && data.length > 0) onListSelect(data[0].id); if (!initialListSelectedRef.current && !selectedListId && !isTrashActive && !selectedTag && data.length > 0) {
initialListSelectedRef.current = true;
onListSelect(data[0].id);
}
} }
}) })
.catch((err) => console.error("Failed to load lists", err)); .catch((err) => console.error("Failed to load lists", err));
// 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, setLists]); }, [isDemo, onListSelect, selectedListId, isTrashActive, selectedTag, setLists, lists.length]);
useEffect(() => { useEffect(() => {
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50); if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
}, [showNewList]); }, [showNewList]);
useEffect(() => {
if (searchExpanded) {
setTimeout(() => searchInputRef.current?.focus(), 60);
}
}, [searchExpanded]);
// Dismiss undo toast on unmount // Dismiss undo toast on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -422,91 +432,41 @@ export function Sidebar({
return ( return (
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}> <aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
{/* Header with Logo & Controls */} {/* Brand Header with Logo & Controls */}
<div className="sidebar-header" style={{ padding: "12px 12px 8px", gap: 6 }}> <div className="sidebar-header" style={{ padding: "14px 14px 10px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<div className="sidebar-logo"></div> <div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}> <div
<span className="sidebar-title" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}> 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")} {t("appName")}
</span> </span>
{isDemo && ( {isDemo && (
<span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}> <span style={{ fontSize: 9.5, color: "var(--accent)", fontWeight: 700, letterSpacing: "0.4px", marginTop: -2 }}>
{t("demoBadge")} {t("demoBadge")}
</span> </span>
)} )}
</div> </div>
{/* Top Controls: Hover-expand Search, Language & Theme */}
<div style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>
{/* Hover-expand Search */}
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
transition: "all var(--dur-normal) var(--ease-out)",
}}
onMouseEnter={() => setSearchExpanded(true)}
onMouseLeave={() => {
if (!searchQuery) setSearchExpanded(false);
}}
>
{searchExpanded ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "var(--bg-primary)",
border: "1.5px solid var(--accent)",
borderRadius: "var(--radius-sm)",
padding: "3px 8px",
animation: "expandSearch var(--dur-normal) var(--ease-out)",
width: 180,
boxShadow: "0 0 0 3px var(--accent-light)",
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "Escape") {
document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"));
setSearchExpanded(false);
setSearchQuery("");
}
}}
placeholder={t("searchPlaceholder")}
style={{
background: "transparent",
border: "none",
outline: "none",
fontSize: 12,
color: "var(--text-primary)",
width: "100%",
}}
/>
</div>
) : (
<button
className="icon-btn"
id="quick-search-btn"
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
title={t("searchPlaceholder")}
style={{ width: 28, height: 28, flexShrink: 0 }}
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
)}
</div> </div>
{/* Top Controls: Language & Theme with Clear Spacing */}
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
<LanguageSelector /> <LanguageSelector />
{/* Theme 3-Way Toggle Button */} {/* Theme 3-Way Toggle Button */}
@@ -515,7 +475,7 @@ export function Sidebar({
id="theme-toggle" id="theme-toggle"
onClick={toggleTheme} onClick={toggleTheme}
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`} title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
style={{ width: 28, height: 28, flexShrink: 0 }} style={{ width: 28, height: 28, flexShrink: 0, borderRadius: "var(--radius-sm)" }}
type="button" type="button"
> >
{theme === "system" ? ( {theme === "system" ? (
@@ -539,6 +499,51 @@ export function Sidebar({
</div> </div>
</div> </div>
{/* Centered Minimal Search Bar (Between Header and Lists) */}
<div style={{ padding: "4px 12px 10px" }}>
<div
className="sidebar-search-box"
id="quick-search-btn"
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 10px",
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-md)",
cursor: "pointer",
transition: "all var(--dur-fast)",
userSelect: "none",
}}
title={`${t("searchPlaceholder")} (Ctrl+K)`}
>
<div style={{ display: "flex", alignItems: "center", gap: 7, minWidth: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.2">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span style={{ fontSize: 12.5, color: "var(--text-tertiary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{t("searchPlaceholder")}
</span>
</div>
<kbd
style={{
fontSize: 10,
fontWeight: 600,
background: "var(--bg-primary)",
border: "1px solid var(--border)",
color: "var(--text-tertiary)",
padding: "1px 5px",
borderRadius: "var(--radius-xs)",
lineHeight: "14px",
}}
>
Ctrl K
</kbd>
</div>
</div>
{/* Nav List */} {/* Nav List */}
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{/* Lists Section */} {/* Lists Section */}
@@ -580,7 +585,16 @@ export function Sidebar({
<span className="item-label">{list.name}</span> <span className="item-label">{list.name}</span>
)} )}
<span className="item-count">{list._count?.tasks || ""}</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>
<button <button
className="icon-btn" className="icon-btn"
style={{ width: 20, height: 20, opacity: 0, transition: "opacity var(--dur-fast)", flexShrink: 0 }} style={{ width: 20, height: 20, opacity: 0, transition: "opacity var(--dur-fast)", flexShrink: 0 }}
@@ -605,7 +619,11 @@ export function Sidebar({
{tags.length > 0 && ( {tags.length > 0 && (
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-section-label">🏷 {t("tags") || "Tags"}</div> <div className="sidebar-section-label">🏷 {t("tags") || "Tags"}</div>
{tags.map((tag) => ( {tags.map((tag) => {
const tagCount = isDemo && typeof window !== "undefined"
? getTagTaskCount(getDemoStore().tasks, tag.name)
: ((tag as APITag)._count?.tasks ?? 0);
return (
<div <div
key={tag.id} key={tag.id}
className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`} className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`}
@@ -613,8 +631,10 @@ export function Sidebar({
> >
<span style={{ color: tag.color, fontSize: 13, fontWeight: 700 }}>#</span> <span style={{ color: tag.color, fontSize: 13, fontWeight: 700 }}>#</span>
<span className="item-label">{tag.name}</span> <span className="item-label">{tag.name}</span>
{tagCount > 0 && <span className="item-count">{tagCount}</span>}
</div> </div>
))} );
})}
</div> </div>
)} )}
@@ -623,14 +643,19 @@ export function Sidebar({
<div <div
className={`sidebar-item${isTrashActive ? " active" : ""}`} className={`sidebar-item${isTrashActive ? " active" : ""}`}
id="trash-menu-btn" id="trash-menu-btn"
onClick={onTrashSelect} onClick={() => {
onTagSelect(null);
onTrashSelect();
}}
style={{ color: isTrashActive ? "var(--danger)" : "var(--text-secondary)" }} 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 }}> <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" /> <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> </svg>
<span className="item-label">{t("trash") || "Trash"}</span> <span className="item-label">{t("trash") || "Trash"}</span>
{trashCount > 0 && <span className="item-count" style={{ color: "var(--danger)" }}>{trashCount}</span>} {trashCount > 0 ? (
<span className="item-count" style={{ color: "var(--danger)", fontWeight: 700 }}>{trashCount}</span>
) : null}
</div> </div>
</div> </div>
+102 -85
View File
@@ -131,8 +131,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs(); const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile"); const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile");
const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "Demo User")); const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "User"));
const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "demo@checkflow.local")); const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "user@checkflow.local"));
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [trashRetention, setTrashRetention] = useState(() => (typeof window !== "undefined" ? (getUserSettings().trashRetentionDays ?? 30) : 30)); const [trashRetention, setTrashRetention] = useState(() => (typeof window !== "undefined" ? (getUserSettings().trashRetentionDays ?? 30) : 30));
const [savedMsg, setSavedMsg] = useState(""); const [savedMsg, setSavedMsg] = useState("");
@@ -143,8 +143,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
const s = getUserSettings(); const s = getUserSettings();
setDisplayName(user.name || s.displayName); setDisplayName(user.name || s.displayName || "User");
setEmail(user.email || s.email); setEmail(user.email || s.email || "user@checkflow.local");
setTrashRetention(s.trashRetentionDays ?? 30); setTrashRetention(s.trashRetentionDays ?? 30);
} }
}, [isOpen, user]); }, [isOpen, user]);
@@ -177,7 +177,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
language: lang, language: lang,
}; };
saveUserSettings(newSettings); saveUserSettings(newSettings);
setSavedMsg("✓ " + (lang === "ko" ? "설정이 저장되었습니다" : lang === "ja" ? "設定が保存されました" : "Settings saved")); setSavedMsg("✓ " + t("settingsSaved"));
setTimeout(() => { setTimeout(() => {
setSavedMsg(""); setSavedMsg("");
onClose(); onClose();
@@ -192,11 +192,11 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
const accentPreview = `hsl(${hue}, ${sat}%, 54%)`; const accentPreview = `hsl(${hue}, ${sat}%, 54%)`;
const tabs = [ const tabs = [
{ id: "profile", label: `👤 ${t("profile") || "Profile"}` }, { id: "profile", label: `👤 ${t("profile")}` },
{ id: "preferences", label: `⚙️ ${t("preferences") || "Preferences"}` }, { id: "preferences", label: `⚙️ ${t("preferences")}` },
{ id: "labs", label: `🧪 ${t("labs") || "Labs"}` }, { id: "labs", label: `🧪 ${t("labs")}` },
{ id: "sync", label: `📱 ${t("syncIntegrations") || "Integrations"}` }, { id: "sync", label: `📱 ${t("syncIntegrations")}` },
{ id: "admin", label: `👑 ${t("admin") || "Admin"}` }, { id: "admin", label: `👑 ${t("admin")}` },
] as const; ] as const;
return ( return (
@@ -208,7 +208,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
> >
{/* Header */} {/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<h2 className="modal-title" style={{ marginBottom: 0, fontSize: 17 }}> {t("settingsModalTitle") || "Settings"}</h2> <h2 className="modal-title" style={{ marginBottom: 0, fontSize: 17 }}> {t("settingsModalTitle")}</h2>
<button className="icon-btn" onClick={onClose} aria-label="Close" type="button"></button> <button className="icon-btn" onClick={onClose} aria-label="Close" type="button"></button>
</div> </div>
@@ -255,15 +255,32 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<div className="settings-tab-content"> <div className="settings-tab-content">
<div className="form-group"> <div className="form-group">
<label className="form-label">{t("displayName")}</label> <label className="form-label">{t("displayName")}</label>
<input className="form-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your Name" /> <input
className="form-input"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t("yourNamePlaceholder")}
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label">{t("email")}</label> <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>
<div className="form-group"> <div className="form-group">
<label className="form-label">{t("password")} (Change)</label> <label className="form-label">{t("password")}</label>
<input className="form-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="New password (leave blank to keep current)" /> <input
className="form-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordChangePlaceholder")}
/>
</div> </div>
</div> </div>
)} )}
@@ -273,16 +290,16 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<div className="settings-tab-content"> <div className="settings-tab-content">
<div className="form-group"> <div className="form-group">
<label className="form-label" style={{ fontWeight: 600 }}> <label className="form-label" style={{ fontWeight: 600 }}>
🗑 {t("trashRetention") || "Trash Auto-Delete Retention Period"} 🗑 {t("trashRetention")}
</label> </label>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 8 }}> <p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 8 }}>
{t("trashRetentionHint") || "Deleted tasks will be permanently removed after the specified period."} {t("trashRetentionHint")}
</p> </p>
<select className="form-input" value={trashRetention} onChange={(e) => setTrashRetention(parseInt(e.target.value, 10))}> <select className="form-input" value={trashRetention} onChange={(e) => setTrashRetention(parseInt(e.target.value, 10))}>
<option value={7}>{t("days7") || "7 Days"}</option> <option value={7}>{t("days7")}</option>
<option value={14}>{t("days14") || "14 Days"}</option> <option value={14}>{t("days14")}</option>
<option value={30}>{t("days30") || "30 Days (Recommended)"}</option> <option value={30}>{t("days30")}</option>
<option value={0}>{t("neverDelete") || "Never Auto-Delete (Manual empty only)"}</option> <option value={0}>{t("neverDelete")}</option>
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group">
@@ -320,46 +337,46 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<div className="settings-tab-content"> <div className="settings-tab-content">
{/* Section: View */} {/* Section: View */}
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}> <div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}>
View {t("labsSectionView")}
</div> </div>
<LabsRow <LabsRow
label="📊 Kanban Board" label={`📊 ${t("labsKanbanTitle")}`}
desc="Switch between list view and 3-column Kanban board (To Do / In Progress / Done)." desc={t("labsKanbanDesc")}
> >
<SegmentedControl <SegmentedControl
value={prefs.viewMode} value={prefs.labs?.kanbanBoard ? "enabled" : "disabled"}
options={[ options={[
{ label: "List", value: "list" }, { label: t("animationOff"), value: "disabled" },
{ label: "Kanban", value: "kanban" }, { label: "ON", value: "enabled" },
]} ]}
onChange={(v) => updatePrefs({ viewMode: v })} onChange={(v) => updatePrefs({ labs: { kanbanBoard: v === "enabled" } })}
/> />
</LabsRow> </LabsRow>
{/* Section: Layout */} {/* Section: Layout */}
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}> <div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
Layout {t("labsSectionLayout")}
</div> </div>
<LabsRow <LabsRow
label="📐 Content Density" label={`📐 ${t("labsDensityTitle")}`}
desc="Controls the vertical spacing of task items." desc={t("labsDensityDesc")}
> >
<SegmentedControl <SegmentedControl
value={prefs.density} value={prefs.density}
options={[ options={[
{ label: "Compact", value: "compact" }, { label: t("densityCompact"), value: "compact" },
{ label: "Default", value: "default" }, { label: t("densityDefault"), value: "default" },
{ label: "Airy", value: "comfortable" }, { label: t("densityComfortable"), value: "comfortable" },
]} ]}
onChange={(v) => updatePrefs({ density: v })} onChange={(v) => updatePrefs({ density: v })}
/> />
</LabsRow> </LabsRow>
<LabsRow <LabsRow
label="↔️ Sidebar Width" label={`↔️ ${t("sidebarWidthTitle")}`}
desc={`Drag the sidebar edge or adjust here. (${prefs.sidebarWidth}px)`} desc={`${t("sidebarWidthDesc")} (${prefs.sidebarWidth}px)`}
> >
<PrefSlider <PrefSlider
min={160} min={160}
@@ -371,8 +388,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
</LabsRow> </LabsRow>
<LabsRow <LabsRow
label="↔️ Detail Panel Width" label={`↔️ ${t("detailWidthTitle")}`}
desc={`Drag the panel edge or adjust here. (${prefs.detailWidth}px)`} desc={`${t("detailWidthDesc")} (${prefs.detailWidth}px)`}
> >
<PrefSlider <PrefSlider
min={300} min={300}
@@ -385,27 +402,27 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
{/* Section: Appearance */} {/* Section: Appearance */}
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}> <div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
Appearance {t("labsSectionAppearance")}
</div> </div>
<LabsRow <LabsRow
label="🔤 Font Size" label={`🔤 ${t("fontSizeTitle")}`}
desc="Base font size across the app." desc={t("fontSizeDesc")}
> >
<SegmentedControl <SegmentedControl
value={prefs.fontSize} value={prefs.fontSize}
options={[ options={[
{ label: "S", value: "small" }, { label: t("fontSizeSmall"), value: "small" },
{ label: "M", value: "default" }, { label: t("fontSizeMedium"), value: "default" },
{ label: "L", value: "large" }, { label: t("fontSizeLarge"), value: "large" },
]} ]}
onChange={(v) => updatePrefs({ fontSize: v })} onChange={(v) => updatePrefs({ fontSize: v })}
/> />
</LabsRow> </LabsRow>
<LabsRow <LabsRow
label="🎨 Accent Color" label={`🎨 ${t("accentColorTitle")}`}
desc="Choose the hue of your accent color. Saturation controls vibrancy." desc={t("accentColorDesc")}
> >
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}> <div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
{/* Hue ring preview */} {/* Hue ring preview */}
@@ -431,7 +448,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
))} ))}
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Saturation</span> <span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("saturationLabel")}</span>
<PrefSlider <PrefSlider
min={20} min={20}
max={100} max={100}
@@ -442,7 +459,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
</div> </div>
{/* Custom hue input */} {/* Custom hue input */}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Hue °</span> <span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("hueLabel")}</span>
<input <input
type="range" type="range"
min={0} min={0}
@@ -457,31 +474,31 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
</LabsRow> </LabsRow>
<LabsRow <LabsRow
label="⬛ Border Roundness" label={`${t("roundnessTitle")}`}
desc="Controls the roundness of cards, buttons, and UI elements." desc={t("roundnessDesc")}
> >
<SegmentedControl <SegmentedControl
value={prefs.roundness} value={prefs.roundness}
options={[ options={[
{ label: "Sharp", value: "sharp" }, { label: t("roundnessSharp"), value: "sharp" },
{ label: "Default", value: "default" }, { label: t("roundnessDefault"), value: "default" },
{ label: "Round", value: "round" }, { label: t("roundnessRound"), value: "round" },
]} ]}
onChange={(v) => updatePrefs({ roundness: v })} onChange={(v) => updatePrefs({ roundness: v })}
/> />
</LabsRow> </LabsRow>
<LabsRow <LabsRow
label="⚡ Animation Speed" label={`${t("animationSpeedTitle")}`}
desc="Controls the speed of transitions and hover effects." desc={t("animationSpeedDesc")}
> >
<SegmentedControl <SegmentedControl
value={prefs.animationSpeed} value={prefs.animationSpeed}
options={[ options={[
{ label: "Off", value: "none" }, { label: t("animationOff"), value: "none" },
{ label: "Fast", value: "fast" }, { label: t("animationFast"), value: "fast" },
{ label: "Default", value: "default" }, { label: t("animationDefault"), value: "default" },
{ label: "Slow", value: "slow" }, { label: t("animationSlow"), value: "slow" },
]} ]}
onChange={(v) => updatePrefs({ animationSpeed: v })} onChange={(v) => updatePrefs({ animationSpeed: v })}
/> />
@@ -490,20 +507,20 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
{/* Reset to Defaults */} {/* Reset to Defaults */}
<div style={{ marginTop: 20, borderTop: "1px solid var(--border)", paddingTop: 16 }}> <div style={{ marginTop: 20, borderTop: "1px solid var(--border)", paddingTop: 16 }}>
<LabsRow <LabsRow
label="🔄 Reset to Defaults" label={`🔄 ${t("resetDefaultsTitle")}`}
desc="Restores all layout, appearance, and view settings to their factory defaults." desc={t("resetDefaultsDesc")}
> >
<button <button
type="button" type="button"
className="btn btn-sm" className="btn btn-sm"
style={{ color: "var(--danger)", border: "1px solid var(--danger)", background: "transparent" }} style={{ color: "var(--danger)", border: "1px solid var(--danger)", background: "transparent" }}
onClick={() => { onClick={() => {
if (confirm(t("resetConfirm") || "Reset all customizations to defaults?")) { if (confirm(t("resetConfirm"))) {
resetToDefaults(); resetToDefaults();
} }
}} }}
> >
{t("resetDefaults") || "Reset"} {t("resetDefaults")}
</button> </button>
</LabsRow> </LabsRow>
</div> </div>
@@ -514,15 +531,15 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
{activeTab === "sync" && ( {activeTab === "sync" && (
<div className="settings-tab-content"> <div className="settings-tab-content">
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}> <h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>
📱 {t("syncTitle") || "CalDAV & Mobile Sync (Android / Apple)"} 📱 {t("syncTitle")}
</h3> </h3>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 14 }}> <p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 14 }}>
{t("syncDesc") || "CheckFlow supports native two-way synchronization with Android, Apple Reminders, and Thunderbird via standard CalDAV."} {t("syncDesc")}
</p> </p>
{/* Endpoint bar & Action buttons */} {/* Endpoint bar & Action buttons */}
<div className="form-group" style={{ marginBottom: 14 }}> <div className="form-group" style={{ marginBottom: 14 }}>
<label className="form-label" style={{ fontWeight: 600 }}>{t("syncBaseUrl") || "CalDAV Server Base URL"}</label> <label className="form-label" style={{ fontWeight: 600 }}>{t("syncBaseUrl")}</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}> <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input <input
className="form-input" className="form-input"
@@ -541,7 +558,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
}} }}
style={{ minWidth: 80, fontWeight: 600 }} style={{ minWidth: 80, fontWeight: 600 }}
> >
{copiedCalDav ? `${t("syncCopied") || "Copied!"}` : `📋 ${t("syncCopyUrl") || "Copy"}`} {copiedCalDav ? `${t("syncCopied")}` : `📋 ${t("syncCopyUrl")}`}
</button> </button>
</div> </div>
</div> </div>
@@ -555,7 +572,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
disabled={testSyncStatus === "testing"} disabled={testSyncStatus === "testing"}
style={{ display: "inline-flex", alignItems: "center", gap: 6 }} style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
> >
{testSyncStatus === "testing" ? `${t("syncTesting") || "Testing..."}` : `🔍 ${t("syncTestConnection") || "Test Endpoint"}`} {testSyncStatus === "testing" ? `${t("syncTesting")}` : `🔍 ${t("syncTestConnection")}`}
</button> </button>
<a <a
href="/api/dav" href="/api/dav"
@@ -564,16 +581,16 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
className="btn btn-sm btn-ghost" className="btn btn-sm btn-ghost"
style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none" }} style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none" }}
> >
📥 {t("syncDownloadIcs") || "Download .ICS Feed"} 📥 {t("syncDownloadIcs")}
</a> </a>
{testSyncStatus === "success" && ( {testSyncStatus === "success" && (
<span style={{ fontSize: 12, color: "var(--success)", fontWeight: 600 }}> <span style={{ fontSize: 12, color: "var(--success)", fontWeight: 600 }}>
{t("syncTestSuccess") || "✓ CalDAV endpoint responded successfully"} {t("syncTestSuccess")}
</span> </span>
)} )}
{testSyncStatus === "error" && ( {testSyncStatus === "error" && (
<span style={{ fontSize: 12, color: "var(--danger)", fontWeight: 600 }}> <span style={{ fontSize: 12, color: "var(--danger)", fontWeight: 600 }}>
Endpoint test failed {t("syncTestError")}
</span> </span>
)} )}
</div> </div>
@@ -583,9 +600,9 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<SegmentedControl <SegmentedControl
value={syncPlatform} value={syncPlatform}
options={[ options={[
{ label: `🤖 ${t("syncTabAndroid") || "Android (DAVx⁵)"}`, value: "android" }, { label: `🤖 ${t("syncTabAndroid")}`, value: "android" },
{ label: `🍎 ${t("syncTabApple") || "Apple Reminders"}`, value: "apple" }, { label: `🍎 ${t("syncTabApple")}`, value: "apple" },
{ label: `🦅 ${t("syncTabThunderbird") || "Thunderbird"}`, value: "thunderbird" }, { label: `🦅 ${t("syncTabThunderbird")}`, value: "thunderbird" },
]} ]}
onChange={(v) => setSyncPlatform(v)} onChange={(v) => setSyncPlatform(v)}
/> />
@@ -615,7 +632,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<li>{t("syncAndroidStep4")}</li> <li>{t("syncAndroidStep4")}</li>
</ol> </ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}> <div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> In DAVx account settings, set Sync Interval to <strong>15 minutes</strong> for battery efficiency and near real-time sync. 💡 <strong>Tip:</strong> {t("syncAndroidTip")}
</div> </div>
</div> </div>
)} )}
@@ -632,7 +649,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<li>{t("syncAppleStep4")}</li> <li>{t("syncAppleStep4")}</li>
</ol> </ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}> <div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> If using HTTPS behind a reverse proxy (Nginx/Caddy), ensure valid SSL certificates are trusted by Apple devices. 💡 <strong>Tip:</strong> {t("syncAppleTip")}
</div> </div>
</div> </div>
)} )}
@@ -649,7 +666,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<li>{t("syncThunderbirdStep4")}</li> <li>{t("syncThunderbirdStep4")}</li>
</ol> </ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}> <div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> Thunderbird Tasks view will display CheckFlow priority tags, due dates, and completion status. 💡 <strong>Tip:</strong> {t("syncThunderbirdTip")}
</div> </div>
</div> </div>
)} )}
@@ -664,7 +681,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<span style={{ fontSize: 24 }}>👑</span> <span style={{ fontSize: 24 }}>👑</span>
<div> <div>
<h3 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}> <h3 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>
{t("admin") || "Admin Console"} {t("admin")}
</h3> </h3>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}> <p style={{ fontSize: 12, color: "var(--text-tertiary)", margin: "2px 0 0" }}>
Multi-user administration, account control, and system telemetry Multi-user administration, account control, and system telemetry
@@ -685,7 +702,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
> >
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10, flexWrap: "wrap", gap: 8 }}> <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
<div> <div>
<strong style={{ color: "var(--text-primary)" }}>Current Account:</strong>{" "} <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)" }}> <code style={{ background: "var(--bg-primary)", padding: "2px 6px", borderRadius: 4, border: "1px solid var(--border)" }}>
{user.email || "demo@checkflow.local"} {user.email || "demo@checkflow.local"}
</code> </code>
@@ -709,12 +726,12 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginTop: 12 }}> <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={{ background: "var(--bg-primary)", padding: "10px 12px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border)" }}>
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Isolation Mode</div> <div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("adminIsolationMode")}</div>
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--success)" }}> Multi-User Privacy</div> <div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--success)" }}> {t("adminMultiUserPrivacy")}</div>
</div> </div>
<div style={{ background: "var(--bg-primary)", padding: "10px 12px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border)" }}> <div style={{ background: "var(--bg-primary)", padding: "10px 12px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border)" }}>
<div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>CalDAV Endpoint</div> <div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{t("adminCalDavEndpoint")}</div>
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--accent)" }}> Active / Enabled</div> <div style={{ fontSize: 13, fontWeight: 600, marginTop: 2, color: "var(--accent)" }}> {t("adminActiveEnabled")}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -725,7 +742,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
className="btn btn-primary" className="btn btn-primary"
style={{ display: "inline-flex", alignItems: "center", gap: 8, textDecoration: "none" }} style={{ display: "inline-flex", alignItems: "center", gap: 8, textDecoration: "none" }}
> >
🚀 Open Admin Dashboard 🚀 {t("adminOpenDashboard")}
</a> </a>
</div> </div>
</div> </div>
@@ -741,7 +758,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}> <div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
<button className="btn btn-ghost" onClick={onClose} type="button">{t("cancel")}</button> <button className="btn btn-ghost" onClick={onClose} type="button">{t("cancel")}</button>
<button className="btn btn-primary" onClick={handleSave} type="button"> <button className="btn btn-primary" onClick={handleSave} type="button">
{t("save") || "Save Changes"} {t("save")}
</button> </button>
</div> </div>
</div> </div>
+21
View File
@@ -101,6 +101,27 @@ export function KanbanView({
</span> </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 && ( {subtaskCount > 0 && (
<span <span
style={{ style={{
+47 -19
View File
@@ -12,10 +12,16 @@ interface MarkdownNoteEditorProps {
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) { export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
const { t } = useI18n(); const { t } = useI18n();
const [mode, setMode] = useState<"edit" | "preview">("edit"); // Default to preview mode as requested
const [mode, setMode] = useState<"edit" | "preview">("preview");
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
// Reset to preview whenever value or active task changes
useEffect(() => {
// Keep preview mode as primary
}, []);
// Configure marked to open links in new tabs safely // Configure marked to open links in new tabs safely
useEffect(() => { useEffect(() => {
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
@@ -48,7 +54,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
// Convert markdown to sanitized HTML with safe links // Convert markdown to sanitized HTML with safe links
const renderMarkdownHtml = () => { const renderMarkdownHtml = () => {
if (!value || !value.trim()) { if (!value || !value.trim()) {
return `<p style="color: var(--text-tertiary); font-style: italic; padding: 8px 0;">${t("notesPlaceholder").split("\n")[0]}</p>`; return `<div style="color: var(--text-tertiary); font-style: italic; user-select: none; line-height: 1.6;">${t("notesPlaceholder")}</div>`;
} }
try { try {
@@ -95,37 +101,57 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
padding: "4px 8px 8px 8px", padding: "4px 8px 8px 8px",
flexShrink: 0,
}} }}
> >
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}> <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}>
{t("notes").toUpperCase()} {t("notes").toUpperCase()}
</span> </span>
<div className="view-switcher-group" style={{ padding: 2 }}> {/* Minimal Matte One-touch Mode Toggle */}
<button <button
type="button" type="button"
className={`view-switcher-btn${mode === "edit" ? " active" : ""}`} className="btn btn-ghost btn-sm"
style={{ fontSize: 11, padding: "2px 8px" }} style={{
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={() => { onClick={() => {
if (mode === "preview") {
setMode("edit"); setMode("edit");
setTimeout(() => textareaRef.current?.focus(), 50); setTimeout(() => textareaRef.current?.focus(), 50);
} else {
setMode("preview");
}
}} }}
title={mode === "preview" ? "Switch to Edit" : "Switch to Preview"}
> >
Edit {mode === "preview" ? "✏️ Edit" : "👁️ Preview"}
</button> </button>
<button
type="button"
className={`view-switcher-btn${mode === "preview" ? " active" : ""}`}
style={{ fontSize: 11, padding: "2px 8px" }}
onClick={() => setMode("preview")}
>
👁 Preview
</button>
</div>
</div> </div>
{/* Editor / Preview Area */} {/* Editor / Preview Area */}
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", position: "relative" }}> <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",
}}
>
{mode === "edit" ? ( {mode === "edit" ? (
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -134,8 +160,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
flex: 1, flex: 1,
width: "100%", width: "100%",
height: "100%", height: "100%",
minHeight: 120, padding: "10px 12px",
padding: "8px 10px",
background: "transparent", background: "transparent",
border: "none", border: "none",
outline: "none", outline: "none",
@@ -144,6 +169,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
lineHeight: 1.6, lineHeight: 1.6,
resize: "none", resize: "none",
fontFamily: "inherit", fontFamily: "inherit",
boxSizing: "border-box",
}} }}
placeholder={t("notesPlaceholder")} placeholder={t("notesPlaceholder")}
value={value} value={value}
@@ -161,11 +187,13 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
flex: 1, flex: 1,
width: "100%", width: "100%",
height: "100%", height: "100%",
padding: "8px 10px", padding: "10px 12px",
overflowY: "auto", overflowY: "auto",
color: "var(--text-primary)", color: "var(--text-primary)",
fontSize: 13.5, fontSize: 13.5,
lineHeight: 1.6, lineHeight: 1.6,
boxSizing: "border-box",
cursor: "text",
}} }}
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }} dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
onClick={() => { onClick={() => {
+100 -23
View File
@@ -40,11 +40,30 @@ export function TaskDetail({
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : ""); const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
const [priority, setPriority] = useState(task.priority); const [priority, setPriority] = useState(task.priority);
const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []); const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []);
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : [])); const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (isDemo && typeof window !== "undefined" ? getCustomTags() : []));
const [showTagPicker, setShowTagPicker] = useState(false); const [showTagPicker, setShowTagPicker] = useState(false);
const [showListPicker, setShowListPicker] = useState(false); const [showListPicker, setShowListPicker] = useState(false);
const [showPriorityPicker, setShowPriorityPicker] = useState(false);
const [newTagName, setNewTagName] = useState(""); 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 // Modular Blocks Customization via global prefs
const { prefs, updatePrefs } = useUserPrefs(); const { prefs, updatePrefs } = useUserPrefs();
const blockOrder = prefs.detailBlockOrder; const blockOrder = prefs.detailBlockOrder;
@@ -160,8 +179,8 @@ export function TaskDetail({
const updatedTask: Task = { const updatedTask: Task = {
...task, ...task,
...data, ...data,
children: subtasks, children: (data.children as Task[]) || subtasks,
tags, tags: (data.tags as { tag: Tag }[]) || tags,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} as Task; } as Task;
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask); if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
@@ -243,17 +262,43 @@ export function TaskDetail({
save(task.id, { tags: nextTags }); save(task.id, { tags: nextTags });
}; };
const addCustomTag = () => { const addCustomTag = async () => {
const trimmed = newTagName.trim().replace(/^#/, ""); const trimmed = newTagName.trim().replace(/^#/, "");
if (!trimmed) return; if (!trimmed) return;
const randomColor = ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)];
if (isDemo) {
const newTag: MockTag = { const newTag: MockTag = {
id: "tag-" + Date.now(), id: "tag-" + Date.now(),
name: trimmed, name: trimmed,
color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)], color: randomColor,
}; };
setAllAvailableTags((p) => [...p, newTag]); const updatedAvailable = [...allAvailableTags, newTag];
setAllAvailableTags(updatedAvailable);
if (typeof window !== "undefined") {
const { saveCustomTags } = require("@/lib/mockData");
saveCustomTags(updatedAvailable);
}
toggleTag(newTag); toggleTag(newTag);
setNewTagName(""); 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 addSubtask = useCallback(async () => { const addSubtask = useCallback(async () => {
@@ -701,31 +746,63 @@ export function TaskDetail({
}} }}
/> />
{/* Priority selector */} {/* Priority custom popover chip */}
<select <div style={{ position: "relative" }}>
className="tick-meta-chip" <button
type="button"
className={`tick-meta-chip${priority > 0 ? " active" : ""}`}
onClick={() => setShowPriorityPicker((p) => !p)}
style={{ style={{
fontSize: 11.5, fontSize: 11.5,
padding: "3px 8px", padding: "3px 8px",
cursor: "pointer", borderRadius: "var(--radius-sm)",
border: "1px solid var(--border)", border: "1px solid var(--border)",
color: priority > 0 ? priorityMap[priority].color : "inherit", color: priority > 0 ? priorityMap[priority].color : "var(--text-tertiary)",
fontWeight: priority > 0 ? 700 : 500, borderColor: priority > 0 ? priorityMap[priority].color : "var(--border)",
}} display: "inline-flex",
value={priority} alignItems: "center",
onChange={(e) => { gap: 5,
const val = parseInt(e.target.value, 10); cursor: "pointer",
setPriority(val);
save(task.id, { priority: val });
}} }}
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) => ( {priorityMap.map((p, idx) => (
<option key={idx} value={idx}> <div
{p.icon ? `${p.icon} ` : ""} key={idx}
{p.label} className="context-menu-item"
</option> 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>
))} ))}
</select> </div>
)}
</div>
{/* Tag Selector */} {/* Tag Selector */}
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
+335 -57
View File
@@ -4,6 +4,7 @@ import { useI18n } from "@/lib/i18n";
import { ContextMenu } from "@/components/ui/ContextMenu"; import { ContextMenu } from "@/components/ui/ContextMenu";
import { KanbanView } from "./KanbanView"; import { KanbanView } from "./KanbanView";
import { useUserPrefs } from "@/lib/useUserPrefs"; import { useUserPrefs } from "@/lib/useUserPrefs";
import { getDemoStore, saveDemoStore, MockTask } from "@/lib/mockData";
export interface Tag { id: string; name: string; color: string } export interface Tag { id: string; name: string; color: string }
@@ -43,7 +44,7 @@ interface TaskItemProps {
isTrashMode?: boolean; isTrashMode?: boolean;
onRestore?: (id: string) => void; onRestore?: (id: string) => void;
onPermanentDelete?: (id: string) => void; onPermanentDelete?: (id: string) => void;
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after") => void; onDragTask?: (draggedId: string, targetId: string, position: "before" | "after" | "inside" | "root") => void;
} }
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly) // Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
@@ -156,12 +157,12 @@ function RecursiveTaskItem({
touchStartX.current = null; touchStartX.current = null;
}; };
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | null>(null); const [isDragOver, setIsDragOver] = useState(false);
return ( return (
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}> <div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
<div <div
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}${dragOverPos ? ` drag-over-${dragOverPos}` : ""}`} className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}${isDragOver ? " drag-over-nested" : ""}`}
onClick={() => onSelect(task)} onClick={() => onSelect(task)}
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -182,20 +183,25 @@ function RecursiveTaskItem({
onDragOver={(e) => { onDragOver={(e) => {
if (isTrashMode) return; if (isTrashMode) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move"; e.dataTransfer.dropEffect = "move";
const rect = e.currentTarget.getBoundingClientRect(); if (!isDragOver) setIsDragOver(true);
const mid = rect.top + rect.height / 2; }}
setDragOverPos(e.clientY < mid ? "top" : "bottom"); onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}} }}
onDragLeave={() => setDragOverPos(null)}
onDrop={(e) => { onDrop={(e) => {
if (isTrashMode) return; if (isTrashMode) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const draggedId = e.dataTransfer.getData("text/plain"); const draggedId = e.dataTransfer.getData("text/plain");
if (draggedId && draggedId !== task.id && onDragTask) { if (draggedId && draggedId !== task.id && onDragTask) {
onDragTask(draggedId, task.id, dragOverPos === "top" ? "before" : "after"); // Unified interaction: drop on task to nest as subtask
onDragTask(draggedId, task.id, "inside");
} }
setDragOverPos(null);
}} }}
id={`task-${task.id}`} id={`task-${task.id}`}
style={{ style={{
@@ -203,16 +209,20 @@ function RecursiveTaskItem({
marginLeft: depth > 0 ? 8 : 0, marginLeft: depth > 0 ? 8 : 0,
transform: `translateX(${swipeOffset}px)`, transform: `translateX(${swipeOffset}px)`,
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none", transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
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 */} {/* Notion-style 6-dot Drag Handle */}
{!isTrashMode && ( {!isTrashMode && (
<div <div
className="task-drag-handle" className="task-drag-handle"
draggable draggable="true"
onDragStart={(e) => { onDragStart={(e) => {
e.stopPropagation(); e.stopPropagation();
e.dataTransfer.setData("text/plain", task.id); e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.setData("application/task-id", task.id);
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
}} }}
title="Drag to reorder" title="Drag to reorder"
@@ -293,7 +303,8 @@ function RecursiveTaskItem({
style={{ padding: "2px 6px", fontSize: depth > 0 ? 13 : 14, fontWeight: 500, height: 26, width: "100%" }} style={{ padding: "2px 6px", fontSize: depth > 0 ? 13 : 14, fontWeight: 500, height: 26, width: "100%" }}
/> />
) : ( ) : (
<div <div style={{ display: "inline-flex", alignItems: "center", maxWidth: "100%" }}>
<span
className="task-title" className="task-title"
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (isTrashMode) return; if (isTrashMode) return;
@@ -301,9 +312,15 @@ function RecursiveTaskItem({
setEditingTitle(true); setEditingTitle(true);
}} }}
title="Double click or press F2 to edit" title="Double click or press F2 to edit"
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }} style={{
fontSize: depth > 0 ? 13 : 14,
fontWeight: depth === 0 ? 600 : 500,
cursor: "text",
display: "inline-block",
}}
> >
{task.title} {task.title}
</span>
</div> </div>
)} )}
@@ -543,11 +560,12 @@ export function TaskList({
const { prefs, updatePrefs } = useUserPrefs(); const { prefs, updatePrefs } = useUserPrefs();
// Only show kanban if the Labs flag is explicitly enabled // Only show kanban if the Labs flag is explicitly enabled
const kanbanEnabled = prefs.labs?.kanbanBoard ?? false; const kanbanEnabled = Boolean(prefs.labs?.kanbanBoard);
const viewMode = kanbanEnabled ? prefs.viewMode : "list"; const viewMode = kanbanEnabled ? (prefs.viewMode || "list") : "list";
const handleViewModeChange = (mode: "list" | "kanban") => { const handleToggleViewMode = () => {
updatePrefs({ viewMode: mode }); const nextMode = viewMode === "kanban" ? "list" : "kanban";
updatePrefs({ viewMode: nextMode });
}; };
// TickTick-style Quick Add Preset states // TickTick-style Quick Add Preset states
@@ -598,8 +616,35 @@ export function TaskList({
return () => document.removeEventListener("checkflow:addTask", handler); return () => document.removeEventListener("checkflow:addTask", handler);
}, []); }, []);
// Recursive update helper for local tree optimistic state
const updateTaskCompletedInTree = (tree: Task[], targetId: string, isComp: boolean): Task[] => {
return tree.map((node) => {
if (node.id === targetId) {
return {
...node,
completed: isComp,
completedAt: isComp ? new Date().toISOString() : null,
children: node.children ? updateTaskCompletedInTree(node.children, targetId, isComp) : [],
};
}
if (node.children && node.children.length > 0) {
return {
...node,
children: updateTaskCompletedInTree(node.children, targetId, isComp),
};
}
return node;
});
};
const handleToggle = useCallback( const handleToggle = useCallback(
async (id: string, completed: boolean) => { async (id: string, completed: boolean) => {
// 1. Optimistic UI update immediately
setTasks((prev) => {
const updated = updateTaskCompletedInTree(prev, id, completed);
return showCompleted ? updated : updated.filter((t) => t.id !== id || !completed);
});
if (isDemo) { if (isDemo) {
if (onDemoToggleTask) onDemoToggleTask(id, completed); if (onDemoToggleTask) onDemoToggleTask(id, completed);
return; return;
@@ -615,9 +660,11 @@ export function TaskList({
onRefresh(); onRefresh();
} catch (err) { } catch (err) {
console.error("[TaskList] handleToggle failed", err); console.error("[TaskList] handleToggle failed", err);
// Rollback on failure
fetchTasks();
} }
}, },
[isDemo, onDemoToggleTask, onRefresh] [isDemo, onDemoToggleTask, onRefresh, showCompleted, setTasks, fetchTasks]
); );
const handleAddTask = async (e: React.FormEvent) => { const handleAddTask = async (e: React.FormEvent) => {
@@ -656,8 +703,43 @@ export function TaskList({
} }
}; };
const insertSubtaskInTree = (tree: Task[], pId: string, subtask: Task): Task[] => {
return tree.map((node) => {
if (node.id === pId) {
const currentChildren = node.children || [];
return { ...node, children: [...currentChildren, subtask] };
}
if (node.children && node.children.length > 0) {
return { ...node, children: insertSubtaskInTree(node.children, pId, subtask) };
}
return node;
});
};
const handleAddSubtaskInline = async (title: string, parentId: string) => { const handleAddSubtaskInline = async (title: string, parentId: string) => {
if (!listId) return; if (!listId) return;
const tempId = "temp-subtask-" + Date.now();
const tempSubtask: Task = {
id: tempId,
listId,
parentId,
title: title.trim(),
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 0,
sortOrder: 999,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [],
tags: [],
};
// Optimistic insert
setTasks((prev) => insertSubtaskInTree(prev, parentId, tempSubtask));
if (isDemo) { if (isDemo) {
if (onDemoAddTask) onDemoAddTask(title, listId, parentId); if (onDemoAddTask) onDemoAddTask(title, listId, parentId);
return; return;
@@ -670,10 +752,27 @@ export function TaskList({
body: JSON.stringify({ title, listId, parentId }), body: JSON.stringify({ title, listId, parentId }),
}); });
if (res.ok) { if (res.ok) {
const createdTask = await res.json();
// Replace temp subtask with actual created task
setTasks((prev) => {
const replaceTemp = (nodes: Task[]): Task[] => {
return nodes.map((n) => {
if (n.id === tempId) return createdTask;
if (n.children && n.children.length > 0) {
return { ...n, children: replaceTemp(n.children) };
}
return n;
});
};
return replaceTemp(prev);
});
onRefresh(); onRefresh();
} else {
fetchTasks();
} }
} catch (err) { } catch (err) {
console.error("Failed to add subtask inline", err); console.error("Failed to add subtask inline", err);
fetchTasks();
} }
}; };
@@ -687,21 +786,164 @@ export function TaskList({
setEditingHeader(false); setEditingHeader(false);
}; };
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => { // Helper: check if targetId is inside the subtree of ancestorId (prevents circular nesting / node disappearing)
setTasks((prev) => { const isDescendantNode = (nodes: Task[], ancestorId: string, targetId: string): boolean => {
const draggedIndex = prev.findIndex((t) => t.id === draggedId); for (const node of nodes) {
const targetIndex = prev.findIndex((t) => t.id === targetId); if (node.id === ancestorId) {
if (draggedIndex === -1 || targetIndex === -1) return prev; 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;
};
const newArr = [...prev]; // Robust Tree-aware reordering function (1st, 2nd, 3rd depth task reordering, promoting to top-level, and demoting into subtask)
const [draggedItem] = newArr.splice(draggedIndex, 1); const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after" | "inside" | "root") => {
const insertAt = position === "before" // Prevent dropping onto itself or into its own subtree (which causes loops/disappearing tasks)
? (draggedIndex < targetIndex ? targetIndex - 1 : targetIndex) if (draggedId === targetId && position !== "root") return;
: (draggedIndex < targetIndex ? targetIndex : targetIndex + 1);
newArr.splice(Math.max(0, insertAt), 0, draggedItem); setTasks((prev) => {
return newArr; 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;
}); });
}, [setTasks]); 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) => { const handleDeleteTask = useCallback((task: Task) => {
if (onDeleteTaskWithUndo) { if (onDeleteTaskWithUndo) {
@@ -729,7 +971,8 @@ export function TaskList({
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}> <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* Header */} {/* Header */}
<div className="main-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"> <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"> <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" /> <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" />
@@ -738,12 +981,12 @@ export function TaskList({
{/* Header Title / Trash Header / Tag Header */} {/* Header Title / Trash Header / Tag Header */}
{isTrashActive ? ( {isTrashActive ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <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: 18, fontWeight: 700, color: "var(--danger)" }}>🗑 {t("trash") || "Trash"}</span>
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span> <span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
</div> </div>
) : selectedTag ? ( ) : selectedTag ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--accent)" }}>🏷 #{selectedTag}</span> <span style={{ fontSize: 18, fontWeight: 700, color: "var(--accent)" }}>🏷 #{selectedTag}</span>
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span> <span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
</div> </div>
@@ -764,7 +1007,7 @@ export function TaskList({
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
autoFocus autoFocus
style={{ fontSize: 18, fontWeight: 700, height: 36, padding: "2px 10px", maxWidth: 360 }} style={{ fontSize: 18, fontWeight: 700, height: 36, padding: "2px 10px", width: "100%", maxWidth: 320 }}
/> />
) : ( ) : (
<div <div
@@ -779,6 +1022,10 @@ export function TaskList({
padding: "4px 8px", padding: "4px 8px",
borderRadius: "var(--radius-sm)", borderRadius: "var(--radius-sm)",
transition: "background var(--dur-fast)", transition: "background var(--dur-fast)",
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}} }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -786,33 +1033,43 @@ export function TaskList({
}} }}
title="Click to rename list" title="Click to rename list"
> >
<span>{currentList?.name || t("tasks")}</span> <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{currentList?.name || t("tasks")}</span>
<span style={{ fontSize: 13, opacity: 0.5 }}></span> <span style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 400, flexShrink: 0 }}>
({incompleteTasks.length})
</span>
</div> </div>
)} )}
</div>
{/* Header Actions */} {/* Header Actions */}
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}> <div className="main-header-actions" style={{ flexShrink: 0, marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
{/* View Switcher: List vs Kanban (Hidden in Trash/Tag mode) */} {/* View Switcher: Minimal & Matte Single Toggle (Only shown when Kanban is enabled in Labs) */}
{!isTrashActive && !selectedTag && ( {!isTrashActive && !selectedTag && kanbanEnabled && (
<div className="view-switcher-group">
<button <button
type="button" type="button"
className={`view-switcher-btn${viewMode === "list" ? " active" : ""}`} className={`view-switcher-toggle-btn${viewMode === "kanban" ? " active" : ""}`}
onClick={() => handleViewModeChange("list")} onClick={handleToggleViewMode}
title={t("listView")} 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 className="desktop-only">{t("listView")}</span> <span>{viewMode === "kanban" ? "📊" : "📋"}</span>
<span style={{ fontSize: 11.5 }}>
{viewMode === "kanban" ? t("kanbanView") : t("listView")}
</span>
</button> </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 ? ( {isTrashActive ? (
@@ -865,17 +1122,38 @@ export function TaskList({
/> />
</div> </div>
) : ( ) : (
/* Task list container */ /* Task list container with background drop support for promoting subtask to 1st depth */
<div <div
className="task-list-container" 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) => { onContextMenu={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
e.preventDefault(); e.preventDefault();
} }
}} }}
> >
{loading && ( {loading && tasks.length === 0 && (
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div> <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 && tasks.length === 0 && ( {!loading && tasks.length === 0 && (
<div className="task-list-empty"> <div className="task-list-empty">
@@ -887,7 +1165,7 @@ export function TaskList({
)} )}
{/* Tasks Tree */} {/* Tasks Tree */}
{incompleteTasks.map((task) => ( {(isTrashActive ? tasks : incompleteTasks).map((task) => (
<RecursiveTaskItem <RecursiveTaskItem
key={task.id} key={task.id}
task={task} task={task}
+21 -3
View File
@@ -25,10 +25,28 @@ export const authOptions: NextAuthOptions = {
const valid = await bcrypt.compare(credentials.password, user.passwordHash); const valid = await bcrypt.compare(credentials.password, user.passwordHash);
if (!valid) return null; if (!valid) return null;
// Admin identification logic // Admin identification logic:
// 1. Explicit admin env match
// 2. Email format match (admin@... or ...@checkflow.local)
// 3. First-ever registered user in the database is automatically granted ADMIN
const adminEmail = process.env.ADMIN_EMAIL; const adminEmail = process.env.ADMIN_EMAIL;
const isEmailAdmin = adminEmail && user.email === adminEmail; const isEmailAdmin = adminEmail && user.email.toLowerCase() === adminEmail.toLowerCase();
const isAdmin = isEmailAdmin || user.email.endsWith("@checkflow.local") || user.email.startsWith("admin@"); const isPrefixAdmin = user.email.endsWith("@checkflow.local") || user.email.toLowerCase().startsWith("admin@");
let isFirstUser = false;
try {
const firstUser = await prisma.user.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
});
if (firstUser && firstUser.id === user.id) {
isFirstUser = true;
}
} catch (e) {
console.error("[auth] failed to check first user", e);
}
const isAdmin = isEmailAdmin || isPrefixAdmin || isFirstUser;
const role = isAdmin ? "ADMIN" : "USER"; const role = isAdmin ? "ADMIN" : "USER";
return { id: user.id, email: user.email, name: user.name, role }; return { id: user.id, email: user.email, name: user.name, role };
+27 -4
View File
@@ -2,6 +2,7 @@
import React, { createContext, useContext, useState, useEffect } from "react"; import React, { createContext, useContext, useState, useEffect } from "react";
import { Language, TranslationKey, TranslationDict } from "./types"; import { Language, TranslationKey, TranslationDict } from "./types";
import { translations, en, ko, ja } from "./locales"; import { translations, en, ko, ja } from "./locales";
import { getUserSettings, saveUserSettings } from "@/lib/mockData";
export type { Language, TranslationKey, TranslationDict }; export type { Language, TranslationKey, TranslationDict };
export { translations, en, ko, ja }; export { translations, en, ko, ja };
@@ -20,15 +21,35 @@ const I18nContext = createContext<I18nContextType>({
const LANG_STORAGE_KEY = "checkflow_lang"; const LANG_STORAGE_KEY = "checkflow_lang";
export function I18nProvider({ children }: { children: React.ReactNode }) { function getInitialLanguage(): Language {
const [lang, setLangState] = useState<Language>("en"); if (typeof window === "undefined") return "en";
useEffect(() => {
try { try {
const saved = localStorage.getItem(LANG_STORAGE_KEY) as Language | null; const saved = localStorage.getItem(LANG_STORAGE_KEY) as Language | null;
if (saved && (saved === "en" || saved === "ko" || saved === "ja")) { if (saved && (saved === "en" || saved === "ko" || saved === "ja")) {
setLangState(saved); 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);
useEffect(() => {
try {
const initLang = getInitialLanguage();
setLangState(initLang);
} catch { } catch {
// ignore // ignore
} }
@@ -38,6 +59,8 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
setLangState(l); setLangState(l);
try { try {
localStorage.setItem(LANG_STORAGE_KEY, l); localStorage.setItem(LANG_STORAGE_KEY, l);
const cur = getUserSettings();
saveUserSettings({ ...cur, language: l });
} catch { } catch {
// ignore // ignore
} }
+128 -73
View File
@@ -3,50 +3,50 @@ import { TranslationDict } from "../types";
export const en: TranslationDict = { export const en: TranslationDict = {
// Auth // Auth
appName: "CheckFlow", appName: "CheckFlow",
tagline: "Your Personal Todo & Markdown Workspace", tagline: "TickTick-style independent task manager",
welcomeBack: "Welcome back", welcomeBack: "Welcome back",
signInSubtitle: "Sign in to your account", signInSubtitle: "Sign in to manage your tasks securely",
createAccount: "Create account", createAccount: "Create account",
createAccountSubtitle: "Start organizing your tasks today", createAccountSubtitle: "Get started with your self-hosted tasks",
displayName: "Display Name", displayName: "Display Name",
email: "Email", email: "Email",
password: "Password", password: "Password",
min8Chars: "Min. 8 characters", min8Chars: "At least 8 characters",
signIn: "Sign in", signIn: "Sign In",
signingIn: "Signing in...", signingIn: "Signing in...",
createBtn: "Create account", createBtn: "Create Account",
creatingBtn: "Creating account...", creatingBtn: "Creating account...",
alreadyHaveAccount: "Already have an account?", alreadyHaveAccount: "Already have an account? Sign in",
dontHaveAccount: "Don't have an account?", dontHaveAccount: "Don't have an account? Create one",
tryDemoMode: "Try Demo Mode (No DB Required)", tryDemoMode: "Try Demo Mode (No DB)",
demoBadge: "Demo Mode", demoBadge: "Demo Mode",
signOut: "Sign out", signOut: "Sign Out",
// Sidebar // Sidebar
lists: "Lists", lists: "Lists",
newList: "New List", newList: "New List",
listNamePlaceholder: "List name", listNamePlaceholder: "List name...",
create: "Create", create: "Create",
cancel: "Cancel", cancel: "Cancel",
save: "Save", save: "Save",
deleteListConfirm: "Delete this list and all its tasks?", deleteListConfirm: "Are you sure you want to delete this list?",
undoDelete: "Undo", undoDelete: "Undo",
listDeleted: "List deleted", listDeleted: "List deleted",
importTasks: "Import Tasks", importTasks: "Import Tasks",
importModalTitle: "Import Tasks", importModalTitle: "Import Tasks (CSV / ICS)",
targetList: "Target List", targetList: "Target List",
fileSelectLabel: "File (CSV or ICS from TickTick)", fileSelectLabel: "Select .csv or .ics file (TickTick export supported)",
tickTickExportHint: "TickTick: Settings → Export → Export as CSV or iCalendar", tickTickExportHint: "TickTick export: Settings → Backup → Export CSV",
importBtn: "Import", importBtn: "Import",
importing: "Importing...", importing: "Importing...",
exportTasks: "Export Tasks", exportTasks: "Export Tasks",
exportModalTitle: "Export Tasks", exportModalTitle: "Export Tasks (CSV / ICS)",
exportFormat: "Format", exportFormat: "Format",
exportScope: "Target List", exportScope: "Scope",
allLists: "All Lists (Entire Tasks)", allLists: "All Lists",
includeCompleted: "Include Completed Tasks", includeCompleted: "Include completed tasks",
exportBtn: "Export & Download", exportBtn: "Export",
exportSuccess: "Tasks exported successfully!", exportSuccess: "Tasks exported successfully",
theme: "Theme", theme: "Theme",
themeSystem: "System", themeSystem: "System",
themeLight: "Light", themeLight: "Light",
@@ -55,21 +55,70 @@ export const en: TranslationDict = {
tags: "Tags", tags: "Tags",
trash: "Trash", trash: "Trash",
emptyTrash: "Empty Trash", emptyTrash: "Empty Trash",
searchPlaceholder: "Search tasks, notes, tags...", searchPlaceholder: "Search tasks...",
searchExpandedPlaceholder: "Search anything — tasks, notes, tags (Ctrl+K)...", searchExpandedPlaceholder: "Type to search tasks, tags, notes...",
// Settings Modal // Settings Modal - General & Tabs
settingsModalTitle: "Settings", settingsModalTitle: "Settings",
profile: "Profile", profile: "Profile",
preferences: "Preferences", preferences: "Preferences",
syncIntegrations: "Integrations (CalDAV)", syncIntegrations: "Integrations",
admin: "Admin", admin: "Admin",
trashRetention: "Trash Retention Period", trashRetention: "Trash Auto-Delete Retention Period",
trashRetentionHint: "Deleted tasks will be permanently removed after the specified period.", trashRetentionHint: "Deleted tasks will be permanently removed after the specified period.",
days7: "7 Days", days7: "7 Days",
days14: "14 Days", days14: "14 Days",
days30: "30 Days (Recommended)", days30: "30 Days (Recommended)",
neverDelete: "Never Auto-Delete", 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 // CalDAV & Sync
syncTitle: "CalDAV & Mobile Sync (Android / Apple)", syncTitle: "CalDAV & Mobile Sync (Android / Apple)",
@@ -80,33 +129,37 @@ export const en: TranslationDict = {
syncDownloadIcs: "Download .ICS Feed", syncDownloadIcs: "Download .ICS Feed",
syncTestConnection: "Test Endpoint", syncTestConnection: "Test Endpoint",
syncTesting: "Testing...", syncTesting: "Testing...",
syncTestSuccess: "CalDAV endpoint responded successfully (200 OK)", syncTestSuccess: "CalDAV endpoint responded successfully",
syncTestError: "Endpoint test failed",
syncTabAndroid: "Android (DAVx⁵)", syncTabAndroid: "Android (DAVx⁵)",
syncTabApple: "Apple Reminders (iOS / macOS)", syncTabApple: "Apple Reminders",
syncTabThunderbird: "Thunderbird (Desktop)", syncTabThunderbird: "Thunderbird",
syncAndroidStep1: "Install DAVx⁵ from Google Play Store or F-Droid.", syncAndroidStep1: "Install DAVx⁵ from Google Play Store or F-Droid.",
syncAndroidStep2: "Open DAVx⁵ → Add Account → Select 'Login with URL and user name'.", syncAndroidStep2: "Choose 'Login with URL and user name' and enter the CalDAV URL above.",
syncAndroidStep3: "Paste the Base URL above, then enter your CheckFlow email & password.", syncAndroidStep3: "Enter your CheckFlow email and password.",
syncAndroidStep4: "Enable 'VTODO (Tasks)' collection to sync with Android Tasks or OpenTasks.", syncAndroidStep4: "Open your favorite To-Do app (e.g. Tasks.org, OpenTasks) to manage your synchronized tasks.",
syncAppleStep1: "Open Settings → Reminders → Accounts → Add Account.", syncAppleStep1: "Open System Settings (macOS) or Settings (iOS/iPadOS) → Internet Accounts.",
syncAppleStep2: "Select 'Other' → Add CalDAV Account.", syncAppleStep2: "Tap 'Add Account' → 'Add CalDAV Account'.",
syncAppleStep3: "Server: paste base URL without protocol, User/Pass: CheckFlow credentials.", syncAppleStep3: "Set Account Type to 'Manual', Server Address to the CalDAV URL, with your email & password.",
syncAppleStep4: "Toggle 'Reminders' ON and tap Save.", syncAppleStep4: "Tasks will automatically sync inside the Apple Reminders app.",
syncThunderbirdStep1: "Open Thunderbird → Switch to Calendar view → New Calendar.", syncThunderbirdStep1: "Open Thunderbird → Calendar / Tasks tab.",
syncThunderbirdStep2: "Select 'On the Network' → Format: CalDAV.", syncThunderbirdStep2: "Right-click Calendar list → 'New Calendar' → 'On the Network'.",
syncThunderbirdStep3: "Location: paste the Base URL, Username: CheckFlow email.", syncThunderbirdStep3: "Select 'CalDAV', paste the CalDAV URL, and enter your credentials.",
syncThunderbirdStep4: "Enter your password when prompted to finalize sync.", 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 // Task List
tasks: "Tasks", tasks: "Tasks",
hideDone: "Hide done", hideDone: "Hide Done",
showDone: "Show done", showDone: "Show Done",
completedSection: "Completed", completedSection: "Completed",
addTaskPlaceholder: "Add a task...", addTaskPlaceholder: "What would you like to accomplish?",
add: "Add", add: "Add",
noTasksYet: "No tasks yet. Add one below!", noTasksYet: "No tasks in this list",
selectListToStart: "Select a list to get started", selectListToStart: "Select a list to get started",
loading: "Loading...", loading: "Loading tasks...",
today: "Today", today: "Today",
tomorrow: "Tomorrow", tomorrow: "Tomorrow",
@@ -117,32 +170,34 @@ export const en: TranslationDict = {
priorityLow: "Low", priorityLow: "Low",
priorityMedium: "Medium", priorityMedium: "Medium",
priorityHigh: "High", priorityHigh: "High",
dueDate: "Due Date", dueDate: "Due date",
clear: "Clear", clear: "Clear",
notes: "Notes", notes: "Notes",
notesPlaceholder: "Add notes, details, or anything you need to remember…\n\nMarkdown supported: **bold**, *italic*, # heading, - list, - [ ] checkbox", notesPlaceholder: "Add details, markdown supported...",
subtasks: "Sub-tasks", subtasks: "Subtasks",
addSubtaskPlaceholder: "Add sub-task", addSubtaskPlaceholder: "Add a subtask...",
deleteTaskConfirm: "Delete this task?", deleteTaskConfirm: "Delete this task?",
autoSaved: "Auto-saved", autoSaved: "Saved",
saving: "Saving", saving: "Saving...",
created: "Created", created: "Created",
edited: "Edited", edited: "Edited",
previewTab: "Preview",
editTab: "Edit",
// Toolbars // Toolbars
bold: "Bold", bold: "Bold",
italic: "Italic", italic: "Italic",
heading: "Heading", heading: "Heading",
bulletList: "Bullet list", bulletList: "Bullet List",
numberedList: "Numbered list", numberedList: "Numbered List",
checkbox: "Checkbox", checkbox: "Checkbox",
code: "Code", code: "Code",
// TickTick Mode Switcher & Quick Add // TickTick Mode Switcher & Quick Add
textMode: "Text Note Mode", textMode: "Text Mode",
subtaskMode: "Checklist Mode", subtaskMode: "Subtask Mode",
switchToTextMode: "Switch to Markdown Note", switchToTextMode: "Switch to Text Mode",
switchToSubtaskMode: "Switch to Checklist Sub-tasks", switchToSubtaskMode: "Switch to Subtask Mode",
moveToList: "Move to List", moveToList: "Move to List",
quickAdd: "Quick Add", quickAdd: "Quick Add",
quickToday: "Today", quickToday: "Today",
@@ -151,31 +206,31 @@ export const en: TranslationDict = {
selectDate: "Date", selectDate: "Date",
selectPriority: "Priority", selectPriority: "Priority",
selectTag: "Tag", selectTag: "Tag",
copied: "Copied!", copied: "Copied",
// Context Menu // Context Menu
editTask: "Edit", editTask: "Edit Task",
duplicateTask: "Duplicate", duplicateTask: "Duplicate",
moveTask: "Move to…", moveTask: "Move To...",
deleteTask: "Delete", deleteTask: "Delete Task",
renameList: "Rename", renameList: "Rename",
deleteList: "Delete list", deleteList: "Delete List",
taskUndoHint: "Deleted. Tap to undo.", taskUndoHint: "Task deleted",
listUndoHint: "List deleted. Tap to undo.", listUndoHint: "List deleted",
// Labs, Kanban & Customization // Labs, Kanban & Customization
labs: "Labs", labs: "Labs",
labsDesc: "Enable or disable experimental features individually.", labsDesc: "Experimental features and UI personalization.",
kanbanView: "Kanban Board", kanbanView: "Kanban",
listView: "List View", listView: "List",
enableKanban: "Kanban Board View", enableKanban: "Kanban Board View",
enableKanbanDesc: "Switch between list and 3-column board (To Do / Doing / Done).", enableKanbanDesc: "Enable 3-column Kanban board view (To Do, In Progress, Done) switchable in task lists.",
swapBlocks: "Swap Position", swapBlocks: "Swap Blocks",
resetDefaults: "Reset to Defaults", resetDefaults: "Reset",
resetConfirm: "Reset all UI customizations to factory defaults?", resetConfirm: "Reset all customizations to defaults?",
todoCol: "To Do", todoCol: "To Do",
inProgressCol: "In Progress", inProgressCol: "In Progress",
doneCol: "Done", doneCol: "Done",
accessRestricted: "Access Restricted", accessRestricted: "Access Restricted",
adminRequired: "This console requires ADMIN role.", adminRequired: "This console requires ADMIN privileges.",
}; };
+162 -107
View File
@@ -3,109 +3,162 @@ import { TranslationDict } from "../types";
export const ja: TranslationDict = { export const ja: TranslationDict = {
// Auth // Auth
appName: "CheckFlow", appName: "CheckFlow",
tagline: "シンプルで洗練されたパーソナルタスク管理", tagline: "TickTickスタイルのセルフホスト型タスク管理",
welcomeBack: "おりなさい 👋", welcomeBack: "おかえりなさい",
signInSubtitle: "メールアドレスでサインインしてください", signInSubtitle: "安全にログインしてタスクを管理します",
createAccount: "アカウント新規登録", createAccount: "アカウント作成",
createAccountSubtitle: "CheckFlowでタスクの整理を始めましょう", createAccountSubtitle: "セルフホストToDoプラットフォームを始めましょう",
displayName: "表示名", displayName: "表示名",
email: "メールアドレス", email: "メールアドレス",
password: "パスワード", password: "パスワード",
min8Chars: "8文字以上", min8Chars: "8文字以上",
signIn: "サインイン", signIn: "ログイン",
signingIn: "サインイン中...", signingIn: "ログイン中...",
createBtn: "アカウント作成", createBtn: "アカウント作成",
creatingBtn: "アカウント作成中...", creatingBtn: "作成中...",
alreadyHaveAccount: "すでにアカウントをお持ちですか?", alreadyHaveAccount: "にアカウントをお持ちですか? ログイン",
dontHaveAccount: "アカウントをお持ちでないですか?", dontHaveAccount: "アカウントをお持ちでないですか? 新規作成",
tryDemoMode: "体験モード試す(DB不要)", tryDemoMode: "デモモード試す(DB不要)",
demoBadge: "体験モード", demoBadge: "デモモード",
signOut: "サインアウト", signOut: "ログアウト",
// Sidebar // Sidebar
lists: "リスト", lists: "リスト",
newList: "新リスト", newList: "新しいリスト",
listNamePlaceholder: "リスト名を入力", listNamePlaceholder: "リスト名を入力...",
create: "作成", create: "作成",
cancel: "キャンセル", cancel: "キャンセル",
save: "保存", save: "保存",
deleteListConfirm: "このリストを削除しますか?含まれるすべてのタスク削除されます。", deleteListConfirm: "このリスト含まれるすべてのタスク削除しますか?",
undoDelete: "元に戻す", undoDelete: "元に戻す",
listDeleted: "リスト削除ました", listDeleted: "リスト削除されました",
importTasks: "インポート", importTasks: "タスクをインポート",
importModalTitle: "タスクのインポート", importModalTitle: "タスクのインポート (CSV / ICS)",
targetList: "インポート先リスト", targetList: "インポート先リスト",
fileSelectLabel: "ファイル選択 (TickTick CSV / ICS)", fileSelectLabel: ".csv または .ics ファイル選択 (TickTickエクスポート対応)",
tickTickExportHint: "TickTick: 設定 → データのエクスポート → CSVまたはiCalendar", tickTickExportHint: "TickTickエクスポート手順: 設定 → バックアップ → CSVエクスポート",
importBtn: "インポート", importBtn: "インポート",
importing: "インポート中...", importing: "インポート中...",
exportTasks: "エクスポート", exportTasks: "タスクをエクスポート",
exportModalTitle: "タスクのエクスポート", exportModalTitle: "タスクのエクスポート (CSV / ICS)",
exportFormat: "エクスポート形式", exportFormat: "ファイル形式",
exportScope: "対象リスト", exportScope: "対象範囲",
allLists: "すべてのリスト (全体)", allLists: "すべてのリスト",
includeCompleted: "完了したタスクを含める", includeCompleted: "完了したタスクを含める",
exportBtn: "エクスポートしてダウンロード", exportBtn: "エクスポート",
exportSuccess: "タスクを正常にエクスポートしました", exportSuccess: "タスクエクスポートが完了しました",
theme: "テーマ", theme: "テーマ",
themeSystem: "システム設定に同期", themeSystem: "システム",
themeLight: "ライトモード", themeLight: "ライト",
themeDark: "ダークモード", themeDark: "ダーク",
language: "言語 (Language)", language: "言語",
tags: "タグ", tags: "タグ",
trash: "ゴミ箱", trash: "ゴミ箱",
emptyTrash: "ゴミ箱を空にする", emptyTrash: "ゴミ箱を空にする",
searchPlaceholder: "タスク、メモ、タグを検索...", searchPlaceholder: "タスクを検索...",
searchExpandedPlaceholder: "タスク、メモ、タグをすべて検索 (Ctrl+K)...", searchExpandedPlaceholder: "タスク名、タグ、メモを検索...",
// Settings Modal // Settings Modal - General & Tabs
settingsModalTitle: "環境設定", settingsModalTitle: "設定",
profile: "プロフィール", profile: "プロフィール",
preferences: "カスタマイズ設定", preferences: "環境設定",
syncIntegrations: "外部連携 (CalDAV)", syncIntegrations: "外部連携",
admin: "管理者設定", admin: "管理者",
trashRetention: "ゴミ箱自動保持期間", trashRetention: "ゴミ箱自動削除の保持期間",
trashRetentionHint: "指定した期間が経過した削除済みタスクは自動的に完全に削除されます。", trashRetentionHint: "削除されたタスクは指定期間を過ぎると自動的に完全に削除されます。",
days7: "7日後に完全削除", days7: "7日",
days14: "14日後に完全削除", days14: "14日",
days30: "30日後に完全削除 (推奨)", days30: "30日 (推奨)",
neverDelete: "自動削除しない (手動削除のみ)", 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 // CalDAV & Sync
syncTitle: "CalDAV モバイルおよび外部アプリ同期", syncTitle: "CalDAV モバイル同期 (Android / Apple)",
syncDesc: "Android (DAVx⁵, Tasks.org)、Apple リマインダー、Thunderbird など標準 CalDAV をサポートする各種端末やアプリと双方向同期できます。", syncDesc: "CheckFlowは標準CalDAVプロトコルにより、Android、Appleリマインダー、Thunderbirdとの双方向同期に対応しています。",
syncBaseUrl: "CalDAV サーバー基本 URL", syncBaseUrl: "CalDAV サーバーアドレス (Base URL)",
syncCopyUrl: "URLをコピー", syncCopyUrl: "URLをコピー",
syncCopied: "コピー完了", syncCopied: "コピーしました",
syncDownloadIcs: ".ICS フィードをダウンロード", syncDownloadIcs: ".ICS フィードをダウンロード",
syncTestConnection: "接続テスト", syncTestConnection: "接続テスト",
syncTesting: "テスト中...", syncTesting: "確認中...",
syncTestSuccess: "CalDAV エンドポイントは正常に応答しています (200 OK)", syncTestSuccess: "CalDAVサーバーが正常に応答しました",
syncTestError: "エンドポイントテスト失敗",
syncTabAndroid: "Android (DAVx⁵)", syncTabAndroid: "Android (DAVx⁵)",
syncTabApple: "Apple リマインダー (iOS / macOS)", syncTabApple: "Apple リマインダー",
syncTabThunderbird: "Thunderbird (Desktop)", syncTabThunderbird: "Thunderbird",
syncAndroidStep1: "Google Play または F-Droid からDAVx⁵」アプリをインストールします。", syncAndroidStep1: "Google Play または F-Droid から DAVx⁵ をインストールします。",
syncAndroidStep2: "DAVx⁵ を起動 → アカウント追加(+) →「URL とユーザー名でログイン」を選択します。", syncAndroidStep2: "「URLとユーザー名でログイン」を選択し、上記のCalDAV URLを入力します。",
syncAndroidStep3: "上記の基本 URL を入力し、CheckFlow のログインメールアドレスとパスワードを入力します。", syncAndroidStep3: "CheckFlow のメールアドレスとパスワードを入力します。",
syncAndroidStep4: "「VTODO (タスク)」コレクションを有効にすると、Tasks.org や OpenTasks と双方向同期されます。", syncAndroidStep4: "Tasks.org や OpenTasks などのアプリを開いてタスクを管理します。",
syncAppleStep1: "端末の設定 → リマインダー → アカウント →「アカウントを追加」を開きます。", syncAppleStep1: "システム設定 (macOS) または 設定 (iOS/iPadOS) → インターネットアカウント を開きます。",
syncAppleStep2: "「その他」を選択 →「CalDAV アカウントを追加」を選択します。", syncAppleStep2: "「アカウントを追加」→「CalDAVアカウントを追加」を選択します。",
syncAppleStep3: "サーバーに上記の URL、ユーザー名とパスワードに CheckFlow のアカウント情報を入力します。", syncAppleStep3: "アカウントの種類を「手動」にし、上記のCalDAV URLとログイン情報を入力します。",
syncAppleStep4: "「リマインダー」をオンにして保存します。", syncAppleStep4: "Apple リマインダー (Reminders) アプリにタスクが自動同期されます。",
syncThunderbirdStep1: "Thunderbird を起動 → カレンダータブ →「新しいカレンダー」を作成します。", syncThunderbirdStep1: "Mozilla Thunderbird を開き、カレンダー/タスク タブを開きます。",
syncThunderbirdStep2: "「ネットワーク上」を選択 → 形式:「CalDAV」を選択します。", syncThunderbirdStep2: "カレンダー一覧を右クリック →「新しいカレンダー」→「ネットワーク上」を選択します。",
syncThunderbirdStep3: "場所に上記の基本 URL、ユーザー名に CheckFlow のメールアドレスを入力します。", syncThunderbirdStep3: "「CalDAV」を選択し、上記のCalDAV URLとログイン情報を入力します。",
syncThunderbirdStep4: "認証ダイアログでパスワードを入力して設定を完了します。", syncThunderbirdStep4: "CheckFlow のタスク一覧が Thunderbird Tasks に表示されます。",
syncAndroidTip: "DAVx⁵ の設定で同期周期を 15 分に設定すると、バッテリーを節約しながら準リアルタイム同期が可能です。",
syncAppleTip: "Nginx/Caddy などのリバースプロキシで HTTPS を使用する場合は、有効な SSL 証明書が設定されているか確認してください。",
syncThunderbirdTip: "Thunderbird のタスクビューで CheckFlow の優先度、期日、完了状態がそのまま表示されます。",
// Task List // Task List
tasks: "タスク", tasks: "タスク",
hideDone: "完了済みを非表示", hideDone: "完了項目を非表示",
showDone: "完了済みを表示", showDone: "完了項目を表示",
completedSection: "完了したタスク", completedSection: "完了済み",
addTaskPlaceholder: "新しいタスクを入力...", addTaskPlaceholder: "新しいタスクを書き留めましょう",
add: "追加", add: "追加",
noTasksYet: "タスクがありません。下から新しいタスクを追加してみましょう!", noTasksYet: "このリストにはタスクがありません",
selectListToStart: "リストを選択してタスクを表示してください", selectListToStart: "リストを選択して開始してください",
loading: "読み込み中...", loading: "読み込み中...",
today: "今日", today: "今日",
tomorrow: "明日", tomorrow: "明日",
@@ -113,69 +166,71 @@ export const ja: TranslationDict = {
// Task Detail // Task Detail
taskTitlePlaceholder: "タスク名", taskTitlePlaceholder: "タスク名",
priority: "優先度", priority: "優先度",
priorityNone: "指定なし", priorityNone: "なし",
priorityLow: "低", priorityLow: "低",
priorityMedium: "中", priorityMedium: "中",
priorityHigh: "高", priorityHigh: "高",
dueDate: "期日", dueDate: "期日",
clear: "クリア", clear: "クリア",
notes: "メモ", notes: "メモ",
notesPlaceholder: "自由にメモを記入してください…\n\nMarkdown対応: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス", notesPlaceholder: "詳細を入力 (Markdown対応)...",
subtasks: "サブタスク", subtasks: "サブタスク",
addSubtaskPlaceholder: "サブタスクを追加", addSubtaskPlaceholder: "サブタスクを追加...",
deleteTaskConfirm: "このタスクを削除しますか?", deleteTaskConfirm: "タスクを削除しますか?",
autoSaved: "自動保存済み", autoSaved: "保存済み",
saving: "保存中", saving: "保存中...",
created: "作成日時", created: "作成日時",
edited: "更新日時", edited: "更新日時",
previewTab: "プレビュー",
editTab: "編集",
// Toolbars // Toolbars
bold: "太字", bold: "太字",
italic: "斜体", italic: "斜体",
heading: "見出し", heading: "見出し",
bulletList: "箇条書きリスト", bulletList: "箇条書き",
numberedList: "番号付きリスト", numberedList: "番号付きリスト",
checkbox: "チェックボックス", checkbox: "チェックボックス",
code: "コードブロック", code: "コード",
// TickTick Mode Switcher & Quick Add // TickTick Mode Switcher & Quick Add
textMode: "Markdownメモモード", textMode: "テキストモード",
subtaskMode: "チェックリストモード", subtaskMode: "サブタスクモード",
switchToTextMode: "Markdownメモモードに切り替え", switchToTextMode: "テキストモードに切り替え",
switchToSubtaskMode: "チェックリストモードに切り替え", switchToSubtaskMode: "サブタスクモードに切り替え",
moveToList: "別のリスト移動", moveToList: "リスト移動",
quickAdd: "クイック追加", quickAdd: "クイック追加",
quickToday: "今日", quickToday: "今日",
quickTomorrow: "明日", quickTomorrow: "明日",
quickNextWeek: "来週", quickNextWeek: "来週",
selectDate: "期日設定", selectDate: "日付を選択",
selectPriority: "優先度設定", selectPriority: "優先度",
selectTag: "タグ設定", selectTag: "タグを選択",
copied: "コピー完了", copied: "コピー完了",
// Context Menu // Context Menu
editTask: "編集", editTask: "タスクを編集",
duplicateTask: "複製", duplicateTask: "複製",
moveTask: "移動", moveTask: "移動...",
deleteTask: "削除", deleteTask: "タスクを削除",
renameList: "名を変更", renameList: "リスト名を変更",
deleteList: "リストを削除", deleteList: "リストを削除",
taskUndoHint: "削除しました。クリックして元に戻せます。", taskUndoHint: "タスクが削除されました",
listUndoHint: "リスト削除ました。クリックして元に戻せます。", listUndoHint: "リスト削除されました",
// Labs, Kanban & Customization // Labs, Kanban & Customization
labs: "Labs (実験室)", labs: "実験室",
labsDesc: "開発中の実験的機能を個別に有効化・無効化できます。", labsDesc: "実験的機能とUIのカスタマイズ設定です。",
kanbanView: "カンバンボード", kanbanView: "カンバン",
listView: "リストビュー", listView: "リスト",
enableKanban: "カンバンボードビュー", enableKanban: "カンバンボードビュー",
enableKanbanDesc: "タスクを3列ボード(未着手 / 進行中 / 完了)形式で管理します。", enableKanbanDesc: "タスクリストでリストビューと3カラムカンバンビューを切り替えられるようにします。",
swapBlocks: "ブロック配置の入れ替え", swapBlocks: "ブロック順序の入れ替え",
resetDefaults: "初期設定にリセット", resetDefaults: "初期状態に戻す",
resetConfirm: "すべてのUIカスタマイズを初期デフォルト設定に戻しますか?", resetConfirm: "すべてのカスタマイズ設定を初期状態に戻しますか?",
todoCol: "未着手", todoCol: "To Do",
inProgressCol: "進行中", inProgressCol: "進行中",
doneCol: "完了", doneCol: "完了",
accessRestricted: "アクセス権限制限", accessRestricted: "アクセス制限",
adminRequired: "この管理コンソールにはADMIN権限が必要です。", adminRequired: "このコンソールには ADMIN 権限が必要です。",
}; };
+160 -105
View File
@@ -3,109 +3,162 @@ import { TranslationDict } from "../types";
export const ko: TranslationDict = { export const ko: TranslationDict = {
// Auth // Auth
appName: "CheckFlow", appName: "CheckFlow",
tagline: "나만의 스마트 할 일 & 마크다운 워크스페이스", tagline: "TickTick 스타일의 독립형 To-Do & 노트 웹앱",
welcomeBack: "다시 오신 것을 환영합니다 👋", welcomeBack: "다시 오신 것을 환영합니다",
signInSubtitle: "이메일 계정으로 로그인하세요", signInSubtitle: "안전하게 로그인하여 할 일을 관리하세요",
createAccount: "계정 생성", createAccount: "계정 만들기",
createAccountSubtitle: "CheckFlow에서 효율적인 할 일 관리를 시작하세요", createAccountSubtitle: "셀프호스팅 To-Do 플랫폼을 시작하세요",
displayName: "사용자 이름", displayName: "이름",
email: "이메일 주소", email: "이메일",
password: "비밀번호", password: "비밀번호",
min8Chars: "8자 이상 입력", min8Chars: "최소 8자 이상",
signIn: "로그인", signIn: "로그인",
signingIn: "로그인 중...", signingIn: "로그인 중...",
createBtn: "계정 생성", createBtn: "계정 생성",
creatingBtn: "계정 생성 중...", creatingBtn: "계정 생성 중...",
alreadyHaveAccount: "이미 계정이 있으신가요?", alreadyHaveAccount: "이미 계정이 있으신가요? 로그인",
dontHaveAccount: "아직 계정이 없으신가요?", dontHaveAccount: "계정이 없으신가요? 계정 만들기",
tryDemoMode: "체험 모드로 둘러보기 (DB 불필요)", tryDemoMode: "데모 모드로 체험하기 (DB 미연결)",
demoBadge: "체험 모드", demoBadge: "데모 모드",
signOut: "로그아웃", signOut: "로그아웃",
// Sidebar // Sidebar
lists: "목록", lists: "목록",
newList: "새 목록", newList: "새 목록",
listNamePlaceholder: "목록 이름 입력", listNamePlaceholder: "목록 이름 입력...",
create: "생성", create: "생성",
cancel: "취소", cancel: "취소",
save: "저장", save: "저장",
deleteListConfirm: "이 목록을 삭제하시겠습니까? 포함된 모든 할 일도 함께 삭제됩니다.", deleteListConfirm: "이 목록과 포함된 모든 할 일을 삭제하시겠습니까?",
undoDelete: "실행 취소", undoDelete: "실행 취소",
listDeleted: "목록이 삭제되었습니다", listDeleted: "목록이 삭제되었습니다",
importTasks: "가져오기", importTasks: "할 일 가져오기",
importModalTitle: "할 일 가져오기", importModalTitle: "할 일 가져오기 (CSV / ICS)",
targetList: "가져올 대상 목록", targetList: "가져올 대상 목록",
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)", fileSelectLabel: ".csv 또는 .ics 파일 선택 (TickTick 백업 지원)",
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar 선택", tickTickExportHint: "TickTick 내보내기 방법: 설정 → 백업 → CSV 내보내기",
importBtn: "가져오기", importBtn: "가져오기",
importing: "가져오는 중...", importing: "가져오는 중...",
exportTasks: "내보내기", exportTasks: "할 일 내보내기",
exportModalTitle: "할 일 내보내기", exportModalTitle: "할 일 내보내기 (CSV / ICS)",
exportFormat: "내보내기 형식", exportFormat: "파일 포맷",
exportScope: "내보낼 대상 목록", exportScope: "내보낼 대상",
allLists: "모든 목록 (전체 할 일)", allLists: "전체 목록",
includeCompleted: "완료된 항목 포함", includeCompleted: "완료된 작업 포함",
exportBtn: "내보내기 및 다운로드", exportBtn: "내보내기",
exportSuccess: "할 일 성공적으로 내보습니다!", exportSuccess: "할 일 성공적으로 내보내졌습니다",
theme: "테마", theme: "테마",
themeSystem: "시스템 설정 동기화", themeSystem: "시스템",
themeLight: "라이트 모드", themeLight: "라이트",
themeDark: "다크 모드", themeDark: "다크",
language: "언어 (Language)", language: "언어",
tags: "태그", tags: "태그",
trash: "휴지통", trash: "휴지통",
emptyTrash: "휴지통 비우기", emptyTrash: "휴지통 비우기",
searchPlaceholder: "할 일, 메모, 태그 검색...", searchPlaceholder: "할 일 검색...",
searchExpandedPlaceholder: "할 일, 메모, 태그 전체 검색 (Ctrl+K)...", searchExpandedPlaceholder: "작업 제목, 태그, 메모 검색...",
// Settings Modal // Settings Modal - General & Tabs
settingsModalTitle: "환경설정", settingsModalTitle: "설정",
profile: "프로필", profile: "프로필",
preferences: "개인화 설정", preferences: "환경설정",
syncIntegrations: "외부 연동 (CalDAV)", syncIntegrations: "외부 연동",
admin: "관리자 설정", admin: "관리자",
trashRetention: "휴지통 자동 보관 기간", trashRetention: "휴지통 자동 삭제 보관 기간",
trashRetentionHint: "지정한 보관 기간이 경과한 삭제 항목은 자동으로 영구 삭제됩니다.", trashRetentionHint: "삭제된 작업은 지정된 기간이 지나면 영구적으로 자동 삭제됩니다.",
days7: "7일 후 영구 삭제", days7: "7일",
days14: "14일 후 영구 삭제", days14: "14일",
days30: "30일 후 영구 삭제 (권장)", days30: "30일 (권장)",
neverDelete: "자동 삭제 안 함 (수동 삭제)", 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 // CalDAV & Sync
syncTitle: "CalDAV 모바일 및 외부 앱 동기화", syncTitle: "CalDAV 모바일 동기화 (Android / Apple)",
syncDesc: "Android(DAVx⁵, Tasks.org), Apple 미리알림, Thunderbird 등 표준 CalDAV를 지원하는 모든 기기 및 앱과 실시간 양방향으로 동기화니다.", syncDesc: "CheckFlow는 표준 CalDAV 프로토콜을 통해 Android, Apple 미리알림, Thunderbird와의 완전한 양방향 동기화를 지원합니다.",
syncBaseUrl: "CalDAV 기본 엔드포인트 URL", syncBaseUrl: "CalDAV 서버 주소 (Base URL)",
syncCopyUrl: "URL 복사", syncCopyUrl: "URL 복사",
syncCopied: "복사 완료!", syncCopied: "복사!",
syncDownloadIcs: ".ICS 피드 다운로드", syncDownloadIcs: ".ICS 피드 다운로드",
syncTestConnection: "연결 테스트", syncTestConnection: "엔드포인트 연결 테스트",
syncTesting: "연결 확인 중...", syncTesting: "연결 확인 중...",
syncTestSuccess: "CalDAV 엔드포인트가 정상적으로 응답합니다 (200 OK)", syncTestSuccess: "CalDAV 서버가 정상적으로 응답했습니다",
syncTestError: "엔드포인트 테스트 실패",
syncTabAndroid: "Android (DAVx⁵)", syncTabAndroid: "Android (DAVx⁵)",
syncTabApple: "Apple 미리알림 (iOS / macOS)", syncTabApple: "Apple 미리알림",
syncTabThunderbird: "Thunderbird (Desktop)", syncTabThunderbird: "Thunderbird",
syncAndroidStep1: "Google Play Store 또는 F-Droid에서 'DAVx⁵' 앱을 설치합니다.", syncAndroidStep1: "Google Play 스토어 또는 F-Droid에서 DAVx⁵ 앱을 설치합니다.",
syncAndroidStep2: "DAVx⁵ 실행 → 계정 추가(+) → 'URL 및 사용자 이름으로 로그인'을 선택합니다.", syncAndroidStep2: "'URL 및 사용자 이름으로 로그인'을 선택하고 위의 CalDAV URL을 입력합니다.",
syncAndroidStep3: "위 기본 URL을 입력하고, CheckFlow 계정 이메일과 비밀번호를 입력합니다.", syncAndroidStep3: "CheckFlow 이메일 계정과 비밀번호를 입력합니다.",
syncAndroidStep4: "'VTODO (할 일)' 컬렉션을 활성화하면 Tasks.org 또는 OpenTasks 앱과 실시간 동기화됩니다.", syncAndroidStep4: "Tasks.org 또는 OpenTasks 등의 앱을 열어 동기화된 할 일을 관리하세요.",
syncAppleStep1: "기기 설정 → 미리알림 → 계정 → '계정 추가'를 선택합니다.", syncAppleStep1: "시스템 설정(macOS) 또는 설정(iOS/iPadOS) → 인터넷 계정으로 이동합니다.",
syncAppleStep2: "'기타' 선택 → 'CalDAV 계정 추가'를 선택합니다.", syncAppleStep2: "'계정 추가' → 'CalDAV 계정 추가'를 선택합니다.",
syncAppleStep3: "서버 주소에 위 URL을 입력하고, 사용자 이름/비밀번호에 CheckFlow 계정 정보를 입력합니다.", syncAppleStep3: "계정 유형을 '수동'으로 설정하고 위의 CalDAV 서버 주소와 로그인 정보를 입력합니다.",
syncAppleStep4: "'미리알림' 항목을 활성화하고 저장을 누릅니다.", syncAppleStep4: "Apple 미리알림(Reminders) 앱에 작업 목록이 자동으로 동기화됩니다.",
syncThunderbirdStep1: "Thunderbird 실행 → 캘린더 탭으로 이동 → '새 캘린더'를 선택합니다.", syncThunderbirdStep1: "Mozilla Thunderbird를 열고 캘린더 / 작업 탭으로 이동합니다.",
syncThunderbirdStep2: "'네트워크에 저장' 선택 → 형식: 'CalDAV'를 선택합니다.", syncThunderbirdStep2: "캘린더 목록 우클릭 → '새 캘린더' → '네트워크에서'를 선택합니다.",
syncThunderbirdStep3: "위치에 위 기본 URL을 입력하고, 사용자 이름에 CheckFlow 계정 이메일을 입력합니다.", syncThunderbirdStep3: "'CalDAV'를 선택하고 위의 CalDAV 주소와 로그인 정보를 입력합니다.",
syncThunderbirdStep4: "인증 대화상자가 표시되면 비밀번호를 입력하여 완료합니다.", syncThunderbirdStep4: "CheckFlow의 작업 목록이 Thunderbird Tasks에 표시됩니다.",
syncAndroidTip: "DAVx⁵ 계정 설정에서 동기화 주기를 15분으로 설정하면 배터리 소모를 줄이면서 실시간 동기화를 유지할 수 있습니다.",
syncAppleTip: "Nginx/Caddy 등 리버스 프록시 뒤에서 HTTPS를 사용할 경우 올바른 SSL 인증서가 구성되어 있는지 확인하세요.",
syncThunderbirdTip: "Thunderbird 작업 뷰에서 CheckFlow의 우선순위, 마감일, 완료 상태가 그대로 표시됩니다.",
// Task List // Task List
tasks: "할 일", tasks: "할 일",
hideDone: "완료 항목 숨기기", hideDone: "완료항목 숨기기",
showDone: "완료 항목 표시", showDone: "완료항목 표시",
completedSection: "완료된 항목", completedSection: "완료",
addTaskPlaceholder: "새 할 일 추가...", addTaskPlaceholder: "새로운 할 일을 적어볼까요?",
add: "추가", add: "추가",
noTasksYet: "등록된 할 일이 없습니다. 새로운 할 일을 추가해보세요!", noTasksYet: "이 목록에 등록된 할 일이 없습니다",
selectListToStart: "목록을 선택하여 할 일을 확인하세요", selectListToStart: "시작할 목록을 선택하세요",
loading: "불러오는 중...", loading: "불러오는 중...",
today: "오늘", today: "오늘",
tomorrow: "내일", tomorrow: "내일",
@@ -113,69 +166,71 @@ export const ko: TranslationDict = {
// Task Detail // Task Detail
taskTitlePlaceholder: "할 일 제목", taskTitlePlaceholder: "할 일 제목",
priority: "우선순위", priority: "우선순위",
priorityNone: "지정 안 함", priorityNone: "없음",
priorityLow: "낮음", priorityLow: "낮음",
priorityMedium: "보통", priorityMedium: "중간",
priorityHigh: "높음", priorityHigh: "높음",
dueDate: "마감일", dueDate: "마감일",
clear: "초기화", clear: "초기화",
notes: "메모", notes: "메모",
notesPlaceholder: "자유롭게 메모를 작성하세요…\n\n마크다운 문법 지원: **굵게**, *기울임*, # 제목, - 목록, - [ ] 체크박스 등", notesPlaceholder: "상세 내용을 입력하세요 (마크다운 지원)...",
subtasks: "하위 작업", subtasks: "하위작업",
addSubtaskPlaceholder: "하위 작업 추가…", addSubtaskPlaceholder: "하위 작업을 적어보세요...",
deleteTaskConfirm: "이 할 일을 삭제하시겠습니까?", deleteTaskConfirm: "태스크를 삭제하시겠습니까?",
autoSaved: "자동 저장 완료", autoSaved: "저장됨",
saving: "저장 중", saving: "저장 중...",
created: "생성 일시", created: "생성",
edited: "수정 일시", edited: "수정",
previewTab: "미리보기",
editTab: "편집",
// Toolbars // Toolbars
bold: "굵게", bold: "굵게",
italic: "기울임", italic: "기울임",
heading: "제목", heading: "제목",
bulletList: "글머리 기호 목록", bulletList: "글머리 기호",
numberedList: "번호 매기기 목록", numberedList: "번호 목록",
checkbox: "체크박스", checkbox: "체크박스",
code: "코드 블록", code: "코드",
// TickTick Mode Switcher & Quick Add // TickTick Mode Switcher & Quick Add
textMode: "마크다운 메모 모드", textMode: "텍스트 모드",
subtaskMode: "체크리스트 모드", subtaskMode: "하위작업 모드",
switchToTextMode: "마크다운 메모 모드로 전환", switchToTextMode: "텍스트 모드로 전환",
switchToSubtaskMode: "하위 체크리스트 모드로 전환", switchToSubtaskMode: "하위작업 모드로 전환",
moveToList: "다른 목록으로 이동", moveToList: "목록 이동",
quickAdd: "빠른 추가", quickAdd: "빠른 추가",
quickToday: "오늘", quickToday: "오늘",
quickTomorrow: "내일", quickTomorrow: "내일",
quickNextWeek: "다음 주", quickNextWeek: "다음 주",
selectDate: "마감일 지정", selectDate: "날짜 선택",
selectPriority: "우선순위 지정", selectPriority: "우선순위",
selectTag: "태그 지정", selectTag: "태그 선택",
copied: "복사 완료!", copied: "복사",
// Context Menu // Context Menu
editTask: "수정", editTask: "작업 수정",
duplicateTask: "복제", duplicateTask: "복제",
moveTask: "이동", moveTask: "이동...",
deleteTask: "삭제", deleteTask: "삭제",
renameList: "이름 변경", renameList: "목록 이름 변경",
deleteList: "목록 삭제", deleteList: "목록 삭제",
taskUndoHint: "삭제되었습니다. 클릭하여 되돌리기", taskUndoHint: "작업이 삭제되었습니다",
listUndoHint: "목록이 삭제되었습니다. 클릭하여 되돌리기", listUndoHint: "목록이 삭제되었습니다",
// Labs, Kanban & Customization // Labs, Kanban & Customization
labs: "실험실 (Labs)", labs: "실험실",
labsDesc: "실험적 기능들을 개별적으로 활성화하거나 비활성화할 수 있습니다.", labsDesc: "실험적 기능 및 UI 개인화 설정입니다.",
kanbanView: "칸반 보드", kanbanView: "칸반 보드",
listView: "리스트 뷰", listView: "목록 뷰",
enableKanban: "칸반 보드 뷰", enableKanban: "칸반 보드 뷰",
enableKanbanDesc: "할 일을 3컬럼 보드(할 일 / 진행 중 / 완료) 형태로 전환하여 관리합니다.", enableKanbanDesc: "목록에서 리스트 뷰와 3컬럼 칸반 보드 뷰 간을 자유롭게 전환합니다.",
swapBlocks: "상하 블록 위치 변경", swapBlocks: "블록 순서 변경",
resetDefaults: "기본값으로 복원", resetDefaults: "기본값 복원",
resetConfirm: "모든 UI 커스텀 설정을 초기 기본값으로 복원하시겠습니까?", resetConfirm: "모든 커스터마이징 설정을 초기 상태로 되돌리시겠습니까?",
todoCol: "할 일", todoCol: "할 일",
inProgressCol: "진행 중", inProgressCol: "진행 중",
doneCol: "완료", doneCol: "완료",
accessRestricted: "접근 권한 제한", accessRestricted: "접근 제한",
adminRequired: "이 관리 콘솔은 ADMIN 권한이 필요합니다.", adminRequired: "이 콘솔은 ADMIN 권한이 필요합니다.",
}; };
+56 -1
View File
@@ -58,7 +58,7 @@ export interface TranslationDict {
searchPlaceholder: string; searchPlaceholder: string;
searchExpandedPlaceholder: string; searchExpandedPlaceholder: string;
// Settings Modal // Settings Modal - General & Tabs
settingsModalTitle: string; settingsModalTitle: string;
profile: string; profile: string;
preferences: string; preferences: string;
@@ -70,6 +70,55 @@ export interface TranslationDict {
days14: string; days14: string;
days30: string; days30: string;
neverDelete: 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 // CalDAV & Sync
syncTitle: string; syncTitle: string;
@@ -81,6 +130,7 @@ export interface TranslationDict {
syncTestConnection: string; syncTestConnection: string;
syncTesting: string; syncTesting: string;
syncTestSuccess: string; syncTestSuccess: string;
syncTestError: string;
syncTabAndroid: string; syncTabAndroid: string;
syncTabApple: string; syncTabApple: string;
syncTabThunderbird: string; syncTabThunderbird: string;
@@ -96,6 +146,9 @@ export interface TranslationDict {
syncThunderbirdStep2: string; syncThunderbirdStep2: string;
syncThunderbirdStep3: string; syncThunderbirdStep3: string;
syncThunderbirdStep4: string; syncThunderbirdStep4: string;
syncAndroidTip: string;
syncAppleTip: string;
syncThunderbirdTip: string;
// Task List // Task List
tasks: string; tasks: string;
@@ -128,6 +181,8 @@ export interface TranslationDict {
saving: string; saving: string;
created: string; created: string;
edited: string; edited: string;
previewTab: string;
editTab: string;
// Toolbars // Toolbars
bold: string; bold: string;
+79 -24
View File
@@ -258,11 +258,18 @@ export function updateTaskInTree(tree: MockTask[], updated: MockTask): MockTask[
}); });
} }
// Soft delete to Trash // Soft delete to Trash (recursively mark node and its children)
export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] { 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) => { return tree.map((node) => {
if (node.id === id) { if (node.id === id) {
return { ...node, isDeleted: true, deletedAt: new Date().toISOString() }; return markDeleted(node);
} }
if (node.children && node.children.length > 0) { if (node.children && node.children.length > 0) {
return { ...node, children: moveToTrashInTree(node.children, id) }; return { ...node, children: moveToTrashInTree(node.children, id) };
@@ -271,14 +278,65 @@ export function moveToTrashInTree(tree: MockTask[], id: string): MockTask[] {
}); });
} }
// Restore from Trash // Restore task (and all its subtasks) from Trash, ensuring ancestor parents are also un-deleted
export function restoreTaskInTree(tree: MockTask[], id: string): MockTask[] { export function restoreTaskInTree(tree: MockTask[], targetId: string): MockTask[] {
return tree.map((node) => { const unmarkNodeAndChildren = (node: MockTask): MockTask => ({
if (node.id === id) { ...node,
return { ...node, isDeleted: false, deletedAt: null }; 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) { if (node.children && node.children.length > 0) {
return { ...node, children: restoreTaskInTree(node.children, id) }; 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 });
}
}
}
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; return node;
}); });
@@ -286,14 +344,7 @@ export function restoreTaskInTree(tree: MockTask[], id: string): MockTask[] {
// Permanent delete // Permanent delete
export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask[] { export function deleteTaskInTree(tree: MockTask[], idToDelete: string): MockTask[] {
return tree return removeTaskFromTree(tree, idToDelete);
.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[] { export function emptyTrashInTree(tree: MockTask[]): MockTask[] {
@@ -334,25 +385,24 @@ export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
return null; return null;
} }
// Get all active tasks or all trash tasks flattened (strictly deduplicated) // Get all top-level trash tasks preserving intact nested sub-tasks hierarchy
export function getAllTrashTasks(tree: MockTask[]): MockTask[] { export function getAllTrashTasks(tree: MockTask[]): MockTask[] {
const map = new Map<string, MockTask>(); const result: MockTask[] = [];
function collect(nodes: MockTask[]) { function collect(nodes: MockTask[]) {
for (const node of nodes) { for (const node of nodes) {
if (node.isDeleted) { if (node.isDeleted) {
if (!map.has(node.id)) { // Collect top-level deleted item with its full subtasks tree intact
map.set(node.id, { ...node, children: [] }); result.push(node);
} } else if (node.children && node.children.length > 0) {
} // If parent is not deleted, check if any subtask was individually deleted
if (node.children && node.children.length > 0) {
collect(node.children); collect(node.children);
} }
} }
} }
collect(tree); collect(tree);
return Array.from(map.values()); return result;
} }
// Filter tasks by custom tag // Filter tasks by custom tag
@@ -368,3 +418,8 @@ export function filterTasksByTag(tree: MockTask[], tagName: string): MockTask[]
} }
return matched; 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;
}