From 0f44090210f0967e69a43d86edbecc4c65781fd0 Mon Sep 17 00:00:00 2001 From: Neru_Han Date: Sat, 22 Aug 2026 09:08:10 +0900 Subject: [PATCH] feat(ux): enhance n-depth tree DND reordering, full i18n localization, admin live stats & sidebar redesign --- .cline-context.md | 6 + AGENTS.md | 3 +- src/app/admin/page.tsx | 313 +++++++++++++------- src/app/api/admin/stats/route.ts | 41 +++ src/app/api/admin/users/route.ts | 94 ++++++ src/app/api/tasks/[id]/route.ts | 33 ++- src/components/layout/AppShell.tsx | 20 +- src/components/layout/Sidebar.tsx | 8 +- src/components/settings/SettingsModal.tsx | 189 ++++++------ src/components/tasks/MarkdownNoteEditor.tsx | 32 +- src/components/tasks/TaskDetail.tsx | 83 ++++-- src/components/tasks/TaskList.tsx | 296 +++++++++++------- src/lib/i18n/index.tsx | 33 ++- src/lib/i18n/locales/en.ts | 203 ++++++++----- src/lib/i18n/locales/ja.ts | 269 ++++++++++------- src/lib/i18n/locales/ko.ts | 267 ++++++++++------- src/lib/i18n/types.ts | 57 +++- src/lib/mockData.ts | 11 +- 18 files changed, 1307 insertions(+), 651 deletions(-) create mode 100644 src/app/api/admin/stats/route.ts create mode 100644 src/app/api/admin/users/route.ts diff --git a/.cline-context.md b/.cline-context.md index f2cc331..22a0889 100644 --- a/.cline-context.md +++ b/.cline-context.md @@ -15,6 +15,10 @@ - `src/middleware.ts`: Route-level auth and admin role verification guard. - **Completed So Far:** + - Standardized complete localization across all 3 supported languages (EN, KO, JA) without partial fallback leaks; translated all Settings Modal tabs (Profile, Preferences, Labs, Sync Integrations, Admin) and interactive guides (v0.7.1). + - Resolved list header rename layout shift bug where editing list title pushed the completed items toggle button (v0.7.1). + - Enhanced Markdown Note Editor preview/editor canvas alignment and unified description typography heights (v0.7.1). + - Standardized Task Detail priority picker into a sleek custom popover chip matching the central quick-add toolbar (v0.7.1). - Rewrote entire Git commit history with active local Git identity across all branches and tags, and force-pushed to self-hosted Gitea remote origin. - Conducted full audit of codebase against all specification items in `AGENTS.md`. - Verified 100% implementation of core features: n-depth recursive checklist, real-time subtask synchronization, modular blocks & split resizer, 3-way theme (VSCode dark / pastel light / system), global `UserPrefs` customization, Labs Kanban view, and mobile bottom sheet. @@ -36,6 +40,8 @@ - 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. - **Next Steps (Todo):** diff --git a/AGENTS.md b/AGENTS.md index ac8c752..470ed79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,8 @@ src/ | `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 | | `checkpoint-v0.6.3` | 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 | | `checkpoint-v0.6.4` | i18n 모듈러 사전 아키텍처(`types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`) 분리 및 번역 완성도 100%, 어드민 콘솔(`/admin`) 데모 프리뷰 및 멀티유저 게이트 연동, 설정 모달 Admin 탭 정보 고도화, 사이드바 프로필 메뉴 내 Admin Console 진입점 추가 | 2026-08-21 | -| `checkpoint-v0.7.0` | **(현재 최신)** 좌측 사이드바 UI/UX 전면 리디자인(브랜드 로고 SVG, 중앙정렬 매트 검색창, 바닥 밀착 프로필), 뷰 전환(리스트/칸반) 미니멀 원터치 토글 및 실험실 연동 버그 수정, 휴지통 카운트 실시간 동기화 & 기본화면 튕김 버그 수정, 첫 가입 계정 자동 ADMIN 승격, 서브태스크 인라인 추가 및 체크박스 Optimistic UI 실시간 렌더링, 메모장 기본 Preview 모드 & 원터치 토글, N-depth 계층형 DND 드래그 앤 드롭 재정렬 지원 | 2026-08-21 | +| `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 | --- diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 853f93e..330096f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useSession } from "next-auth/react"; import { getDemoStore } from "@/lib/mockData"; @@ -15,7 +15,7 @@ interface AdminUser { active: boolean; } -const INITIAL_ADMIN_USERS: AdminUser[] = [ +const INITIAL_DEMO_USERS: AdminUser[] = [ { id: "user-1", name: "Admin Master", @@ -49,27 +49,18 @@ export default function AdminPage() { const { data: session, status } = useSession(); const { t } = useI18n(); - const [users, setUsers] = useState(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 [users, setUsers] = useState(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 userEmail = session?.user?.email; const isAuthorized = @@ -79,7 +70,49 @@ export default function AdminPage() { userEmail?.startsWith("admin@") || userEmail === "admin@checkflow.local"; - // 로딩 중 + const fetchAdminData = useCallback(async () => { + if (isDemo) { + if (typeof window !== "undefined") { + const store = getDemoStore(); + setStats({ + totalUsers: INITIAL_DEMO_USERS.length, + totalLists: store.lists.length, + totalTasks: store.tasks.length, + caldavStatus: "Active", + }); + setUsers(INITIAL_DEMO_USERS); + } + return; + } + + setLoadingUsers(true); + try { + const [usersRes, statsRes] = await Promise.all([ + fetch("/api/admin/users"), + fetch("/api/admin/stats"), + ]); + + if (usersRes.ok) { + const uData = await usersRes.json(); + setUsers(uData); + } + if (statsRes.ok) { + const sData = await statsRes.json(); + setStats(sData); + } + } catch (err) { + console.error("Failed to load admin data:", err); + } finally { + setLoadingUsers(false); + } + }, [isDemo]); + + useEffect(() => { + if (status !== "loading") { + fetchAdminData(); + } + }, [status, fetchAdminData]); + if (status === "loading") { return (
@@ -114,10 +147,27 @@ export default function AdminPage() { ); } - const toggleRole = (id: string) => { - setUsers((prev) => - prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u)) - ); + const toggleRole = async (user: AdminUser) => { + const newRole = user.role === "ADMIN" ? "USER" : "ADMIN"; + if (isDemo) { + setUsers((prev) => + prev.map((u) => (u.id === user.id ? { ...u, role: newRole } : u)) + ); + return; + } + + try { + const res = await fetch("/api/admin/users", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: user.id, role: newRole }), + }); + if (res.ok) { + fetchAdminData(); + } + } catch (err) { + console.error("Failed to toggle role:", err); + } }; const toggleStatus = (id: string) => { @@ -126,9 +176,26 @@ export default function AdminPage() { ); }; - const deleteUser = (id: string) => { - if (confirm("Are you sure you want to delete this user and all their tasks?")) { - setUsers((prev) => prev.filter((u) => u.id !== id)); + const deleteUser = async (user: AdminUser) => { + if (!confirm(`Are you sure you want to delete ${user.name} (${user.email}) and all their tasks?`)) { + return; + } + + if (isDemo) { + setUsers((prev) => prev.filter((u) => u.id !== user.id)); + return; + } + + try { + const res = await fetch(`/api/admin/users?userId=${user.id}`, { method: "DELETE" }); + if (res.ok) { + fetchAdminData(); + } else { + const data = await res.json(); + alert(data.error || "Failed to delete user"); + } + } catch (err) { + console.error("Failed to delete user:", err); } }; @@ -149,10 +216,14 @@ export default function AdminPage() {

CheckFlow Admin Console

- {isDemo && ( + {isDemo ? ( Demo Preview + ) : ( + + Live Instance + )}

@@ -162,9 +233,15 @@ export default function AdminPage() {

- - ← Back to Demo - + {isDemo ? ( + + ← Back to Demo + + ) : ( + + )} 🚀 Open Dashboard @@ -175,25 +252,25 @@ export default function AdminPage() {
Total Users
-
{users.length}
+
{stats.totalUsers}
● 100% active instances
Total Projects / Lists
-
{totalLists}
+
{stats.totalLists}
Across all users
Total Tasks & Notes
-
{totalTasks}
+
{stats.totalTasks}
Recursive subtasks included
CalDAV / DAVx⁵ Status
-
Active
+
{stats.caldavStatus}
● Basic Auth Bypass Verified
@@ -218,85 +295,91 @@ export default function AdminPage() { {/* Users Table */}
- - - - - - - - - - - - - {filteredUsers.map((u) => ( - - - - - - - + {loadingUsers ? ( +
+ {t("loading")} +
+ ) : ( +
UserRoleJoined DateTasksStatusActions
-
{u.name}
-
{u.email}
-
- - {u.role} - - - {new Date(u.createdAt).toLocaleDateString()} - - {u.taskCount} tasks - - - {u.active ? "● Active" : "○ Inactive"} - - -
- - - -
-
+ + + + + + + + - ))} - -
UserRoleJoined DateTasksStatusActions
+ + + {filteredUsers.map((u) => ( + + +
{u.name}
+
{u.email}
+ + + + {u.role} + + + + {new Date(u.createdAt).toLocaleDateString()} + + + {u.taskCount} tasks + + + + {u.active ? "● Active" : "○ Inactive"} + + + +
+ + + +
+ + + ))} + + + )}
diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts new file mode 100644 index 0000000..712ee75 --- /dev/null +++ b/src/app/api/admin/stats/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..e5b0ad9 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -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 }); + } +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index ee87bbf..70ef9d6 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -116,26 +116,27 @@ export async function DELETE(req: NextRequest, { params }: Ctx) { return NextResponse.json({ ok: true }); } - // Soft delete task and its children + // Soft delete task and all its descendants recursively const now = new Date(); - await prisma.task.update({ - where: { id }, + const collectDescendantIds = async (parentIds: string[]): Promise => { + 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, }); - // Recursively soft-delete children - const softDeleteChildren = async (parentId: string) => { - const children = await prisma.task.findMany({ where: { parentId } }); - for (const child of children) { - await prisma.task.update({ - where: { id: child.id }, - data: { isDeleted: true, deletedAt: now } as any, - }); - await softDeleteChildren(child.id); - } - }; - await softDeleteChildren(id); - return NextResponse.json({ ok: true, softDeleted: true }); } catch (err) { console.error("[tasks/id:DELETE]", err); diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index b24e83b..e4d65be 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -21,6 +21,7 @@ import { getAllTrashTasks, filterTasksByTag, filterActiveTree, + removeTaskFromTree, } from "@/lib/mockData"; interface AppShellProps { @@ -342,7 +343,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { const [undoToast, setUndoToast] = useState<{ task: Task; title: string } | null>(null); const undoTimerRef = useRef(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) => { if (undoTimerRef.current) clearTimeout(undoTimerRef.current); @@ -361,15 +362,26 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { } if (selectedTask?.id === task.id) setSelectedTask(null); } 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); - 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(() => { setUndoToast(null); }, 6000); - }, [isDemo, selectedTask]); + }, [isDemo, selectedTask, isTrashActive, selectedListId, selectedTag, showCompleted]); // Undo delete (Restores exact soft-deleted task hierarchy) const handleUndoDelete = useCallback(async () => { diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index c31627a..7f9602c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -75,7 +75,7 @@ export function Sidebar({ const [exportListId, setExportListId] = useState("all"); const [exportIncludeCompleted, setExportIncludeCompleted] = useState(true); const [exporting, setExporting] = useState(false); - const [tags, setTags] = useState>(() => (typeof window !== "undefined" ? getCustomTags() : [])); + const [tags, setTags] = useState>(() => (isDemo && typeof window !== "undefined" ? getCustomTags() : [])); const [internalTrashCount, setInternalTrashCount] = useState(0); const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount; @@ -121,8 +121,12 @@ export function Sidebar({ .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)); + .catch((err) => { + console.error("Failed to load tags", err); + setTags([]); + }); // Load trash count fetch("/api/tasks?countTrash=true") diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index cf3fe35..244fcde 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -131,8 +131,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting const { prefs, updatePrefs, resetToDefaults } = useUserPrefs(); const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile"); - const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "Demo User")); - const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "demo@checkflow.local")); + const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "User")); + const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "user@checkflow.local")); const [password, setPassword] = useState(""); const [trashRetention, setTrashRetention] = useState(() => (typeof window !== "undefined" ? (getUserSettings().trashRetentionDays ?? 30) : 30)); const [savedMsg, setSavedMsg] = useState(""); @@ -143,8 +143,8 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting useEffect(() => { if (isOpen) { const s = getUserSettings(); - setDisplayName(user.name || s.displayName); - setEmail(user.email || s.email); + setDisplayName(user.name || s.displayName || "User"); + setEmail(user.email || s.email || "user@checkflow.local"); setTrashRetention(s.trashRetentionDays ?? 30); } }, [isOpen, user]); @@ -177,7 +177,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting language: lang, }; saveUserSettings(newSettings); - setSavedMsg("✓ " + (lang === "ko" ? "설정이 저장되었습니다" : lang === "ja" ? "設定が保存されました" : "Settings saved")); + setSavedMsg("✓ " + t("settingsSaved")); setTimeout(() => { setSavedMsg(""); onClose(); @@ -192,11 +192,11 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting const accentPreview = `hsl(${hue}, ${sat}%, 54%)`; const tabs = [ - { id: "profile", label: `👤 ${t("profile") || "Profile"}` }, - { id: "preferences", label: `⚙️ ${t("preferences") || "Preferences"}` }, - { id: "labs", label: `🧪 ${t("labs") || "Labs"}` }, - { id: "sync", label: `📱 ${t("syncIntegrations") || "Integrations"}` }, - { id: "admin", label: `👑 ${t("admin") || "Admin"}` }, + { id: "profile", label: `👤 ${t("profile")}` }, + { id: "preferences", label: `⚙️ ${t("preferences")}` }, + { id: "labs", label: `🧪 ${t("labs")}` }, + { id: "sync", label: `📱 ${t("syncIntegrations")}` }, + { id: "admin", label: `👑 ${t("admin")}` }, ] as const; return ( @@ -208,7 +208,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting > {/* Header */}
-

⚙️ {t("settingsModalTitle") || "Settings"}

+

⚙️ {t("settingsModalTitle")}

@@ -255,15 +255,32 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
- setDisplayName(e.target.value)} placeholder="Your Name" /> + setDisplayName(e.target.value)} + placeholder={t("yourNamePlaceholder")} + />
- setEmail(e.target.value)} placeholder="your.email@example.com" /> + setEmail(e.target.value)} + placeholder="your.email@example.com" + />
- - setPassword(e.target.value)} placeholder="New password (leave blank to keep current)" /> + + setPassword(e.target.value)} + placeholder={t("passwordChangePlaceholder")} + />
)} @@ -273,16 +290,16 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting

- {t("trashRetentionHint") || "Deleted tasks will be permanently removed after the specified period."} + {t("trashRetentionHint")}

@@ -320,46 +337,46 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
{/* Section: View */}
- View + {t("labsSectionView")}
updatePrefs({ viewMode: v })} + onChange={(v) => updatePrefs({ labs: { kanbanBoard: v === "enabled" } })} /> {/* Section: Layout */}
- Layout + {t("labsSectionLayout")}
updatePrefs({ density: v })} /> - Appearance + {t("labsSectionAppearance")}
updatePrefs({ fontSize: v })} />
{/* Hue ring preview */} @@ -431,7 +448,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting ))}
- Saturation + {t("saturationLabel")} {/* Custom hue input */}
- Hue ° + {t("hueLabel")} updatePrefs({ roundness: v })} /> updatePrefs({ animationSpeed: v })} /> @@ -490,20 +507,20 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting {/* Reset to Defaults */}
@@ -514,15 +531,15 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting {activeTab === "sync" && (

- 📱 {t("syncTitle") || "CalDAV & Mobile Sync (Android / Apple)"} + 📱 {t("syncTitle")}

- {t("syncDesc") || "CheckFlow supports native two-way synchronization with Android, Apple Reminders, and Thunderbird via standard CalDAV."} + {t("syncDesc")}

{/* Endpoint bar & Action buttons */}
- +
- {copiedCalDav ? `✓ ${t("syncCopied") || "Copied!"}` : `📋 ${t("syncCopyUrl") || "Copy"}`} + {copiedCalDav ? `✓ ${t("syncCopied")}` : `📋 ${t("syncCopyUrl")}`}
@@ -555,7 +572,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting disabled={testSyncStatus === "testing"} style={{ display: "inline-flex", alignItems: "center", gap: 6 }} > - {testSyncStatus === "testing" ? `⏳ ${t("syncTesting") || "Testing..."}` : `🔍 ${t("syncTestConnection") || "Test Endpoint"}`} + {testSyncStatus === "testing" ? `⏳ ${t("syncTesting")}` : `🔍 ${t("syncTestConnection")}`} - 📥 {t("syncDownloadIcs") || "Download .ICS Feed"} + 📥 {t("syncDownloadIcs")} {testSyncStatus === "success" && ( - {t("syncTestSuccess") || "✓ CalDAV endpoint responded successfully"} + ✓ {t("syncTestSuccess")} )} {testSyncStatus === "error" && ( - ✕ Endpoint test failed + ✕ {t("syncTestError")} )}
@@ -583,9 +600,9 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting setSyncPlatform(v)} /> @@ -615,7 +632,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
  • {t("syncAndroidStep4")}
  • - 💡 Tip: In DAVx⁵ account settings, set Sync Interval to 15 minutes for battery efficiency and near real-time sync. + 💡 Tip: {t("syncAndroidTip")}
    )} @@ -632,7 +649,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
  • {t("syncAppleStep4")}
  • - 💡 Tip: If using HTTPS behind a reverse proxy (Nginx/Caddy), ensure valid SSL certificates are trusted by Apple devices. + 💡 Tip: {t("syncAppleTip")}
    )} @@ -649,7 +666,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
  • {t("syncThunderbirdStep4")}
  • - 💡 Tip: Thunderbird Tasks view will display CheckFlow priority tags, due dates, and completion status. + 💡 Tip: {t("syncThunderbirdTip")}
    )} @@ -664,7 +681,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting 👑

    - {t("admin") || "Admin Console"} + {t("admin")}

    Multi-user administration, account control, and system telemetry @@ -685,7 +702,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting >

    - Current Account:{" "} + {t("adminCurrentAccount")}:{" "} {user.email || "demo@checkflow.local"} @@ -709,12 +726,12 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
    -
    Isolation Mode
    -
    ● Multi-User Privacy
    +
    {t("adminIsolationMode")}
    +
    ● {t("adminMultiUserPrivacy")}
    -
    CalDAV Endpoint
    -
    ● Active / Enabled
    +
    {t("adminCalDavEndpoint")}
    +
    ● {t("adminActiveEnabled")}
    @@ -725,7 +742,7 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting className="btn btn-primary" style={{ display: "inline-flex", alignItems: "center", gap: 8, textDecoration: "none" }} > - 🚀 Open Admin Dashboard → + 🚀 {t("adminOpenDashboard")}
    @@ -741,11 +758,11 @@ export function SettingsModal({ isOpen, onClose, user, isDemo = false }: Setting
    ); -} \ No newline at end of file +} diff --git a/src/components/tasks/MarkdownNoteEditor.tsx b/src/components/tasks/MarkdownNoteEditor.tsx index 8d6e3e3..477db2d 100644 --- a/src/components/tasks/MarkdownNoteEditor.tsx +++ b/src/components/tasks/MarkdownNoteEditor.tsx @@ -17,6 +17,11 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit const textareaRef = useRef(null); const previewRef = useRef(null); + // Reset to preview whenever value or active task changes + useEffect(() => { + // Keep preview mode as primary + }, []); + // Configure marked to open links in new tabs safely useEffect(() => { const renderer = new marked.Renderer(); @@ -49,7 +54,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit // Convert markdown to sanitized HTML with safe links const renderMarkdownHtml = () => { if (!value || !value.trim()) { - return `

    ${t("notesPlaceholder").split("\n")[0]}

    `; + return `
    ${t("notesPlaceholder")}
    `; } try { @@ -96,6 +101,7 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit alignItems: "center", justifyContent: "space-between", padding: "4px 8px 8px 8px", + flexShrink: 0, }} > @@ -133,7 +139,19 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit {/* Editor / Preview Area */} -
    +
    {mode === "edit" ? (