feat: initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user