feat: initial commit

This commit is contained in:
2026-08-21 15:22:17 +09:00
commit c65fff4dfd
94 changed files with 20244 additions and 0 deletions
+542
View File
@@ -0,0 +1,542 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { Sidebar } from "./Sidebar";
import { TaskList, Task, List, User } from "../tasks/TaskList";
import { useUserPrefs, usePrefsStyle } from "@/lib/useUserPrefs";
import { TaskDetail } from "../tasks/TaskDetail";
import { CommandPalette } from "../ui/CommandPalette";
import {
getDemoStore,
saveDemoStore,
MockList,
MockTask,
MockTag,
updateTaskInTree,
moveToTrashInTree,
restoreTaskInTree,
deleteTaskInTree,
emptyTrashInTree,
addTaskToTree,
findTaskInTree,
getAllTrashTasks,
filterTasksByTag,
} from "@/lib/mockData";
interface AppShellProps {
user: User;
isDemo?: boolean;
}
export function AppShell({ user, isDemo = false }: AppShellProps) {
const [lists, setLists] = useState<List[]>(() => {
if (isDemo && typeof window !== "undefined") {
return getDemoStore().lists as List[];
}
return [];
});
const [selectedListId, setSelectedListId] = useState<string | null>(() => {
if (isDemo && typeof window !== "undefined") {
const storeLists = getDemoStore().lists;
return storeLists.length > 0 ? storeLists[0].id : null;
}
return null;
});
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [isTrashActive, setIsTrashActive] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [tasks, setTasks] = useState<Task[]>(() => {
if (isDemo && typeof window !== "undefined") {
const store = getDemoStore();
const firstListId = store.lists[0]?.id;
return (store.tasks as Task[]).filter((t) => !t.isDeleted && t.listId === firstListId && !t.completed);
}
return [];
});
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
// Global user preferences (reactive)
const { prefs, updatePrefs } = useUserPrefs();
const prefsStyle = usePrefsStyle(prefs);
// Sidebar resize drag
const sidebarResizing = useRef(false);
const handleSidebarResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
sidebarResizing.current = true;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMove = (ev: MouseEvent) => {
if (!sidebarResizing.current) return;
const newW = Math.max(160, Math.min(420, ev.clientX));
updatePrefs({ sidebarWidth: newW });
};
const onUp = () => {
sidebarResizing.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
// Detail panel resize drag
const detailResizing = useRef(false);
const handleDetailResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
detailResizing.current = true;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMove = (ev: MouseEvent) => {
if (!detailResizing.current) return;
const newW = Math.max(300, Math.min(760, window.innerWidth - ev.clientX));
updatePrefs({ detailWidth: newW });
};
const onUp = () => {
detailResizing.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
// Listen for Ctrl+K event
useEffect(() => {
const handler = () => setCmdPaletteOpen(true);
document.addEventListener("checkflow:openCommandPalette", handler);
return () => document.removeEventListener("checkflow:openCommandPalette", handler);
}, []);
// Demo tasks filter & reconstruct hierarchical structure
useEffect(() => {
if (isDemo) {
const store = getDemoStore();
setLists(store.lists);
if (isTrashActive) {
setTasks(getAllTrashTasks(store.tasks) as Task[]);
} else if (selectedTag) {
setTasks(filterTasksByTag(store.tasks, selectedTag) as Task[]);
} else if (selectedListId) {
const listTasks = (store.tasks as Task[]).filter(
(t) => !t.isDeleted && t.listId === selectedListId && (showCompleted ? true : !t.completed)
);
setTasks(listTasks);
}
}
}, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const handleTaskSelect = useCallback((task: Task | null) => {
setSelectedTask(task);
}, []);
// Update a task in full tree (supports unlimited N-depth nesting)
const handleTaskUpdate = useCallback((updated: Task) => {
setTasks((prev) => updateTaskInTree(prev as MockTask[], updated as MockTask) as Task[]);
setSelectedTask((prev) => {
if (prev && prev.id === updated.id) {
return { ...updated, children: updated.children || prev.children || [] };
}
return prev;
});
}, []);
const handleListSelect = useCallback((id: string) => {
setIsTrashActive(false);
setSelectedTag(null);
setSelectedListId(id);
setSelectedTask(null);
setSidebarOpen(false);
}, []);
const handleTagSelect = useCallback((tagName: string | null) => {
setIsTrashActive(false);
setSelectedTag(tagName);
setSelectedTask(null);
setSidebarOpen(false);
}, []);
const handleTrashSelect = useCallback(() => {
setIsTrashActive(true);
setSelectedTag(null);
setSelectedListId(null);
setSelectedTask(null);
setSidebarOpen(false);
}, []);
// Update List Name
const handleUpdateListName = async (id: string, newName: string) => {
const trimmed = newName.trim();
if (!trimmed) return;
if (isDemo) {
const store = getDemoStore();
const newLists = store.lists.map((l) => (l.id === id ? { ...l, name: trimmed } : l));
saveDemoStore(newLists, store.tasks);
setLists(newLists);
return;
}
try {
const res = await fetch(`/api/lists/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (res.ok) {
const updated = await res.json();
setLists((prev) => prev.map((l) => (l.id === id ? { ...l, name: updated.name } : l)));
}
} catch (err) {
console.error("Failed to update list name", err);
}
};
// Demo Handlers
const handleDemoCreateList = (name: string, color: string) => {
const newList: MockList = {
id: "demo-list-" + Date.now(),
name,
color,
icon: "list",
};
const store = getDemoStore();
const newLists = [...store.lists, newList];
saveDemoStore(newLists, store.tasks);
setLists(newLists);
setSelectedListId(newList.id);
};
const handleDemoDeleteList = (id: string) => {
const store = getDemoStore();
const newLists = store.lists.filter((l) => l.id !== id);
const newTasks = store.tasks.filter((t) => t.listId !== id);
saveDemoStore(newLists, newTasks);
setLists(newLists);
if (selectedListId === id) {
setSelectedListId(newLists[0]?.id || null);
}
refresh();
};
const handleDemoAddTask = (
title: string,
listId: string,
parentId: string | null = null,
meta?: { dueDate?: string | null; priority?: number; tags?: { tag: MockTag }[] }
) => {
const newTask: Task = {
id: "demo-task-" + Date.now(),
listId,
parentId: parentId || null,
title,
note: null,
completed: false,
completedAt: null,
dueDate: meta?.dueDate || null,
priority: meta?.priority || 0,
sortOrder: tasks.length,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: meta?.tags || [],
};
const store = getDemoStore();
const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask);
saveDemoStore(store.lists, newTasks);
setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]);
if (selectedTask && selectedTask.id === parentId) {
setSelectedTask((prev) =>
prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev
);
}
refresh();
};
const handleDemoToggleTask = (id: string, completed: boolean) => {
const store = getDemoStore();
const target = findTaskInTree(store.tasks, id);
if (target) {
const updated = {
...target,
completed,
completedAt: completed ? new Date().toISOString() : null,
};
const newTasks = updateTaskInTree(store.tasks, updated);
saveDemoStore(store.lists, newTasks);
setTasks((prev) =>
(updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed)
);
if (selectedTask) {
const latestSelected = findTaskInTree(newTasks, selectedTask.id);
if (latestSelected) {
setSelectedTask(latestSelected as Task);
}
}
}
refresh();
};
const handleDemoUpdateTask = (updated: Task) => {
const store = getDemoStore();
const newTasks = updateTaskInTree(store.tasks, updated as MockTask);
saveDemoStore(store.lists, newTasks);
handleTaskUpdate(updated);
};
// Move to Trash (Soft Delete)
const handleDemoDeleteTask = (id: string) => {
const store = getDemoStore();
const newTasks = moveToTrashInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id));
if (selectedTask?.id === id) {
setSelectedTask(null);
}
refresh();
};
// Restore from Trash
const handleRestoreTask = (id: string) => {
if (isDemo) {
const store = getDemoStore();
const newTasks = restoreTaskInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id));
refresh();
}
};
// Permanent Delete
const handlePermanentDeleteTask = (id: string) => {
if (isDemo) {
const store = getDemoStore();
const newTasks = deleteTaskInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id));
refresh();
}
};
// Empty Trash
const handleEmptyTrash = () => {
if (!confirm("Permanently empty all items in trash?")) return;
if (isDemo) {
const store = getDemoStore();
const newTasks = emptyTrashInTree(store.tasks);
saveDemoStore(store.lists, newTasks);
setTasks([]);
refresh();
}
};
const handleUpdateTaskTitle = async (taskId: string, newTitle: string) => {
const trimmed = newTitle.trim();
if (!trimmed) return;
if (isDemo) {
const store = getDemoStore();
const target = findTaskInTree(store.tasks, taskId);
if (target) {
const updated = { ...target, title: trimmed };
const newTasks = updateTaskInTree(store.tasks, updated);
saveDemoStore(store.lists, newTasks);
handleTaskUpdate(updated as Task);
}
return;
}
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: trimmed }),
});
if (res.ok) {
const updated = await res.json();
handleTaskUpdate(updated);
}
} catch (err) {
console.error("Failed to update task title", err);
}
};
return (
<div className="app-layout">
{/* Dynamic CSS variable injection from user prefs */}
<style>{prefsStyle}</style>
{/* Sidebar 모바일 오버레이 */}
{sidebarOpen && (
<div
className="modal-overlay"
style={{ zIndex: 25 }}
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 바텀시트 모바일 오버레이 — 태스크 상세 패널이 열릴 때 */}
{selectedTask && (
<div
className="bottom-sheet-overlay"
onClick={() => setSelectedTask(null)}
/>
)}
<Sidebar
user={user}
lists={lists}
setLists={setLists}
selectedListId={selectedListId}
onListSelect={handleListSelect}
selectedTag={selectedTag}
onTagSelect={handleTagSelect}
isTrashActive={isTrashActive}
onTrashSelect={handleTrashSelect}
mobileOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
isDemo={isDemo}
onDemoCreateList={handleDemoCreateList}
onDemoDeleteList={handleDemoDeleteList}
/>
{/* Sidebar resize handle */}
<div
className="sidebar-resize-handle"
onMouseDown={handleSidebarResizeStart}
title="Drag to resize sidebar"
style={{
width: 4,
cursor: "col-resize",
background: "transparent",
flexShrink: 0,
transition: "background var(--dur-fast)",
position: "relative",
zIndex: 10,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
/>
<div className="main-content">
<TaskList
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
user={user}
listId={selectedListId}
lists={lists}
tasks={tasks}
setTasks={setTasks}
selectedTaskId={selectedTask?.id ?? null}
onTaskSelect={handleTaskSelect}
showCompleted={showCompleted}
onToggleCompleted={() => setShowCompleted((p) => !p)}
onMenuOpen={() => setSidebarOpen(true)}
onRefresh={refresh}
isDemo={isDemo}
isTrashActive={isTrashActive}
selectedTag={selectedTag}
onEmptyTrash={handleEmptyTrash}
onRestoreTask={handleRestoreTask}
onPermanentDeleteTask={handlePermanentDeleteTask}
onDemoAddTask={handleDemoAddTask}
onDemoToggleTask={handleDemoToggleTask}
onUpdateTaskTitle={handleUpdateTaskTitle}
onUpdateListName={handleUpdateListName}
onDeleteTask={isDemo ? handleDemoDeleteTask : async (id: string) => {
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
refresh();
}}
/>
</div>
{selectedTask && (
<>
{/* Detail panel resize handle (left edge) */}
<div
onMouseDown={handleDetailResizeStart}
title="Drag to resize detail panel"
style={{
width: 4,
cursor: "col-resize",
background: "transparent",
flexShrink: 0,
zIndex: 10,
transition: "background var(--dur-fast)",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
/>
<TaskDetail
task={selectedTask}
onClose={() => setSelectedTask(null)}
onUpdate={handleTaskUpdate}
onDelete={() => {
setSelectedTask(null);
refresh();
}}
listId={selectedTask.listId}
listName={lists.find((l) => l.id === selectedTask.listId)?.name}
lists={lists}
onMoveList={async (targetListId) => {
if (isDemo) {
const store = getDemoStore();
const updated = { ...selectedTask, listId: targetListId };
const newTasks = updateTaskInTree(store.tasks, updated as MockTask);
saveDemoStore(store.lists, newTasks);
handleTaskUpdate(updated);
} else {
await fetch(`/api/tasks/${selectedTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ listId: targetListId }),
});
handleTaskUpdate({ ...selectedTask, listId: targetListId });
}
refresh();
}}
isDemo={isDemo}
onDemoUpdateTask={handleDemoUpdateTask}
onDemoDeleteTask={handleDemoDeleteTask}
/>
</>
)}
{/* Global Command Palette (Ctrl+K) */}
<CommandPalette
isOpen={cmdPaletteOpen}
onClose={() => setCmdPaletteOpen(false)}
tasks={tasks}
onSelectTask={handleTaskSelect}
/>
{/* Mobile FAB */}
<button
className="fab"
id="mobile-add-task"
aria-label="Add task"
onClick={() => {
document.dispatchEvent(new CustomEvent("checkflow:addTask"));
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
);
}
+951
View File
@@ -0,0 +1,951 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
import { SettingsModal } from "@/components/settings/SettingsModal";
import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore } from "@/lib/mockData";
interface User { id: string; name?: string | null; email?: string | null }
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
const LIST_COLORS = ["#5B8DEF", "#E05252", "#3DAD84", "#E8931A", "#8B6CF7", "#E4609B", "#0ABAD1", "#F17A3B", "#6875F5", "#14B8A6"];
interface SidebarProps {
user: User;
lists: List[];
setLists: React.Dispatch<React.SetStateAction<List[]>>;
selectedListId: string | null;
onListSelect: (id: string) => void;
selectedTag: string | null;
onTagSelect: (tag: string | null) => void;
isTrashActive: boolean;
onTrashSelect: () => void;
mobileOpen: boolean;
onClose: () => void;
isDemo?: boolean;
onDemoCreateList?: (name: string, color: string) => void;
onDemoDeleteList?: (id: string) => void;
}
/* ===================== UNDO TOAST ===================== */
interface UndoToastState {
message: string;
onUndo: () => void;
timeoutId: ReturnType<typeof setTimeout> | null;
}
export function Sidebar({
user,
lists,
setLists,
selectedListId,
onListSelect,
selectedTag,
onTagSelect,
isTrashActive,
onTrashSelect,
mobileOpen,
onClose: _onClose,
isDemo = false,
onDemoCreateList,
onDemoDeleteList,
}: SidebarProps) {
const router = useRouter();
const { t } = useI18n();
const { theme, toggleTheme } = useTheme();
const [showNewList, setShowNewList] = useState(false);
const [newListName, setNewListName] = useState("");
const [newListColor, setNewListColor] = useState(LIST_COLORS[0]);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [showImport, setShowImport] = useState(false);
const [showExport, setShowExport] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [importListId, setImportListId] = useState("");
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState("");
const [exportFormat, setExportFormat] = useState<"csv" | "ics">("csv");
const [exportListId, setExportListId] = useState<string>("all");
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
const [exporting, setExporting] = useState(false);
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
const [trashCount, setTrashCount] = useState(() => {
if (typeof window !== "undefined") {
const store = getDemoStore();
return getAllTrashTasks(store.tasks).length;
}
return 0;
});
// Context Menu for Lists
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
// Undo Toast for deleted list
const [undoToast, setUndoToast] = useState<UndoToastState | null>(null);
// Inline rename
const [renamingListId, setRenamingListId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// Search expansion
const [searchExpanded, setSearchExpanded] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setTags(getCustomTags());
const store = getDemoStore();
const trashed = getAllTrashTasks(store.tasks);
setTrashCount(trashed.length);
}, [lists]);
useEffect(() => {
if (!isDemo) {
fetch("/api/lists")
.then((r) => (r.ok ? r.json() : []))
.then((data) => {
if (Array.isArray(data)) {
setLists(data);
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
}
})
.catch((err) => console.error("Failed to load lists", err));
}
}, [isDemo, onListSelect, selectedListId, setLists]);
useEffect(() => {
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
}, [showNewList]);
useEffect(() => {
if (searchExpanded) {
setTimeout(() => searchInputRef.current?.focus(), 60);
}
}, [searchExpanded]);
// Dismiss undo toast on unmount
useEffect(() => {
return () => {
if (undoToast?.timeoutId) clearTimeout(undoToast.timeoutId);
};
}, [undoToast]);
const showUndoToast = useCallback((message: string, onUndo: () => void) => {
if (undoToast?.timeoutId) clearTimeout(undoToast.timeoutId);
const tid = setTimeout(() => setUndoToast(null), 5000);
setUndoToast({ message, onUndo, timeoutId: tid });
}, [undoToast]);
const createList = async () => {
const name = newListName.trim();
if (!name) return;
if (isDemo) {
if (onDemoCreateList) onDemoCreateList(name, newListColor);
setNewListName("");
setShowNewList(false);
return;
}
try {
const res = await fetch("/api/lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, color: newListColor }),
});
if (res.ok) {
const list = await res.json();
setLists((prev) => [...prev, list]);
setNewListName("");
setShowNewList(false);
onListSelect(list.id);
}
} catch (err) {
console.error("Failed to create list", err);
}
};
const deleteList = async (id: string) => {
// Store list for undo
const listToDelete = lists.find((l) => l.id === id);
if (!listToDelete) return;
// Optimistic: remove from UI immediately
setLists((prev) => {
const updated = prev.filter((l) => l.id !== id);
if (selectedListId === id && updated.length > 0) onListSelect(updated[0].id);
return updated;
});
if (isDemo) {
if (onDemoDeleteList) onDemoDeleteList(id);
showUndoToast(t("listDeleted"), () => {
// Undo: re-add the list (demo mode — restore optimistically)
setLists((prev) => {
if (prev.find((l) => l.id === id)) return prev;
return [...prev, listToDelete].sort((a, b) => a.name.localeCompare(b.name));
});
onListSelect(id);
});
return;
}
try {
await fetch(`/api/lists/${id}`, { method: "DELETE" });
} catch (err) {
console.error("Failed to delete list", err);
// Rollback
setLists((prev) => [...prev, listToDelete]);
}
showUndoToast(t("listDeleted"), () => {
// For API mode, re-fetch (simplest undo simulation)
fetch("/api/lists")
.then((r) => r.ok ? r.json() : [])
.then((data) => { if (Array.isArray(data)) setLists(data); })
.catch(() => {});
});
};
const renameList = async (id: string, newName: string) => {
const trimmed = newName.trim();
if (!trimmed) return;
setLists((prev) => prev.map((l) => l.id === id ? { ...l, name: trimmed } : l));
setRenamingListId(null);
if (!isDemo) {
try {
await fetch(`/api/lists/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
} catch (err) {
console.error("Failed to rename list", err);
}
}
};
const handleExport = () => {
setExporting(true);
const timestamp = new Date().toISOString().split("T")[0];
if (isDemo) {
try {
const store = getDemoStore();
let targetTasks = store.tasks.filter((t) => !t.deletedAt && !t.parentId);
if (exportListId !== "all") {
targetTasks = targetTasks.filter((t) => t.listId === exportListId);
}
if (!exportIncludeCompleted) {
targetTasks = targetTasks.filter((t) => !t.completed);
}
let content = "";
let mimeType = "text/csv;charset=utf-8;";
let filename = `checkflow-demo-export-${timestamp}.csv`;
if (exportFormat === "ics") {
mimeType = "text/calendar;charset=utf-8;";
filename = `checkflow-demo-export-${timestamp}.ics`;
const priorityMap: Record<number, number> = { 0: 0, 1: 9, 2: 5, 3: 1 };
const now = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const vtodos = targetTasks.map((t) => {
const listObj = store.lists.find((l) => l.id === t.listId);
const lines = [
"BEGIN:VTODO",
`UID:${t.id}@checkflow`,
`DTSTAMP:${now}`,
`CREATED:${t.createdAt ? new Date(t.createdAt).toISOString().replace(/[-:]/g, "").split(".")[0] + "Z" : now}`,
`SUMMARY:${t.title.replace(/\n/g, "\\n")}`,
`STATUS:${t.completed ? "COMPLETED" : "NEEDS-ACTION"}`,
`PRIORITY:${priorityMap[t.priority] ?? 0}`,
];
if (listObj?.name) lines.push(`CATEGORIES:${listObj.name.replace(/\n/g, "\\n")}`);
if (t.note) lines.push(`DESCRIPTION:${t.note.replace(/\n/g, "\\n")}`);
if (t.dueDate) lines.push(`DUE:${new Date(t.dueDate).toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
if (t.completedAt) lines.push(`COMPLETED:${new Date(t.completedAt).toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"}`);
lines.push("END:VTODO");
return lines.join("\r\n");
});
content = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CheckFlow//Demo Tasks Export//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
...vtodos,
"END:VCALENDAR",
].join("\r\n");
} else {
// CSV Export
const escapeCsv = (val: string | null | undefined) => {
if (!val) return '""';
const s = String(val).replace(/"/g, '""');
return `"${s}"`;
};
const priorityLabels: Record<number, string> = { 0: "None", 1: "Low", 2: "Medium", 3: "High" };
const headers = [
"Folder Name",
"List Name",
"Title",
"Tags",
"Content",
"Is Check list",
"Start Date",
"Due Date",
"Reminder",
"Repeat",
"Priority",
"Status",
"Created Time",
"Completed Time",
"Order",
"Timezone",
];
const rows = [headers.map((h) => `"${h}"`).join(",")];
targetTasks.forEach((t, i) => {
const listObj = store.lists.find((l) => l.id === t.listId);
rows.push([
escapeCsv(""),
escapeCsv(listObj?.name || "Inbox"),
escapeCsv(t.title),
escapeCsv(""),
escapeCsv(t.note || ""),
escapeCsv("0"),
escapeCsv(""),
escapeCsv(t.dueDate ? new Date(t.dueDate).toISOString() : ""),
escapeCsv(""),
escapeCsv(""),
escapeCsv(priorityLabels[t.priority] || "None"),
escapeCsv(t.completed ? "Completed" : "Normal"),
escapeCsv(t.createdAt ? new Date(t.createdAt).toISOString() : new Date().toISOString()),
escapeCsv(t.completedAt ? new Date(t.completedAt).toISOString() : ""),
escapeCsv(String(i)),
escapeCsv(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"),
].join(","));
});
content = "\uFEFF" + rows.join("\r\n");
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setShowExport(false);
} catch (e) {
console.error("Demo export error", e);
} finally {
setExporting(false);
}
return;
}
// Authenticated API Export
const url = `/api/export?format=${exportFormat}&listId=${exportListId}&includeCompleted=${exportIncludeCompleted}`;
const a = document.createElement("a");
a.href = url;
a.download = `checkflow-export-${timestamp}.${exportFormat}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setExporting(false);
setShowExport(false);
};
const handleImport = async () => {
const file = fileRef.current?.files?.[0];
if (!file || !importListId) return;
if (isDemo) {
setImportResult("✓ Demo Mode: Import simulated successfully");
setTimeout(() => { setShowImport(false); setImportResult(""); }, 1500);
return;
}
setImporting(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("listId", importListId);
const res = await fetch("/api/import", { method: "POST", body: formData });
const data = await res.json().catch(() => ({}));
if (res.ok) {
setImportResult(`✓ Imported ${data.imported} tasks`);
if (fileRef.current) fileRef.current.value = "";
const listsRes = await fetch("/api/lists");
if (listsRes.ok) {
const updated = await listsRes.json();
if (Array.isArray(updated)) setLists(updated);
}
setTimeout(() => { setShowImport(false); setImportResult(""); }, 2000);
} else {
setImportResult(`Error: ${data.error || "Import failed"}`);
}
} catch (err) {
console.error("[Sidebar] import failed", err);
setImportResult("Error: Network error");
} finally {
setImporting(false);
}
};
const initials = user.name?.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
// Context menu items for list (right-click)
const getListContextMenuItems = (listId: string): MenuItem[] => [
{
label: t("renameList"),
icon: "✏️",
onClick: () => {
const list = lists.find((l) => l.id === listId);
if (list) {
setRenamingListId(listId);
setRenameValue(list.name);
}
},
},
{
label: t("deleteList"),
icon: "🗑️",
danger: true,
onClick: () => deleteList(listId),
},
];
return (
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
{/* Header with Logo & Controls */}
<div className="sidebar-header" style={{ padding: "12px 12px 8px", gap: 6 }}>
<div className="sidebar-logo"></div>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
<span className="sidebar-title" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{t("appName")}
</span>
{isDemo && (
<span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}>
{t("demoBadge")}
</span>
)}
</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>
<LanguageSelector />
{/* Theme 3-Way Toggle Button */}
<button
className="icon-btn"
id="theme-toggle"
onClick={toggleTheme}
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
style={{ width: 28, height: 28, flexShrink: 0 }}
type="button"
>
{theme === "system" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
</svg>
) : theme === "light" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
</div>
</div>
{/* Nav List */}
<nav className="sidebar-nav">
{/* Lists Section */}
<div className="sidebar-section">
<div className="sidebar-section-label">{t("lists")}</div>
{lists.map((list) => (
<div
key={list.id}
className={`sidebar-item${!isTrashActive && !selectedTag && selectedListId === list.id ? " active" : ""}`}
onClick={() => {
if (renamingListId === list.id) return;
onTagSelect(null);
onListSelect(list.id);
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
}}
id={`list-item-${list.id}`}
style={{ overflow: "visible" }}
>
<div className="list-dot" style={{ background: list.color, flexShrink: 0 }} />
{renamingListId === list.id ? (
<input
className="form-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") renameList(list.id, renameValue);
if (e.key === "Escape") setRenamingListId(null);
}}
onBlur={() => renameList(list.id, renameValue)}
autoFocus
onClick={(e) => e.stopPropagation()}
style={{ flex: 1, fontSize: 12.5, padding: "2px 6px", height: 24 }}
/>
) : (
<span className="item-label">{list.name}</span>
)}
<span className="item-count">{list._count?.tasks || ""}</span>
<button
className="icon-btn"
style={{ width: 20, height: 20, opacity: 0, transition: "opacity var(--dur-fast)", flexShrink: 0 }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.opacity = "1"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.opacity = "0"; }}
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
}}
title="More options"
type="button"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" />
</svg>
</button>
</div>
))}
</div>
{/* Custom Tags Section */}
{tags.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-label">🏷 {t("tags") || "Tags"}</div>
{tags.map((tag) => (
<div
key={tag.id}
className={`sidebar-item${selectedTag === tag.name ? " active" : ""}`}
onClick={() => onTagSelect(selectedTag === tag.name ? null : tag.name)}
>
<span style={{ color: tag.color, fontSize: 13, fontWeight: 700 }}>#</span>
<span className="item-label">{tag.name}</span>
</div>
))}
</div>
)}
{/* Trash Smart List */}
<div className="sidebar-section">
<div
className={`sidebar-item${isTrashActive ? " active" : ""}`}
id="trash-menu-btn"
onClick={onTrashSelect}
style={{ color: isTrashActive ? "var(--danger)" : "var(--text-secondary)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.7 }}>
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
</svg>
<span className="item-label">{t("trash") || "Trash"}</span>
{trashCount > 0 && <span className="item-count" style={{ color: "var(--danger)" }}>{trashCount}</span>}
</div>
</div>
{/* New list form */}
{showNewList ? (
<div style={{ padding: "4px 10px" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 5, padding: "6px 4px 8px" }}>
{LIST_COLORS.map((c) => (
<div
key={c}
className={`color-swatch${newListColor === c ? " selected" : ""}`}
style={{ background: c, color: c }}
onClick={() => setNewListColor(c)}
/>
))}
</div>
<input
ref={inputRef}
className="form-input"
placeholder={t("listNamePlaceholder")}
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") createList();
if (e.key === "Escape") setShowNewList(false);
}}
style={{ marginBottom: 6 }}
/>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList} type="button">
{t("create")}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)} type="button">
{t("cancel")}
</button>
</div>
</div>
) : (
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)} type="button">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t("newList")}
</button>
)}
</nav>
{/* User footer */}
<div className="sidebar-footer">
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
<div className="user-avatar" style={{ background: "var(--accent)" }}>{initials}</div>
<div className="user-info">
<div className="user-name">{user.name || (isDemo ? "Demo User" : "User")}</div>
<div className="user-email">{user.email || (isDemo ? "demo@checkflow.local" : "")}</div>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.4 }}>
<polyline points="6 9 12 15 18 9" />
</svg>
{/* User Popover Menu */}
{userMenuOpen && (
<div className="dropdown" style={{ bottom: "calc(100% + 6px)", left: 0, right: 0 }}>
<div
className="dropdown-item"
id="sidebar-settings-menu-item"
onClick={(e) => {
e.stopPropagation();
setShowSettings(true);
setUserMenuOpen(false);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
{t("settingsModalTitle") || "Settings"}
</div>
<div
className="dropdown-item"
id="sidebar-import-menu-item"
onClick={(e) => {
e.stopPropagation();
setShowImport(true);
setImportListId(lists[0]?.id || "");
setUserMenuOpen(false);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
</svg>
{t("importTasks")}
</div>
<div
className="dropdown-item"
id="sidebar-export-menu-item"
onClick={(e) => {
e.stopPropagation();
setShowExport(true);
setUserMenuOpen(false);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
{t("exportTasks")}
</div>
<div className="dropdown-divider" />
{isDemo ? (
<div className="dropdown-item" onClick={() => { router.push("/login"); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
</svg>
{t("signIn")}
</div>
) : (
<div className="dropdown-item danger" id="signout-btn" onClick={() => signOut({ callbackUrl: "/login" })}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
</svg>
{t("signOut")}
</div>
)}
</div>
)}
</div>
</div>
{/* Context Menu on List */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={getListContextMenuItems(contextMenu.listId)}
onClose={() => setContextMenu(null)}
/>
)}
{/* Settings Modal */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
user={user}
isDemo={isDemo}
/>
{/* Import modal */}
{showImport && (
<div className="modal-overlay" onClick={() => setShowImport(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">{t("importModalTitle")}</h2>
<div className="form-group">
<label className="form-label">{t("targetList")}</label>
<select className="form-input" value={importListId} onChange={(e) => setImportListId(e.target.value)}>
{lists.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">{t("fileSelectLabel")}</label>
<input ref={fileRef} type="file" accept=".csv,.ics" className="form-input" />
</div>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 12 }}>
{t("tickTickExportHint")}
</p>
{importResult && (
<p style={{ fontSize: 13, color: importResult.startsWith("✓") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>
{importResult}
</p>
)}
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setShowImport(false)} type="button">{t("cancel")}</button>
<button id="import-submit-btn" className="btn btn-primary" onClick={handleImport} disabled={importing} type="button">
{importing ? t("importing") : t("importBtn")}
</button>
</div>
</div>
</div>
)}
{/* Export modal */}
{showExport && (
<div className="modal-overlay" onClick={() => setShowExport(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
<h2 className="modal-title">📤 {t("exportModalTitle")}</h2>
<div className="form-group">
<label className="form-label">{t("exportFormat")}</label>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
className={`btn btn-sm ${exportFormat === "csv" ? "btn-primary" : "btn-ghost"}`}
onClick={() => setExportFormat("csv")}
style={{ flex: 1 }}
>
📄 CSV (TickTick/Excel)
</button>
<button
type="button"
className={`btn btn-sm ${exportFormat === "ics" ? "btn-primary" : "btn-ghost"}`}
onClick={() => setExportFormat("ics")}
style={{ flex: 1 }}
>
📅 ICS (iCalendar VTODO)
</button>
</div>
</div>
<div className="form-group">
<label className="form-label">{t("exportScope")}</label>
<select className="form-input" value={exportListId} onChange={(e) => setExportListId(e.target.value)}>
<option value="all">{t("allLists")}</option>
{lists.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
<div className="form-group" style={{ marginBottom: 16 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 13, color: "var(--text-primary)" }}>
<input
type="checkbox"
checked={exportIncludeCompleted}
onChange={(e) => setExportIncludeCompleted(e.target.checked)}
style={{ accentColor: "var(--accent)" }}
/>
<span>{t("includeCompleted")}</span>
</label>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setShowExport(false)} type="button">
{t("cancel")}
</button>
<button
id="export-submit-btn"
className="btn btn-primary"
onClick={handleExport}
disabled={exporting}
type="button"
>
{exporting ? "..." : `📥 ${t("exportBtn")}`}
</button>
</div>
</div>
</div>
)}
{/* Undo Toast */}
{undoToast && (
<div
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
zIndex: 300,
display: "flex",
alignItems: "center",
gap: 10,
background: "var(--text-primary)",
color: "var(--bg-primary)",
padding: "10px 16px",
borderRadius: "var(--radius-full)",
fontSize: 13,
fontWeight: 500,
boxShadow: "var(--shadow-lg)",
animation: "toastIn var(--dur-normal) var(--ease-out)",
whiteSpace: "nowrap",
}}
>
<span>{undoToast.message}</span>
<button
type="button"
onClick={() => {
undoToast.onUndo();
if (undoToast.timeoutId) clearTimeout(undoToast.timeoutId);
setUndoToast(null);
}}
style={{
background: "var(--accent)",
color: "#fff",
border: "none",
borderRadius: "var(--radius-sm)",
padding: "3px 10px",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
}}
>
{t("undoDelete")}
</button>
<button
type="button"
onClick={() => {
if (undoToast.timeoutId) clearTimeout(undoToast.timeoutId);
setUndoToast(null);
}}
style={{
background: "transparent",
color: "inherit",
border: "none",
opacity: 0.6,
cursor: "pointer",
fontSize: 14,
padding: "0 2px",
}}
>
</button>
</div>
)}
</aside>
);
}
+690
View File
@@ -0,0 +1,690 @@
"use client";
import React, { useState, useEffect } from "react";
import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/app/providers";
import { getUserSettings, saveUserSettings, UserSettings } from "@/lib/mockData";
import { useUserPrefs } from "@/lib/useUserPrefs";
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
user: { id: string; name?: string | null; email?: string | null };
isDemo?: boolean;
}
/** A toggle-row component used throughout Labs tab */
function LabsRow({
label,
desc,
children,
}: {
label: string;
desc?: string;
children: React.ReactNode;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 14px",
background: "var(--bg-secondary)",
borderRadius: "var(--radius-md)",
border: "1px solid var(--border)",
marginBottom: 8,
gap: 16,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-primary)" }}>{label}</div>
{desc && (
<div style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 2, lineHeight: 1.4 }}>
{desc}
</div>
)}
</div>
<div style={{ flexShrink: 0 }}>{children}</div>
</div>
);
}
/** Segmented control (radio-style) */
function SegmentedControl<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { label: string; value: T }[];
onChange: (v: T) => void;
}) {
return (
<div
style={{
display: "inline-flex",
background: "var(--bg-primary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-sm)",
padding: 2,
gap: 2,
}}
>
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
style={{
padding: "4px 10px",
fontSize: 12,
fontWeight: value === opt.value ? 700 : 500,
borderRadius: "var(--radius-xs)",
background: value === opt.value ? "var(--accent)" : "transparent",
color: value === opt.value ? "#fff" : "var(--text-secondary)",
border: "none",
cursor: "pointer",
transition: "all var(--dur-fast)",
}}
>
{opt.label}
</button>
))}
</div>
);
}
/** Slider with live value display */
function PrefSlider({
min,
max,
value,
onChange,
unit,
}: {
min: number;
max: number;
value: number;
onChange: (v: number) => void;
unit?: string;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
style={{ width: 120, accentColor: "var(--accent)" }}
/>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-secondary)", minWidth: 40 }}>
{value}{unit}
</span>
</div>
);
}
export function SettingsModal({ isOpen, onClose, user, isDemo: _isDemo = false }: SettingsModalProps) {
const { t, lang, setLang } = useI18n();
const { theme, toggleTheme } = useTheme();
const { prefs, updatePrefs, resetToDefaults } = useUserPrefs();
const [activeTab, setActiveTab] = useState<"profile" | "preferences" | "labs" | "sync" | "admin">("profile");
const [displayName, setDisplayName] = useState(() => user.name || (typeof window !== "undefined" ? getUserSettings().displayName : "Demo User"));
const [email, setEmail] = useState(() => user.email || (typeof window !== "undefined" ? getUserSettings().email : "demo@checkflow.local"));
const [password, setPassword] = useState("");
const [trashRetention, setTrashRetention] = useState(() => (typeof window !== "undefined" ? (getUserSettings().trashRetentionDays ?? 30) : 30));
const [savedMsg, setSavedMsg] = useState("");
const [syncPlatform, setSyncPlatform] = useState<"android" | "apple" | "thunderbird">("android");
const [copiedCalDav, setCopiedCalDav] = useState(false);
const [testSyncStatus, setTestSyncStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
useEffect(() => {
if (isOpen) {
const s = getUserSettings();
setDisplayName(user.name || s.displayName);
setEmail(user.email || s.email);
setTrashRetention(s.trashRetentionDays ?? 30);
}
}, [isOpen, user]);
const handleTestConnection = async () => {
setTestSyncStatus("testing");
try {
const res = await fetch("/api/dav", { method: "OPTIONS" });
if (res.ok || res.status === 401 || res.status === 207) {
setTestSyncStatus("success");
} else {
setTestSyncStatus("error");
}
} catch {
setTestSyncStatus("error");
}
setTimeout(() => {
setTestSyncStatus("idle");
}, 4000);
};
if (!isOpen) return null;
const handleSave = () => {
const newSettings: UserSettings = {
displayName,
email,
trashRetentionDays: trashRetention,
theme,
language: lang,
};
saveUserSettings(newSettings);
setSavedMsg("✓ " + (lang === "ko" ? "설정이 저장되었습니다" : lang === "ja" ? "設定が保存されました" : "Settings saved"));
setTimeout(() => {
setSavedMsg("");
onClose();
}, 1000);
};
const calDavUrl = typeof window !== "undefined" ? `${window.location.origin}/api/dav` : "https://todo.yourdomain.com/api/dav";
// Accent hue preview
const hue = prefs.accentHue;
const sat = Math.round(prefs.saturation * 0.9);
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"}` },
] as const;
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal settings-modal"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: 660, width: "92%", padding: "24px 24px 20px", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<h2 className="modal-title" style={{ marginBottom: 0, fontSize: 17 }}> {t("settingsModalTitle") || "Settings"}</h2>
<button className="icon-btn" onClick={onClose} aria-label="Close" type="button"></button>
</div>
{/* Tab Bar */}
<div
style={{
display: "flex",
gap: 2,
borderBottom: "1px solid var(--border)",
marginBottom: 16,
paddingBottom: 0,
overflowX: "auto",
}}
>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
style={{
padding: "7px 14px",
fontSize: 12.5,
fontWeight: activeTab === tab.id ? 700 : 500,
background: "transparent",
color: activeTab === tab.id ? "var(--accent)" : "var(--text-secondary)",
border: "none",
borderBottom: activeTab === tab.id ? "2px solid var(--accent)" : "2px solid transparent",
borderRadius: 0,
cursor: "pointer",
whiteSpace: "nowrap",
transition: "all var(--dur-fast)",
}}
>
{tab.label}
</button>
))}
</div>
{/* Scrollable tab content */}
<div style={{ flex: 1, overflowY: "auto", paddingRight: 2 }}>
{/* Tab 1: Profile */}
{activeTab === "profile" && (
<div className="settings-tab-content">
<div className="form-group">
<label className="form-label">{t("displayName")}</label>
<input className="form-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your Name" />
</div>
<div className="form-group">
<label className="form-label">{t("email")}</label>
<input className="form-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your.email@example.com" />
</div>
<div className="form-group">
<label className="form-label">{t("password")} (Change)</label>
<input className="form-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="New password (leave blank to keep current)" />
</div>
</div>
)}
{/* Tab 2: Preferences */}
{activeTab === "preferences" && (
<div className="settings-tab-content">
<div className="form-group">
<label className="form-label" style={{ fontWeight: 600 }}>
🗑 {t("trashRetention") || "Trash Auto-Delete Retention Period"}
</label>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 8 }}>
{t("trashRetentionHint") || "Deleted tasks will be permanently removed after the specified period."}
</p>
<select className="form-input" value={trashRetention} onChange={(e) => setTrashRetention(parseInt(e.target.value, 10))}>
<option value={7}>{t("days7") || "7 Days"}</option>
<option value={14}>{t("days14") || "14 Days"}</option>
<option value={30}>{t("days30") || "30 Days (Recommended)"}</option>
<option value={0}>{t("neverDelete") || "Never Auto-Delete (Manual empty only)"}</option>
</select>
</div>
<div className="form-group">
<label className="form-label">{t("language")}</label>
<select className="form-input" value={lang} onChange={(e) => setLang(e.target.value as "en" | "ko" | "ja")}>
<option value="en">English (Default)</option>
<option value="ko"> (Korean)</option>
<option value="ja"> (Japanese)</option>
</select>
</div>
<div className="form-group">
<label className="form-label">{t("theme")}</label>
<div style={{ display: "flex", gap: 8 }}>
{[
{ key: "system", icon: "💻", label: t("themeSystem") },
{ key: "light", icon: "☀️", label: t("themeLight") },
{ key: "dark", icon: "🌙", label: t("themeDark") },
].map(({ key, icon, label }) => (
<button
key={key}
type="button"
className={`btn btn-sm ${theme === key ? "btn-primary" : "btn-ghost"}`}
onClick={() => { if (theme !== key) toggleTheme(); }}
>
{icon} {label}
</button>
))}
</div>
</div>
</div>
)}
{/* Tab 3: 🧪 CheckFlow Labs */}
{activeTab === "labs" && (
<div className="settings-tab-content">
{/* Section: View */}
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}>
View
</div>
<LabsRow
label="📊 Kanban Board"
desc="Switch between list view and 3-column Kanban board (To Do / In Progress / Done)."
>
<SegmentedControl
value={prefs.viewMode}
options={[
{ label: "List", value: "list" },
{ label: "Kanban", value: "kanban" },
]}
onChange={(v) => updatePrefs({ viewMode: v })}
/>
</LabsRow>
{/* Section: Layout */}
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
Layout
</div>
<LabsRow
label="📐 Content Density"
desc="Controls the vertical spacing of task items."
>
<SegmentedControl
value={prefs.density}
options={[
{ label: "Compact", value: "compact" },
{ label: "Default", value: "default" },
{ label: "Airy", value: "comfortable" },
]}
onChange={(v) => updatePrefs({ density: v })}
/>
</LabsRow>
<LabsRow
label="↔️ Sidebar Width"
desc={`Drag the sidebar edge or adjust here. (${prefs.sidebarWidth}px)`}
>
<PrefSlider
min={160}
max={420}
value={prefs.sidebarWidth}
onChange={(v) => updatePrefs({ sidebarWidth: v })}
unit="px"
/>
</LabsRow>
<LabsRow
label="↔️ Detail Panel Width"
desc={`Drag the panel edge or adjust here. (${prefs.detailWidth}px)`}
>
<PrefSlider
min={300}
max={760}
value={prefs.detailWidth}
onChange={(v) => updatePrefs({ detailWidth: v })}
unit="px"
/>
</LabsRow>
{/* Section: Appearance */}
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", letterSpacing: "0.08em", textTransform: "uppercase", margin: "16px 0 8px" }}>
Appearance
</div>
<LabsRow
label="🔤 Font Size"
desc="Base font size across the app."
>
<SegmentedControl
value={prefs.fontSize}
options={[
{ label: "S", value: "small" },
{ label: "M", value: "default" },
{ label: "L", value: "large" },
]}
onChange={(v) => updatePrefs({ fontSize: v })}
/>
</LabsRow>
<LabsRow
label="🎨 Accent Color"
desc="Choose the hue of your accent color. Saturation controls vibrancy."
>
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
{/* Hue ring preview */}
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", justifyContent: "flex-end" }}>
{[210, 250, 340, 10, 45, 145, 185].map((h) => (
<div
key={h}
onClick={() => updatePrefs({ accentHue: h })}
title={`Hue ${h}°`}
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: `hsl(${h}, ${sat}%, 54%)`,
cursor: "pointer",
border: prefs.accentHue === h ? "2.5px solid var(--text-primary)" : "2px solid transparent",
outline: prefs.accentHue === h ? `3px solid ${accentPreview}` : "none",
outlineOffset: 1,
transition: "transform var(--dur-fast)",
transform: prefs.accentHue === h ? "scale(1.2)" : "scale(1)",
}}
/>
))}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Saturation</span>
<PrefSlider
min={20}
max={100}
value={prefs.saturation}
onChange={(v) => updatePrefs({ saturation: v })}
unit="%"
/>
</div>
{/* Custom hue input */}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>Hue °</span>
<input
type="range"
min={0}
max={360}
value={prefs.accentHue}
onChange={(e) => updatePrefs({ accentHue: parseInt(e.target.value, 10) })}
style={{ width: 100, accentColor: accentPreview }}
/>
<div style={{ width: 18, height: 18, borderRadius: "50%", background: accentPreview, flexShrink: 0 }} />
</div>
</div>
</LabsRow>
<LabsRow
label="⬛ Border Roundness"
desc="Controls the roundness of cards, buttons, and UI elements."
>
<SegmentedControl
value={prefs.roundness}
options={[
{ label: "Sharp", value: "sharp" },
{ label: "Default", value: "default" },
{ label: "Round", value: "round" },
]}
onChange={(v) => updatePrefs({ roundness: v })}
/>
</LabsRow>
<LabsRow
label="⚡ Animation Speed"
desc="Controls the speed of transitions and hover effects."
>
<SegmentedControl
value={prefs.animationSpeed}
options={[
{ label: "Off", value: "none" },
{ label: "Fast", value: "fast" },
{ label: "Default", value: "default" },
{ label: "Slow", value: "slow" },
]}
onChange={(v) => updatePrefs({ animationSpeed: v })}
/>
</LabsRow>
{/* Reset to Defaults */}
<div style={{ marginTop: 20, borderTop: "1px solid var(--border)", paddingTop: 16 }}>
<LabsRow
label="🔄 Reset to Defaults"
desc="Restores all layout, appearance, and view settings to their factory defaults."
>
<button
type="button"
className="btn btn-sm"
style={{ color: "var(--danger)", border: "1px solid var(--danger)", background: "transparent" }}
onClick={() => {
if (confirm(t("resetConfirm") || "Reset all customizations to defaults?")) {
resetToDefaults();
}
}}
>
{t("resetDefaults") || "Reset"}
</button>
</LabsRow>
</div>
</div>
)}
{/* Tab: CalDAV / External Sync */}
{activeTab === "sync" && (
<div className="settings-tab-content">
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>
📱 {t("syncTitle") || "Galaxy & External Sync (CalDAV)"}
</h3>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 14 }}>
{t("syncDesc") || "CheckFlow supports native two-way synchronization with Samsung Galaxy Reminder, Apple Reminders, and Thunderbird via CalDAV."}
</p>
{/* Endpoint bar & Action buttons */}
<div className="form-group" style={{ marginBottom: 14 }}>
<label className="form-label" style={{ fontWeight: 600 }}>{t("syncBaseUrl") || "CalDAV Server Base URL"}</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
className="form-input"
readOnly
value={calDavUrl}
style={{ fontFamily: "monospace", fontSize: 12.5, background: "var(--bg-secondary)", flex: 1 }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
navigator.clipboard.writeText(calDavUrl);
setCopiedCalDav(true);
setTimeout(() => setCopiedCalDav(false), 2000);
}}
style={{ minWidth: 80, fontWeight: 600 }}
>
{copiedCalDav ? `${t("syncCopied") || "Copied!"}` : `📋 ${t("syncCopyUrl") || "Copy"}`}
</button>
</div>
</div>
{/* Actions row: Test & Download */}
<div style={{ display: "flex", gap: 10, marginBottom: 16, flexWrap: "wrap", alignItems: "center" }}>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={handleTestConnection}
disabled={testSyncStatus === "testing"}
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
>
{testSyncStatus === "testing" ? `${t("syncTesting") || "Testing..."}` : `🔍 ${t("syncTestConnection") || "Test Endpoint"}`}
</button>
<a
href="/api/dav"
target="_blank"
rel="noreferrer"
className="btn btn-sm btn-ghost"
style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none" }}
>
📥 {t("syncDownloadIcs") || "Download .ICS Feed"}
</a>
{testSyncStatus === "success" && (
<span style={{ fontSize: 12, color: "var(--success)", fontWeight: 600 }}>
{t("syncTestSuccess") || "✓ CalDAV endpoint responded successfully"}
</span>
)}
{testSyncStatus === "error" && (
<span style={{ fontSize: 12, color: "var(--danger)", fontWeight: 600 }}>
Endpoint test failed
</span>
)}
</div>
{/* Client setup guides segmented selector */}
<div style={{ marginBottom: 10 }}>
<SegmentedControl
value={syncPlatform}
options={[
{ label: `🤖 ${t("syncTabAndroid") || "Galaxy / DAVx⁵"}`, value: "android" },
{ label: `🍎 ${t("syncTabApple") || "Apple Reminders"}`, value: "apple" },
{ label: `💻 ${t("syncTabThunderbird") || "Thunderbird"}`, value: "thunderbird" },
]}
onChange={(v) => setSyncPlatform(v)}
/>
</div>
{/* Step by step cards */}
<div
style={{
background: "var(--bg-secondary)",
padding: "14px 16px",
borderRadius: "var(--radius-md)",
border: "1px solid var(--border)",
fontSize: 12.5,
color: "var(--text-primary)",
lineHeight: 1.6,
}}
>
{syncPlatform === "android" && (
<div>
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
📱 Samsung Galaxy & Android (DAVx + Reminder / OpenTasks)
</div>
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<li>{t("syncAndroidStep1")}</li>
<li>{t("syncAndroidStep2")}</li>
<li>{t("syncAndroidStep3")}</li>
<li>{t("syncAndroidStep4")}</li>
</ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> In DAVx account settings, set Sync Interval to <strong>15 minutes</strong> for battery efficiency and near real-time sync.
</div>
</div>
)}
{syncPlatform === "apple" && (
<div>
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
🍎 Apple Reminders & Calendar (iOS / iPadOS / macOS)
</div>
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<li>{t("syncAppleStep1")}</li>
<li>{t("syncAppleStep2")}</li>
<li>{t("syncAppleStep3")}</li>
<li>{t("syncAppleStep4")}</li>
</ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> If using HTTPS behind a reverse proxy (Nginx/Caddy), ensure valid SSL certificates are trusted by Apple devices.
</div>
</div>
)}
{syncPlatform === "thunderbird" && (
<div>
<div style={{ fontWeight: 700, marginBottom: 6, color: "var(--accent)" }}>
💻 Mozilla Thunderbird (Windows / Mac / Linux)
</div>
<ol style={{ paddingLeft: 20, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<li>{t("syncThunderbirdStep1")}</li>
<li>{t("syncThunderbirdStep2")}</li>
<li>{t("syncThunderbirdStep3")}</li>
<li>{t("syncThunderbirdStep4")}</li>
</ol>
<div style={{ marginTop: 10, fontSize: 11.5, color: "var(--text-tertiary)", borderTop: "1px dashed var(--border)", paddingTop: 8 }}>
💡 <strong>Tip:</strong> Thunderbird Tasks view will display CheckFlow priority tags, due dates, and completion status.
</div>
</div>
)}
</div>
</div>
)}
{/* Tab 4: Admin Quick Access */}
{activeTab === "admin" && (
<div className="settings-tab-content">
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>👑 Multi-User Admin Console</h3>
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 14 }}>
Manage registered users, user roles (User/Admin), system statistics, and storage allocations.
</p>
<a href="/admin" className="btn btn-primary" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
🚀 Open Admin Dashboard
</a>
</div>
)}
</div>
{/* Footer */}
<div
className="modal-footer"
style={{ marginTop: 16, paddingTop: 12, borderTop: "1px solid var(--border)", display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
{savedMsg && <span style={{ fontSize: 13, color: "var(--success)", fontWeight: 600 }}>{savedMsg}</span>}
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
<button className="btn btn-ghost" onClick={onClose} type="button">{t("cancel")}</button>
<button className="btn btn-primary" onClick={handleSave} type="button">
{t("save") || "Save Changes"}
</button>
</div>
</div>
</div>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
"use client";
import React, { useState } from "react";
import { Task } from "./TaskList";
import { useI18n } from "@/lib/i18n";
interface KanbanViewProps {
tasks: Task[];
selectedTaskId: string | null;
onSelectTask: (task: Task) => void;
onToggleTask: (id: string, completed: boolean) => void;
onAddTask: (title: string, priority?: number) => void;
}
export function KanbanView({
tasks,
selectedTaskId,
onSelectTask,
onToggleTask,
onAddTask,
}: KanbanViewProps) {
const { t } = useI18n();
const [newTodoTitle, setNewTodoTitle] = useState("");
const [newProgTitle, setNewProgTitle] = useState("");
const PRIORITY_COLORS = ["", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
// Group tasks into 3 columns
const todoTasks = tasks.filter((task) => !task.completed && task.priority < 2 && !task.parentId);
const inProgressTasks = tasks.filter((task) => !task.completed && task.priority >= 2 && !task.parentId);
const doneTasks = tasks.filter((task) => task.completed && !task.parentId);
const renderCard = (task: Task) => {
const isSelected = selectedTaskId === task.id;
const subtaskCount = task.children?.length || 0;
const completedSubtasks = task.children?.filter((s) => s.completed).length || 0;
return (
<div
key={task.id}
className={`kanban-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTask(task)}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<button
type="button"
className={`task-check-btn${task.completed ? " checked" : ""}`}
style={{ width: 17, height: 17, marginTop: 2, flexShrink: 0 }}
onClick={(e) => {
e.stopPropagation();
onToggleTask(task.id, !task.completed);
}}
aria-label="Toggle task completion"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13.5,
fontWeight: 600,
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
textDecoration: task.completed ? "line-through" : "none",
wordBreak: "break-word",
lineHeight: 1.4,
}}
>
{task.title}
</div>
{/* Badges row: Priority, Due Date, Subtask progress */}
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
{task.priority > 0 && (
<span
style={{
fontSize: 10,
fontWeight: 700,
padding: "2px 6px",
borderRadius: "var(--radius-sm)",
background: `${PRIORITY_COLORS[task.priority]}15`,
color: PRIORITY_COLORS[task.priority],
display: "inline-flex",
alignItems: "center",
gap: 3,
}}
>
🚩 P{task.priority}
</span>
)}
{task.dueDate && (
<span
style={{
fontSize: 10,
fontWeight: 500,
padding: "2px 6px",
borderRadius: "var(--radius-sm)",
background: "var(--bg-hover)",
color: "var(--text-secondary)",
}}
>
📅 {new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</span>
)}
{subtaskCount > 0 && (
<span
style={{
fontSize: 10,
fontWeight: 600,
padding: "2px 6px",
borderRadius: "var(--radius-sm)",
background: "var(--bg-hover)",
color: "var(--text-tertiary)",
}}
>
{completedSubtasks}/{subtaskCount}
</span>
)}
{task.note && (
<span style={{ fontSize: 10, color: "var(--text-tertiary)" }} title="Has note">
📝
</span>
)}
</div>
</div>
</div>
</div>
);
};
return (
<div className="kanban-container">
{/* Column 1: To Do */}
<div className="kanban-column">
<div className="kanban-column-header">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--text-tertiary)" }} />
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-primary)" }}>{t("todoCol")}</span>
</div>
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", background: "var(--bg-hover)", padding: "2px 8px", borderRadius: "var(--radius-full)" }}>
{todoTasks.length}
</span>
</div>
<div className="kanban-column-body">
{todoTasks.map(renderCard)}
{/* Quick inline add in To Do */}
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
<input
className="form-input"
placeholder={t("addTaskPlaceholder")}
style={{ fontSize: 12, padding: "6px 10px", flex: 1 }}
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newTodoTitle.trim()) {
e.preventDefault();
onAddTask(newTodoTitle.trim(), 0);
setNewTodoTitle("");
}
}}
/>
</div>
</div>
</div>
{/* Column 2: In Progress / High Priority */}
<div className="kanban-column">
<div className="kanban-column-header">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)" }} />
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-primary)" }}>{t("inProgressCol")}</span>
</div>
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--accent)", background: "var(--accent-light)", padding: "2px 8px", borderRadius: "var(--radius-full)" }}>
{inProgressTasks.length}
</span>
</div>
<div className="kanban-column-body">
{inProgressTasks.map(renderCard)}
{/* Quick inline add in In Progress */}
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
<input
className="form-input"
placeholder={t("addTaskPlaceholder")}
style={{ fontSize: 12, padding: "6px 10px", flex: 1 }}
value={newProgTitle}
onChange={(e) => setNewProgTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newProgTitle.trim()) {
e.preventDefault();
onAddTask(newProgTitle.trim(), 2);
setNewProgTitle("");
}
}}
/>
</div>
</div>
</div>
{/* Column 3: Done */}
<div className="kanban-column">
<div className="kanban-column-header">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--success)" }} />
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text-primary)" }}>{t("doneCol")}</span>
</div>
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--success)", background: "rgba(16, 185, 129, 0.12)", padding: "2px 8px", borderRadius: "var(--radius-full)" }}>
{doneTasks.length}
</span>
</div>
<div className="kanban-column-body">
{doneTasks.map(renderCard)}
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useI18n } from "@/lib/i18n";
interface MarkdownNoteEditorProps {
value: string;
onChange: (newValue: string) => void;
onSave?: () => void;
}
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
const { t } = useI18n();
const [mode, setMode] = useState<"edit" | "preview">("edit");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
// Configure marked to open links in new tabs safely
useEffect(() => {
const renderer = new marked.Renderer();
renderer.link = ({ href, title, text }: { href: string; title?: string | null; text: string }) => {
const titleAttr = title ? ` title="${title}"` : "";
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr} class="markdown-link">${text}</a>`;
};
marked.use({ renderer, breaks: true, gfm: true });
}, []);
// Keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (onSave) onSave();
} else if (e.key === "Tab") {
e.preventDefault();
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const newVal = value.slice(0, start) + " " + value.slice(end);
onChange(newVal);
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + 2;
});
}
};
// Convert markdown to sanitized HTML with safe links
const renderMarkdownHtml = () => {
if (!value || !value.trim()) {
return `<p style="color: var(--text-tertiary); font-style: italic; padding: 8px 0;">${t("notesPlaceholder").split("\n")[0]}</p>`;
}
try {
const textWithLinks = value.replace(
/(^|[^"'])(https?:\/\/[^\s<]+)/g,
(match, prefix, url) => {
if (match.includes("](") || match.includes('href="')) return match;
return `${prefix}[${url}](${url})`;
}
);
const rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string;
return DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
"h1", "h2", "h3", "h4", "h5", "h6", "p", "a", "span", "strong", "em", "del", "s",
"ul", "ol", "li", "code", "pre", "blockquote", "hr", "br", "table", "thead", "tbody",
"tr", "th", "td"
],
ALLOWED_ATTR: ["href", "title", "target", "rel", "class", "style"],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ADD_ATTR: ["target", "rel", "class"],
});
} catch {
return value;
}
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
minHeight: 0,
position: "relative",
}}
>
{/* Minimal Header with Mode Switcher */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "4px 8px 8px 8px",
}}
>
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}>
{t("notes").toUpperCase()}
</span>
<div className="view-switcher-group" style={{ padding: 2 }}>
<button
type="button"
className={`view-switcher-btn${mode === "edit" ? " active" : ""}`}
style={{ fontSize: 11, padding: "2px 8px" }}
onClick={() => {
setMode("edit");
setTimeout(() => textareaRef.current?.focus(), 50);
}}
>
Edit
</button>
<button
type="button"
className={`view-switcher-btn${mode === "preview" ? " active" : ""}`}
style={{ fontSize: 11, padding: "2px 8px" }}
onClick={() => setMode("preview")}
>
👁 Preview
</button>
</div>
</div>
{/* Editor / Preview Area */}
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", position: "relative" }}>
{mode === "edit" ? (
<textarea
ref={textareaRef}
className="note-editor-textarea"
style={{
flex: 1,
width: "100%",
height: "100%",
minHeight: 120,
padding: "8px 10px",
background: "transparent",
border: "none",
outline: "none",
color: "var(--text-primary)",
fontSize: 13.5,
lineHeight: 1.6,
resize: "none",
fontFamily: "inherit",
}}
placeholder={t("notesPlaceholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (onSave) onSave();
}}
/>
) : (
<div
ref={previewRef}
className="note-editor-preview"
style={{
flex: 1,
width: "100%",
height: "100%",
padding: "8px 10px",
overflowY: "auto",
color: "var(--text-primary)",
fontSize: 13.5,
lineHeight: 1.6,
}}
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
onClick={() => {
setMode("edit");
setTimeout(() => textareaRef.current?.focus(), 50);
}}
/>
)}
</div>
</div>
);
}
+852
View File
@@ -0,0 +1,852 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useI18n } from "@/lib/i18n";
import { Task, Tag } from "./TaskList";
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
import { getCustomTags, MockTag } from "@/lib/mockData";
import { useUserPrefs } from "@/lib/useUserPrefs";
interface Props {
task: Task;
listId: string;
listName?: string;
lists?: { id: string; name: string; color: string }[];
onMoveList?: (targetListId: string) => void;
onClose: () => void;
onUpdate: (t: Task) => void;
onDelete: () => void;
isDemo?: boolean;
onDemoUpdateTask?: (updated: Task) => void;
onDemoDeleteTask?: (id: string) => void;
}
export function TaskDetail({
task,
listId,
listName,
lists = [],
onMoveList,
onClose,
onUpdate,
onDelete,
isDemo = false,
onDemoUpdateTask,
onDemoDeleteTask,
}: Props) {
const { t, lang } = useI18n();
const [title, setTitle] = useState(task.title);
const [note, setNote] = useState(task.note || "");
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
const [priority, setPriority] = useState(task.priority);
const [tags, setTags] = useState<{ tag: Tag }[]>(task.tags || []);
const [allAvailableTags, setAllAvailableTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
const [showTagPicker, setShowTagPicker] = useState(false);
const [showListPicker, setShowListPicker] = useState(false);
const [newTagName, setNewTagName] = useState("");
// Modular Blocks Customization via global prefs
const { prefs, updatePrefs } = useUserPrefs();
const blockOrder = prefs.detailBlockOrder;
const splitRatio = prefs.detailSplitRatio;
const setBlockOrder = useCallback((next: ("subtasks" | "note")[]) => {
updatePrefs({ detailBlockOrder: next });
}, [updatePrefs]);
const setSplitRatio = useCallback((r: number) => {
updatePrefs({ detailSplitRatio: r });
}, [updatePrefs]);
const [subtasksCollapsed, setSubtasksCollapsed] = useState(false);
const [noteCollapsed, setNoteCollapsed] = useState(false);
const [isDraggingSplit, setIsDraggingSplit] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [newSubtitle, setNewSubtitle] = useState("");
const [subtasks, setSubtasks] = useState(task.children || []);
const [saving, setSaving] = useState(false);
// Mobile Bottom-Sheet: swipe down to close
const touchStartY = useRef<number | null>(null);
const touchStartX = useRef<number | null>(null);
const gestureDirection = useRef<"vertical" | "horizontal" | null>(null);
const [panelTranslateY, setPanelTranslateY] = useState(0);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const check = () => setIsMobile(window.innerWidth <= 768);
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, []);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTaskId = useRef(task.id);
const priorityMap = [
{ label: t("priorityNone"), color: "var(--text-tertiary)", icon: "" },
{ label: t("priorityLow"), color: "var(--priority-low)", icon: "▼" },
{ label: t("priorityMedium"), color: "var(--priority-medium)", icon: "▶" },
{ label: t("priorityHigh"), color: "var(--priority-high)", icon: "▲" },
];
// Toggle block order — prefs store handles persistence
const toggleBlockOrder = () => {
const next = blockOrder[0] === "subtasks"
? ["note", "subtasks"] as ("subtasks" | "note")[]
: ["subtasks", "note"] as ("subtasks" | "note")[];
setBlockOrder(next);
};
const handleSplitMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsDraggingSplit(true);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingSplit || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
let ratio = (relativeY / rect.height) * 100;
ratio = Math.max(15, Math.min(85, ratio));
setSplitRatio(ratio);
};
const handleMouseUp = () => {
if (isDraggingSplit) {
setIsDraggingSplit(false);
}
};
if (isDraggingSplit) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isDraggingSplit, setSplitRatio]);
// Sync state when switching task
useEffect(() => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
currentTaskId.current = task.id;
setTitle(task.title);
setNote(task.note || "");
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
setPriority(task.priority);
setTags(task.tags || []);
setSubtasks(task.children || []);
}, [task.id, task.title, task.note, task.dueDate, task.priority, task.children, task.tags]);
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
const save = useCallback(
async (taskId: string, data: Record<string, unknown>) => {
if (taskId !== currentTaskId.current) return;
setSaving(true);
if (isDemo) {
const updatedTask: Task = {
...task,
...data,
children: subtasks,
tags,
updatedAt: new Date().toISOString(),
} as Task;
if (onDemoUpdateTask) onDemoUpdateTask(updatedTask);
onUpdate(updatedTask);
setSaving(false);
return;
}
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const updated = await res.json();
if (taskId === currentTaskId.current) {
onUpdate({ ...updated, children: subtasks, tags });
}
}
} catch (err) {
console.error("[TaskDetail] save failed", err);
} finally {
setSaving(false);
}
},
[isDemo, onDemoUpdateTask, onUpdate, subtasks, tags, task]
);
const debounceSave = useCallback(
(data: Record<string, unknown>) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
save(task.id, data);
}, 500);
},
[save, task.id]
);
const handleNoteChange = (newNote: string) => {
setNote(newNote);
debounceSave({ note: newNote });
};
const handleToggleComplete = useCallback(async () => {
const nextCompleted = !task.completed;
save(task.id, {
completed: nextCompleted,
completedAt: nextCompleted ? new Date().toISOString() : null,
});
}, [save, task.id, task.completed]);
const handleDelete = useCallback(async () => {
if (!confirm(t("deleteTaskConfirm"))) return;
if (isDemo) {
if (onDemoDeleteTask) onDemoDeleteTask(task.id);
onDelete();
return;
}
try {
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
onDelete();
} catch (err) {
console.error("[TaskDetail] delete failed", err);
}
}, [t, isDemo, onDemoDeleteTask, task.id, onDelete]);
// Tag Management
const toggleTag = (tag: MockTag) => {
const exists = tags.some((tItem) => tItem.tag.id === tag.id);
let nextTags: { tag: Tag }[];
if (exists) {
nextTags = tags.filter((tItem) => tItem.tag.id !== tag.id);
} else {
nextTags = [...tags, { tag }];
}
setTags(nextTags);
save(task.id, { tags: nextTags });
};
const addCustomTag = () => {
const trimmed = newTagName.trim().replace(/^#/, "");
if (!trimmed) return;
const newTag: MockTag = {
id: "tag-" + Date.now(),
name: trimmed,
color: ["#4B7BF5", "#10B981", "#EF4444", "#F59E0B", "#8B5CF6"][Math.floor(Math.random() * 5)],
};
setAllAvailableTags((p) => [...p, newTag]);
toggleTag(newTag);
setNewTagName("");
};
const addSubtask = useCallback(async () => {
const tTitle = newSubtitle.trim();
if (!tTitle) return;
if (isDemo) {
const newSub: Task = {
id: "demo-sub-" + Date.now(),
listId,
parentId: task.id,
title: tTitle,
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 0,
sortOrder: subtasks.length,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [],
tags: [],
};
const nextSubs = [...subtasks, newSub];
setSubtasks(nextSubs);
setNewSubtitle("");
const updatedParent = { ...task, children: nextSubs };
if (onDemoUpdateTask) onDemoUpdateTask(updatedParent);
onUpdate(updatedParent);
return;
}
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: tTitle, listId, parentId: task.id }),
});
if (res.ok) {
const sub = await res.json();
setSubtasks((prev) => {
const next = [...prev, sub];
onUpdate({ ...task, children: next });
return next;
});
setNewSubtitle("");
}
} catch (err) {
console.error("[TaskDetail] addSubtask failed", err);
}
}, [newSubtitle, isDemo, listId, task, subtasks, onDemoUpdateTask, onUpdate]);
const toggleSubtask = async (sub: Task) => {
const nextCompleted = !sub.completed;
const nextSubs = subtasks.map((s) => (s.id === sub.id ? { ...s, completed: nextCompleted } : s));
setSubtasks(nextSubs);
const updatedParent = { ...task, children: nextSubs };
if (isDemo && onDemoUpdateTask) {
onDemoUpdateTask(updatedParent);
}
onUpdate(updatedParent);
if (!isDemo) {
try {
await fetch(`/api/tasks/${sub.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: nextCompleted }),
});
} catch (err) {
console.error("[TaskDetail] toggleSubtask failed", err);
}
}
};
const deleteSubtask = async (subId: string) => {
const nextSubs = subtasks.filter((s) => s.id !== subId);
setSubtasks(nextSubs);
const updatedParent = { ...task, children: nextSubs };
if (isDemo && onDemoUpdateTask) {
onDemoUpdateTask(updatedParent);
}
onUpdate(updatedParent);
if (!isDemo) {
try {
await fetch(`/api/tasks/${subId}`, { method: "DELETE" });
} catch (err) {
console.error("[TaskDetail] deleteSubtask failed", err);
}
}
};
const completedCount = subtasks.filter((s) => s.completed).length;
// Render Subtasks Block
const renderSubtasksBlock = () => {
return (
<div
className="detail-block-card"
style={{
flex: subtasksCollapsed ? "0 0 auto" : `0 0 ${noteCollapsed ? "100%" : `${splitRatio}%`}`,
minHeight: subtasksCollapsed ? 38 : 120,
display: "flex",
flexDirection: "column",
}}
>
<div className="detail-block-header">
<div
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
onClick={() => setSubtasksCollapsed((p) => !p)}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{ transform: subtasksCollapsed ? "rotate(0deg)" : "rotate(90deg)", transition: "transform 0.15s" }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-primary)" }}>
{t("subtasks").toUpperCase()}
</span>
{subtasks.length > 0 && (
<span style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", background: "var(--bg-hover)", padding: "1px 6px", borderRadius: "var(--radius-full)" }}>
{completedCount}/{subtasks.length}
</span>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
type="button"
className="icon-btn"
style={{ width: 22, height: 22, fontSize: 11 }}
onClick={toggleBlockOrder}
title={t("swapBlocks")}
>
</button>
</div>
</div>
{!subtasksCollapsed && (
<div style={{ padding: "8px 12px", flex: 1, display: "flex", flexDirection: "column", gap: 6, overflowY: "auto" }}>
{/* Progress Bar */}
{subtasks.length > 0 && (
<div style={{ height: 3, background: "var(--bg-hover)", borderRadius: 2, marginBottom: 4, overflow: "hidden" }}>
<div
style={{
height: "100%",
width: `${(completedCount / subtasks.length) * 100}%`,
background: "var(--accent)",
borderRadius: 2,
transition: "width var(--dur-normal) var(--ease-out)",
}}
/>
</div>
)}
{/* List of subtasks */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{subtasks.map((sub) => (
<div
key={sub.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 10px",
borderRadius: "var(--radius-sm)",
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
}}
>
<button
className={`task-check-btn${sub.completed ? " checked" : ""}`}
style={{ width: 16, height: 16, flexShrink: 0 }}
onClick={() => toggleSubtask(sub)}
aria-label="Toggle subtask"
type="button"
/>
<span
style={{
flex: 1,
fontSize: 13,
textDecoration: sub.completed ? "line-through" : "none",
color: sub.completed ? "var(--text-tertiary)" : "var(--text-primary)",
fontWeight: 500,
}}
>
{sub.title}
</span>
<button
className="icon-btn"
style={{ width: 20, height: 20, opacity: 0.4 }}
onClick={() => deleteSubtask(sub.id)}
title="Delete subtask"
type="button"
>
</button>
</div>
))}
</div>
{/* Quick Add Subtask Input */}
<div style={{ display: "flex", gap: 6, marginTop: "auto", paddingTop: 4 }}>
<input
className="form-input"
placeholder={t("addSubtaskPlaceholder")}
style={{ flex: 1, fontSize: 12.5, padding: "5px 10px" }}
value={newSubtitle}
onChange={(e) => setNewSubtitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addSubtask();
}
}}
/>
{newSubtitle.trim() && (
<button className="btn btn-primary btn-sm" onClick={addSubtask} type="button">
{t("add")}
</button>
)}
</div>
</div>
)}
</div>
);
};
// Render Note Block
const renderNoteBlock = () => {
return (
<div
className="detail-block-card"
style={{
flex: noteCollapsed ? "0 0 auto" : `0 0 ${subtasksCollapsed ? "100%" : `${100 - splitRatio}%`}`,
minHeight: noteCollapsed ? 38 : 120,
display: "flex",
flexDirection: "column",
}}
>
<div className="detail-block-header">
<div
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
onClick={() => setNoteCollapsed((p) => !p)}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{ transform: noteCollapsed ? "rotate(0deg)" : "rotate(90deg)", transition: "transform 0.15s" }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-primary)" }}>
📝 {t("notes").toUpperCase()}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
type="button"
className="icon-btn"
style={{ width: 22, height: 22, fontSize: 11 }}
onClick={toggleBlockOrder}
title={t("swapBlocks")}
>
</button>
</div>
</div>
{!noteCollapsed && (
<div style={{ flex: 1, padding: 8, minHeight: 0, display: "flex", flexDirection: "column" }}>
<MarkdownNoteEditor
value={note}
onChange={handleNoteChange}
onSave={() => save(task.id, { note })}
/>
</div>
)}
</div>
);
};
return (
<aside
className="detail-panel mobile-open"
style={{
transform: isMobile ? `translateY(${panelTranslateY}px)` : "none",
transition: panelTranslateY === 0 ? "transform 0.25s var(--ease-out)" : "none",
}}
>
{/* Mobile swipe indicator */}
<div
className="mobile-swipe-handle mobile-only"
style={{
width: "100%",
padding: "10px 0 4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
cursor: "grab",
}}
onTouchStart={(e) => {
touchStartY.current = e.touches[0].clientY;
touchStartX.current = e.touches[0].clientX;
gestureDirection.current = null;
}}
onTouchMove={(e) => {
if (touchStartY.current === null) return;
const deltaY = e.touches[0].clientY - touchStartY.current;
if (deltaY > 0) setPanelTranslateY(deltaY);
}}
onTouchEnd={() => {
if (panelTranslateY > 120) {
onClose();
}
setPanelTranslateY(0);
touchStartY.current = null;
}}
>
<div style={{ width: 36, height: 4, borderRadius: 2, background: "var(--border-strong)" }} />
</div>
{/* Top Header */}
<div className="detail-header">
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 0, position: "relative" }}>
{/* Breadcrumb / Project Move selector */}
<div style={{ position: "relative" }}>
<button
type="button"
className="tick-meta-chip"
onClick={() => setShowListPicker((p) => !p)}
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--accent)",
background: "var(--accent-light)",
borderColor: "transparent",
}}
title={t("moveToList")}
>
📁 {listName || t("tasks")}
</button>
{showListPicker && lists.length > 0 && (
<div
className="dropdown"
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 160, padding: 4, zIndex: 110 }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ padding: "4px 8px", fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)" }}>
{t("moveToList")}
</div>
{lists.map((l) => (
<div
key={l.id}
className="context-menu-item"
style={{
padding: "6px 10px",
fontSize: 12.5,
cursor: "pointer",
background: l.id === listId ? "var(--bg-active)" : "transparent",
}}
onClick={() => {
if (onMoveList && l.id !== listId) {
onMoveList(l.id);
}
setShowListPicker(false);
}}
>
📁 {l.name}
</div>
))}
</div>
)}
</div>
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
{saving ? `${t("saving")}` : `${t("autoSaved")}`}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
className="icon-btn"
onClick={handleDelete}
title={t("deleteTaskConfirm").split("?")[0]}
style={{ color: "var(--danger)" }}
type="button"
>
🗑
</button>
<button className="icon-btn" onClick={onClose} title="Close" type="button">
</button>
</div>
</div>
{/* Main Body */}
<div className="detail-scroll" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 10 }}>
{/* Title and Complete Button */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
className={`task-check-btn${task.completed ? " checked" : ""}`}
onClick={handleToggleComplete}
aria-label="Toggle completion"
type="button"
/>
<input
className="detail-title-input"
value={title}
onChange={(e) => {
setTitle(e.target.value);
debounceSave({ title: e.target.value });
}}
placeholder={t("taskTitlePlaceholder")}
style={{ flex: 1, fontSize: 16, fontWeight: 600 }}
/>
</div>
{/* Metadata Chips: Due Date, Priority, Tags */}
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
{/* Due date chip */}
<input
type="date"
className="tick-meta-chip"
style={{ fontSize: 11.5, padding: "3px 8px", cursor: "pointer", border: "1px solid var(--border)" }}
value={dueDate}
onChange={(e) => {
const val = e.target.value || null;
setDueDate(e.target.value);
save(task.id, { dueDate: val });
}}
/>
{/* Priority selector */}
<select
className="tick-meta-chip"
style={{
fontSize: 11.5,
padding: "3px 8px",
cursor: "pointer",
border: "1px solid var(--border)",
color: priority > 0 ? priorityMap[priority].color : "inherit",
fontWeight: priority > 0 ? 700 : 500,
}}
value={priority}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
setPriority(val);
save(task.id, { priority: val });
}}
>
{priorityMap.map((p, idx) => (
<option key={idx} value={idx}>
{p.icon ? `${p.icon} ` : ""}
{p.label}
</option>
))}
</select>
{/* Tag Selector */}
<div style={{ position: "relative" }}>
<button
type="button"
className="tick-meta-chip"
onClick={() => setShowTagPicker((p) => !p)}
style={{ fontSize: 11.5, padding: "3px 8px" }}
>
🏷 {tags.length > 0 ? `${tags.length} tags` : t("selectTag")}
</button>
{showTagPicker && (
<div
className="dropdown"
style={{ left: 0, top: "calc(100% + 4px)", minWidth: 180, padding: 8, zIndex: 110 }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text-tertiary)", marginBottom: 6 }}>
{t("tags")}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 140, overflowY: "auto" }}>
{allAvailableTags.map((tag) => {
const active = tags.some((tItem) => tItem.tag.id === tag.id);
return (
<div
key={tag.id}
className="context-menu-item"
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
onClick={() => toggleTag(tag)}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: tag.color || "var(--accent)" }} />
<span style={{ flex: 1 }}>{tag.name}</span>
{active && <span></span>}
</div>
);
})}
</div>
<div style={{ display: "flex", gap: 4, marginTop: 6, borderTop: "1px solid var(--border)", paddingTop: 6 }}>
<input
className="form-input"
placeholder="New tag..."
style={{ fontSize: 11, padding: "3px 6px", flex: 1 }}
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomTag();
}
}}
/>
<button className="btn btn-primary btn-sm" style={{ fontSize: 10, padding: "2px 6px" }} onClick={addCustomTag} type="button">
+
</button>
</div>
</div>
)}
</div>
</div>
{/* Modular Blocks Container (Subtasks & Notes) with Split Resizer */}
<div
ref={containerRef}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 280,
overflow: "hidden",
gap: 4,
}}
>
{blockOrder.map((blockType, idx) => (
<React.Fragment key={blockType}>
{blockType === "subtasks" ? renderSubtasksBlock() : renderNoteBlock()}
{/* Split Resizer bar between blocks if neither is collapsed */}
{idx === 0 && !subtasksCollapsed && !noteCollapsed && (
<div
className={`detail-split-resizer${isDraggingSplit ? " dragging" : ""}`}
onMouseDown={handleSplitMouseDown}
title="Drag to resize blocks"
>
<div className="detail-split-resizer-line" />
</div>
)}
</React.Fragment>
))}
</div>
{/* Footer Info */}
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid var(--border)", paddingTop: 8, flexWrap: "wrap", gap: 6 }}>
{listName && (
<span className="tick-meta-chip" style={{ fontSize: 11, padding: "2px 8px" }}>
📁 {listName}
</span>
)}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginLeft: "auto", flexWrap: "wrap" }}>
<span>
{t("created")}:{" "}
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
{task.updatedAt && (
<span>
{t("edited")}:{" "}
{new Date(task.updatedAt).toLocaleTimeString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
</div>
</div>
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { useI18n } from "@/lib/i18n";
import { Task } from "../tasks/TaskList";
interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
tasks: Task[];
onSelectTask: (task: Task) => void;
}
export function CommandPalette({ isOpen, onClose, tasks, onSelectTask }: CommandPaletteProps) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
if (isOpen) onClose();
else {
// Open handled by parent
document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"));
}
}
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Flatten recursive tasks for search
const flattenTasks = (list: Task[]): Task[] => {
let result: Task[] = [];
for (const item of list) {
result.push(item);
if (item.children && item.children.length > 0) {
result = result.concat(flattenTasks(item.children));
}
}
return result;
};
const allTasks = flattenTasks(tasks);
const filtered = query.trim()
? allTasks.filter(
(task) =>
task.title.toLowerCase().includes(query.toLowerCase()) ||
(task.note && task.note.toLowerCase().includes(query.toLowerCase())) ||
(task.tags && task.tags.some((t) => t.tag.name.toLowerCase().includes(query.toLowerCase())))
)
: allTasks.slice(0, 8);
return (
<div className="modal-overlay" onClick={onClose} style={{ zIndex: 99999, alignItems: "flex-start", paddingTop: "12vh" }}>
<div
className="modal command-palette-modal"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: 580, width: "90%", padding: 0, overflow: "hidden", borderRadius: "var(--radius-md)" }}
>
{/* Search Header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 18px", borderBottom: "1px solid var(--border)" }}>
<span style={{ fontSize: 16, opacity: 0.6 }}>🔍</span>
<input
ref={inputRef}
className="form-input"
placeholder={t("searchPlaceholder") || "Search all tasks, notes, tags (Ctrl+K)..."}
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ border: "none", background: "none", fontSize: 15, padding: 0, outline: "none", boxShadow: "none" }}
/>
<span style={{ fontSize: 11, color: "var(--text-tertiary)", background: "var(--bg-secondary)", padding: "2px 6px", borderRadius: 4 }}>
ESC
</span>
</div>
{/* Results List */}
<div style={{ maxHeight: 340, overflowY: "auto", padding: "8px 0" }}>
{filtered.length === 0 ? (
<div style={{ padding: "24px 20px", textAlign: "center", color: "var(--text-tertiary)", fontSize: 13 }}>
No matching tasks or notes found
</div>
) : (
filtered.map((task) => (
<div
key={task.id}
className="context-menu-item"
style={{ padding: "10px 18px", gap: 12, borderRadius: 0 }}
onClick={() => {
onSelectTask(task);
onClose();
}}
>
<button
className={`task-check-btn${task.completed ? " checked" : ""}`}
style={{ width: 16, height: 16, flexShrink: 0 }}
type="button"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{task.title}
</div>
{task.note && (
<div style={{ fontSize: 11, color: "var(--text-tertiary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{task.note.replace(/[#*`]/g, "").slice(0, 60)}
</div>
)}
</div>
{task.tags && task.tags.length > 0 && (
<div style={{ display: "flex", gap: 4 }}>
{task.tags.map((tg) => (
<span key={tg.tag.id} className="badge" style={{ background: tg.tag.color + "22", color: tg.tag.color, fontSize: 10, padding: "1px 6px" }}>
#{tg.tag.name}
</span>
))}
</div>
)}
</div>
))
)}
</div>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React, { useEffect, useRef } from "react";
export interface MenuItem {
label: string;
icon?: string | React.ReactNode;
danger?: boolean;
divider?: boolean;
onClick?: () => void;
}
interface ContextMenuProps {
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
// Adjust coordinates so menu stays inside viewport
const adjustedX = typeof window !== "undefined" ? Math.min(x, window.innerWidth - 180) : x;
const adjustedY = typeof window !== "undefined" ? Math.min(y, window.innerHeight - 250) : y;
return (
<div
ref={menuRef}
className="custom-context-menu"
style={{
position: "fixed",
top: adjustedY,
left: adjustedX,
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item, i) => {
if (item.divider) {
return <div key={i} className="context-menu-divider" />;
}
return (
<div
key={i}
className={`context-menu-item${item.danger ? " danger" : ""}`}
onClick={() => {
if (item.onClick) item.onClick();
onClose();
}}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span style={{ flex: 1 }}>{item.label}</span>
</div>
);
})}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { useI18n, Language } from "@/lib/i18n";
const LANGUAGES: { code: Language; label: string; flag: string }[] = [
{ code: "en", label: "English", flag: "🇺🇸" },
{ code: "ko", label: "한국어", flag: "🇰🇷" },
{ code: "ja", label: "日本語", flag: "🇯🇵" },
];
export function LanguageSelector() {
const { lang, setLang } = useI18n();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const current = LANGUAGES.find((l) => l.code === lang) || LANGUAGES[0];
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div className="lang-selector-wrap" ref={wrapRef}>
<button
className="lang-btn"
onClick={() => setOpen((p) => !p)}
aria-label="Select Language"
type="button"
>
<span style={{ fontSize: 13, lineHeight: 1 }}>🌐</span>
<span>{current.code.toUpperCase()}</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{ transform: open ? "rotate(180deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{open && (
<div className="lang-dropdown">
{LANGUAGES.map((item) => (
<div
key={item.code}
className={`lang-option${lang === item.code ? " selected" : ""}`}
onClick={() => {
setLang(item.code);
setOpen(false);
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span>{item.flag}</span>
<span>{item.label}</span>
</div>
{lang === item.code && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
))}
</div>
)}
</div>
);
}