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
+134 -19
View File
@@ -1,7 +1,8 @@
"use client";
import { useState, useCallback, useEffect } from "react";
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 {
@@ -9,6 +10,7 @@ import {
saveDemoStore,
MockList,
MockTask,
MockTag,
updateTaskInTree,
moveToTrashInTree,
restoreTaskInTree,
@@ -37,6 +39,58 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
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);
@@ -167,7 +221,12 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
refresh();
};
const handleDemoAddTask = (title: string, listId: string, parentId: string | null = null) => {
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,
@@ -176,15 +235,15 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
note: null,
completed: false,
completedAt: null,
dueDate: null,
priority: 0,
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: [],
tags: meta?.tags || [],
};
const store = getDemoStore();
const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask);
@@ -311,6 +370,9 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
return (
<div className="app-layout">
{/* Dynamic CSS variable injection from user prefs */}
<style>{prefsStyle}</style>
{/* Sidebar 모바일 오버레이 */}
{sidebarOpen && (
<div
@@ -345,6 +407,24 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
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}`}
@@ -377,20 +457,55 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
</div>
{selectedTask && (
<TaskDetail
task={selectedTask}
onClose={() => setSelectedTask(null)}
onUpdate={handleTaskUpdate}
onDelete={() => {
setSelectedTask(null);
refresh();
}}
listId={selectedTask.listId}
listName={lists.find((l) => l.id === selectedTask.listId)?.name}
isDemo={isDemo}
onDemoUpdateTask={handleDemoUpdateTask}
onDemoDeleteTask={handleDemoDeleteTask}
/>
<>
{/* 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) */}