feat: implement user preference management with persistence, reactive CSS variables, and layout customization

This commit is contained in:
2026-08-21 10:25:00 +09:00
parent 8212f16d28
commit fa15956a90
23 changed files with 2915 additions and 1146 deletions
+257 -29
View File
@@ -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>