feat: implement user preference management with persistence, reactive CSS variables, and layout customization
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
||||
import { KanbanView } from "./KanbanView";
|
||||
import { useUserPrefs } from "@/lib/useUserPrefs";
|
||||
|
||||
export interface Tag { id: string; name: string; color: string }
|
||||
|
||||
@@ -454,7 +456,12 @@ interface Props {
|
||||
onEmptyTrash?: () => void;
|
||||
onRestoreTask?: (id: string) => void;
|
||||
onPermanentDeleteTask?: (id: string) => void;
|
||||
onDemoAddTask?: (title: string, listId: string, parentId?: string | null) => void;
|
||||
onDemoAddTask?: (
|
||||
title: string,
|
||||
listId: string,
|
||||
parentId?: string | null,
|
||||
meta?: { dueDate?: string | null; priority?: number; tags?: { tag: Tag }[] }
|
||||
) => void;
|
||||
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
||||
onUpdateTaskTitle?: (id: string, title: string) => void;
|
||||
onUpdateListName?: (id: string, name: string) => void;
|
||||
@@ -492,6 +499,23 @@ export function TaskList({
|
||||
const [headerTitle, setHeaderTitle] = useState("");
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; task: Task } | null>(null);
|
||||
|
||||
const { prefs, updatePrefs } = useUserPrefs();
|
||||
// Only show kanban if the Labs flag is explicitly enabled
|
||||
const kanbanEnabled = prefs.labs?.kanbanBoard ?? false;
|
||||
const viewMode = kanbanEnabled ? prefs.viewMode : "list";
|
||||
|
||||
const handleViewModeChange = (mode: "list" | "kanban") => {
|
||||
updatePrefs({ viewMode: mode });
|
||||
};
|
||||
|
||||
// TickTick-style Quick Add Preset states
|
||||
const [quickDueDate, setQuickDueDate] = useState<string | null>(null);
|
||||
const [quickPriority, setQuickPriority] = useState<number>(0);
|
||||
const [showQuickDue, setShowQuickDue] = useState(false);
|
||||
const [showQuickPriority, setShowQuickPriority] = useState(false);
|
||||
|
||||
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const headerInputRef = useRef<HTMLInputElement>(null);
|
||||
const currentList = lists.find((l) => l.id === listId);
|
||||
@@ -559,9 +583,16 @@ export function TaskList({
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title || !listId) return;
|
||||
|
||||
const meta = {
|
||||
dueDate: quickDueDate,
|
||||
priority: quickPriority,
|
||||
};
|
||||
|
||||
if (isDemo) {
|
||||
if (onDemoAddTask) onDemoAddTask(title, listId, null);
|
||||
if (onDemoAddTask) onDemoAddTask(title, listId, null, meta);
|
||||
setNewTaskTitle("");
|
||||
setQuickDueDate(null);
|
||||
setQuickPriority(0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -569,12 +600,14 @@ export function TaskList({
|
||||
const res = await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, listId }),
|
||||
body: JSON.stringify({ title, listId, ...meta }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const task = await res.json();
|
||||
setTasks((prev) => [...prev, task]);
|
||||
setNewTaskTitle("");
|
||||
setQuickDueDate(null);
|
||||
setQuickPriority(0);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("[TaskList] handleAddTask failed", err);
|
||||
@@ -693,7 +726,29 @@ export function TaskList({
|
||||
)}
|
||||
|
||||
{/* Header Actions */}
|
||||
<div className="main-header-actions">
|
||||
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{/* View Switcher: List vs Kanban (Hidden in Trash/Tag mode) */}
|
||||
{!isTrashActive && !selectedTag && (
|
||||
<div className="view-switcher-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-switcher-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => handleViewModeChange("list")}
|
||||
title={t("listView")}
|
||||
>
|
||||
📋 <span className="desktop-only">{t("listView")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-switcher-btn${viewMode === "kanban" ? " active" : ""}`}
|
||||
onClick={() => handleViewModeChange("kanban")}
|
||||
title={t("kanbanView")}
|
||||
>
|
||||
📊 <span className="desktop-only">{t("kanbanView")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTrashActive ? (
|
||||
<button
|
||||
id="empty-trash-btn"
|
||||
@@ -715,32 +770,55 @@ export function TaskList({
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{showCompleted ? t("hideDone") : t("showDone")}
|
||||
<span className="desktop-only">{showCompleted ? t("hideDone") : t("showDone")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task list container */}
|
||||
<div
|
||||
className="task-list-container"
|
||||
onContextMenu={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
||||
)}
|
||||
{!loading && tasks.length === 0 && (
|
||||
<div className="task-list-empty">
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
<p>{isTrashActive ? "Trash is empty" : selectedTag ? "No tasks with this tag" : t("noTasksYet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Main Task View (List View vs Kanban View) */}
|
||||
{viewMode === "kanban" && !isTrashActive && !selectedTag ? (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<KanbanView
|
||||
tasks={tasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={onTaskSelect}
|
||||
onToggleTask={handleToggle}
|
||||
onAddTask={(title, priority = 0) => {
|
||||
if (!listId) return;
|
||||
if (isDemo && onDemoAddTask) {
|
||||
onDemoAddTask(title, listId, null, { priority });
|
||||
} else if (!isDemo) {
|
||||
fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, listId, priority }),
|
||||
}).then(() => onRefresh());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Task list container */
|
||||
<div
|
||||
className="task-list-container"
|
||||
onContextMenu={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
||||
)}
|
||||
{!loading && tasks.length === 0 && (
|
||||
<div className="task-list-empty">
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
<p>{isTrashActive ? "Trash is empty" : selectedTag ? "No tasks with this tag" : t("noTasksYet")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks Tree */}
|
||||
{incompleteTasks.map((task) => (
|
||||
@@ -794,6 +872,7 @@ export function TaskList({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full-featured Context Menu */}
|
||||
{contextMenu && !isTrashActive && (
|
||||
@@ -837,11 +916,11 @@ export function TaskList({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add task bar (hidden in trash mode) */}
|
||||
{/* Add task bar with TickTick-style Quick Presets (hidden in trash mode) */}
|
||||
{!isTrashActive && !selectedTag && (
|
||||
<div className="add-task-bar">
|
||||
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" type="button" />
|
||||
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
||||
<div className="add-task-bar" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<form onSubmit={handleAddTask} style={{ display: "flex", alignItems: "center", gap: 8, width: "100%" }}>
|
||||
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" type="button" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="add-task-input"
|
||||
@@ -856,6 +935,155 @@ export function TaskList({
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* TickTick-style Preset Toolbar (Due Date, Priority) */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingLeft: 28, flexWrap: "wrap" }}>
|
||||
{/* Due Date Preset */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tick-meta-chip${quickDueDate ? " active" : ""}`}
|
||||
onClick={() => setShowQuickDue((p) => !p)}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
color: quickDueDate ? "var(--accent)" : "var(--text-tertiary)",
|
||||
borderColor: quickDueDate ? "var(--accent)" : "transparent",
|
||||
}}
|
||||
title={t("selectDate")}
|
||||
>
|
||||
📅 {quickDueDate ? new Date(quickDueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : t("selectDate")}
|
||||
</button>
|
||||
|
||||
{showQuickDue && (
|
||||
<div
|
||||
className="dropdown"
|
||||
style={{ left: 0, bottom: "calc(100% + 6px)", minWidth: 150, padding: 6, zIndex: 120 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
setQuickDueDate(new Date().toISOString().split("T")[0]);
|
||||
setShowQuickDue(false);
|
||||
}}
|
||||
>
|
||||
⚡ {t("quickToday")}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
const tm = new Date(Date.now() + 86400000).toISOString().split("T")[0];
|
||||
setQuickDueDate(tm);
|
||||
setShowQuickDue(false);
|
||||
}}
|
||||
>
|
||||
🌅 {t("quickTomorrow")}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
const nw = new Date(Date.now() + 7 * 86400000).toISOString().split("T")[0];
|
||||
setQuickDueDate(nw);
|
||||
setShowQuickDue(false);
|
||||
}}
|
||||
>
|
||||
🗓️ {t("quickNextWeek")}
|
||||
</div>
|
||||
<div style={{ borderTop: "1px solid var(--border)", margin: "4px 0", paddingTop: 4 }}>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
style={{ fontSize: 11, padding: "2px 6px", width: "100%" }}
|
||||
value={quickDueDate || ""}
|
||||
onChange={(e) => {
|
||||
setQuickDueDate(e.target.value || null);
|
||||
setShowQuickDue(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{quickDueDate && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
style={{ padding: "4px 8px", fontSize: 11, color: "var(--danger)", cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
setQuickDueDate(null);
|
||||
setShowQuickDue(false);
|
||||
}}
|
||||
>
|
||||
✕ {t("clear")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority Preset */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tick-meta-chip${quickPriority > 0 ? " active" : ""}`}
|
||||
onClick={() => setShowQuickPriority((p) => !p)}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
color: quickPriority > 0 ? PRIORITY_COLORS[quickPriority] : "var(--text-tertiary)",
|
||||
borderColor: quickPriority > 0 ? PRIORITY_COLORS[quickPriority] : "transparent",
|
||||
}}
|
||||
title={t("selectPriority")}
|
||||
>
|
||||
🚩 {quickPriority > 0 ? priorityLabels[quickPriority] : t("selectPriority")}
|
||||
</button>
|
||||
|
||||
{showQuickPriority && (
|
||||
<div
|
||||
className="dropdown"
|
||||
style={{ left: 0, bottom: "calc(100% + 6px)", minWidth: 120, padding: 4, zIndex: 120 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[0, 1, 2, 3].map((pVal) => (
|
||||
<div
|
||||
key={pVal}
|
||||
className="context-menu-item"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
color: pVal > 0 ? PRIORITY_COLORS[pVal] : "inherit",
|
||||
fontWeight: quickPriority === pVal ? 700 : 400,
|
||||
}}
|
||||
onClick={() => {
|
||||
setQuickPriority(pVal);
|
||||
setShowQuickPriority(false);
|
||||
}}
|
||||
>
|
||||
{pVal === 0 ? `○ ${t("priorityNone")}` : `● ${priorityLabels[pVal]}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Helper Text */}
|
||||
{(quickDueDate || quickPriority > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 10, padding: "1px 6px", opacity: 0.6 }}
|
||||
onClick={() => {
|
||||
setQuickDueDate(null);
|
||||
setQuickPriority(0);
|
||||
}}
|
||||
>
|
||||
{t("clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user