feat: implement user preference management with persistence, reactive CSS variables, and layout customization
This commit is contained in:
@@ -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) */}
|
||||
|
||||
Reference in New Issue
Block a user