Feature: Smooth sidebar animation, fixed theme button clipping, moved import to profile menu, added custom webapp context menu, and polished TickTick style detail panel

This commit is contained in:
2026-08-20 14:34:17 +09:00
parent ed8c9187a7
commit 9c01516124
6 changed files with 308 additions and 48 deletions
+79 -6
View File
@@ -62,8 +62,8 @@
--dur-slow: 300ms; --dur-slow: 300ms;
/* Layout */ /* Layout */
--sidebar-width: 240px; --sidebar-width: 260px;
--detail-width: min(560px, 48vw); --detail-width: min(580px, 50vw);
--header-height: 56px; --header-height: 56px;
} }
@@ -1680,8 +1680,81 @@ a { color: inherit; text-decoration: none; }
display: none; display: none;
} }
@media (max-width: 768px) { /* ============================================
.mobile-only { CUSTOM CONTEXT MENU
display: flex; ============================================ */
} .custom-context-menu {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 5px;
min-width: 170px;
animation: dropdownIn var(--dur-fast) var(--ease-out);
backdrop-filter: blur(12px);
user-select: none;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
border-radius: var(--radius-xs);
cursor: pointer;
transition: background var(--dur-fast), color var(--dur-fast);
}
.context-menu-item:hover {
background: var(--bg-hover);
color: var(--accent);
}
.context-menu-item.danger {
color: var(--danger);
}
.context-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
}
.context-menu-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.context-menu-icon {
font-size: 14px;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.8;
}
/* TickTick Style Meta Chip */
.tick-meta-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: var(--radius-full);
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--dur-fast);
}
.tick-meta-chip:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-medium);
} }
+2 -1
View File
@@ -1,4 +1,4 @@
"use client"; "use client";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { TaskList, Task, List, User } from "../tasks/TaskList"; import { TaskList, Task, List, User } from "../tasks/TaskList";
@@ -361,6 +361,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
refresh(); refresh();
}} }}
listId={selectedTask.listId} listId={selectedTask.listId}
listName={lists.find((l) => l.id === selectedTask.listId)?.name}
isDemo={isDemo} isDemo={isDemo}
onDemoUpdateTask={handleDemoUpdateTask} onDemoUpdateTask={handleDemoUpdateTask}
onDemoDeleteTask={handleDemoDeleteTask} onDemoDeleteTask={handleDemoDeleteTask}
+88 -38
View File
@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { useI18n, Language } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useTheme, ThemeMode } from "@/app/providers"; import { useTheme } from "@/app/providers";
import { LanguageSelector } from "@/components/ui/LanguageSelector"; import { LanguageSelector } from "@/components/ui/LanguageSelector";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
interface User { id: string; name?: string | null; email?: string | null } interface User { id: string; name?: string | null; email?: string | null }
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } } interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
@@ -35,8 +36,8 @@ export function Sidebar({
onDemoCreateList, onDemoCreateList,
onDemoDeleteList, onDemoDeleteList,
}: SidebarProps) { }: SidebarProps) {
const { t, lang, setLang } = useI18n(); const { t } = useI18n();
const { theme, setTheme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [showNewList, setShowNewList] = useState(false); const [showNewList, setShowNewList] = useState(false);
const [newListName, setNewListName] = useState(""); const [newListName, setNewListName] = useState("");
@@ -47,6 +48,9 @@ export function Sidebar({
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState(""); const [importResult, setImportResult] = useState("");
// Context Menu for Lists
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -156,13 +160,25 @@ export function Sidebar({
const initials = user.name?.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?"; const initials = user.name?.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
// List context menu items
const getListContextMenuItems = (listId: string): MenuItem[] => [
{
label: t("deleteListConfirm").split("?")[0],
icon: "🗑️",
danger: true,
onClick: () => deleteList(listId),
},
];
return ( return (
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}> <aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
{/* Header */} {/* Header with Logo & Controls */}
<div className="sidebar-header"> <div className="sidebar-header" style={{ padding: "14px 14px 10px", gap: 8 }}>
<div className="sidebar-logo"></div> <div className="sidebar-logo"></div>
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
<span className="sidebar-title">{t("appName")}</span> <span className="sidebar-title" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{t("appName")}
</span>
{isDemo && ( {isDemo && (
<span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}> <span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}>
{t("demoBadge")} {t("demoBadge")}
@@ -170,9 +186,8 @@ export function Sidebar({
)} )}
</div> </div>
{/* Top Quick Actions (Lang & Theme) */} {/* Top Controls: Language & Theme — Fixed without clipping */}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
{/* Custom Language Selector Popover */}
<LanguageSelector /> <LanguageSelector />
{/* Theme 3-Way Toggle Button */} {/* Theme 3-Way Toggle Button */}
@@ -181,20 +196,21 @@ export function Sidebar({
id="theme-toggle" id="theme-toggle"
onClick={toggleTheme} onClick={toggleTheme}
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`} title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
style={{ width: 28, height: 28 }} style={{ width: 28, height: 28, flexShrink: 0 }}
type="button"
> >
{theme === "system" ? ( {theme === "system" ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" /> <line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" /> <line x1="12" y1="17" x2="12" y2="21" />
</svg> </svg>
) : theme === "light" ? ( ) : theme === "light" ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <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>
) : ( ) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg> </svg>
)} )}
@@ -202,7 +218,7 @@ export function Sidebar({
</div> </div>
</div> </div>
{/* Nav */} {/* Nav List */}
<nav className="sidebar-nav"> <nav className="sidebar-nav">
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-section-label">{t("lists")}</div> <div className="sidebar-section-label">{t("lists")}</div>
@@ -211,6 +227,10 @@ export function Sidebar({
key={list.id} key={list.id}
className={`sidebar-item${selectedListId === list.id ? " active" : ""}`} className={`sidebar-item${selectedListId === list.id ? " active" : ""}`}
onClick={() => onListSelect(list.id)} onClick={() => onListSelect(list.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
}}
id={`list-item-${list.id}`} id={`list-item-${list.id}`}
> >
<div className="list-dot" style={{ background: list.color }} /> <div className="list-dot" style={{ background: list.color }} />
@@ -218,14 +238,15 @@ export function Sidebar({
<span className="item-count">{list._count?.tasks || ""}</span> <span className="item-count">{list._count?.tasks || ""}</span>
<button <button
className="icon-btn" className="icon-btn"
style={{ width: 22, height: 22, opacity: 0.5 }} style={{ width: 22, height: 22, opacity: 0.4 }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
deleteList(list.id); deleteList(list.id);
}} }}
title="Delete list" title="Delete list"
type="button"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" /> <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> </svg>
</button> </button>
@@ -233,9 +254,9 @@ export function Sidebar({
))} ))}
</div> </div>
{/* New list */} {/* New list form */}
{showNewList ? ( {showNewList ? (
<div style={{ padding: "4px 8px" }}> <div style={{ padding: "4px 10px" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, padding: "6px 4px 8px" }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 6, padding: "6px 4px 8px" }}>
{LIST_COLORS.map((c) => ( {LIST_COLORS.map((c) => (
<div <div
@@ -259,30 +280,25 @@ export function Sidebar({
style={{ marginBottom: 6 }} style={{ marginBottom: 6 }}
/> />
<div style={{ display: "flex", gap: 6 }}> <div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList}>{t("create")}</button> <button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList} type="button">
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)}>{t("cancel")}</button> {t("create")}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)} type="button">
{t("cancel")}
</button>
</div> </div>
</div> </div>
) : ( ) : (
<button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)}> <button id="new-list-btn" className="sidebar-add-btn" onClick={() => setShowNewList(true)} type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="14" height="14" 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" /> <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
{t("newList")} {t("newList")}
</button> </button>
)} )}
<div className="sidebar-section" style={{ borderTop: "1px solid var(--border)", paddingTop: 12, marginTop: 4 }}>
<button id="import-btn" className="sidebar-add-btn" onClick={() => { setShowImport(true); setImportListId(lists[0]?.id || ""); }}>
<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("importTasks")}
</button>
</div>
</nav> </nav>
{/* User footer */} {/* User footer with Profile Menu containing Import */}
<div className="sidebar-footer"> <div className="sidebar-footer">
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}> <div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
<div className="user-avatar" style={{ background: "#4B7BF5" }}>{initials}</div> <div className="user-avatar" style={{ background: "#4B7BF5" }}>{initials}</div>
@@ -290,8 +306,32 @@ export function Sidebar({
<div className="user-name">{user.name || (isDemo ? "Demo User" : "User")}</div> <div className="user-name">{user.name || (isDemo ? "Demo User" : "User")}</div>
<div className="user-email">{user.email || (isDemo ? "demo@checkflow.local" : "")}</div> <div className="user-email">{user.email || (isDemo ? "demo@checkflow.local" : "")}</div>
</div> </div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.5 }}>
<polyline points="6 9 12 15 18 9" />
</svg>
{/* User Popover Menu */}
{userMenuOpen && ( {userMenuOpen && (
<div className="dropdown" style={{ bottom: "100%", left: 0, right: 0, marginBottom: 4 }}> <div className="dropdown" style={{ bottom: "calc(100% + 6px)", left: 0, right: 0, marginBottom: 0 }}>
{/* Import Action */}
<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="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
{t("importTasks")}
</div>
<div className="context-menu-divider" />
{isDemo ? ( {isDemo ? (
<div <div
className="dropdown-item" className="dropdown-item"
@@ -315,6 +355,16 @@ export function Sidebar({
</div> </div>
</div> </div>
{/* Context Menu on List */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={getListContextMenuItems(contextMenu.listId)}
onClose={() => setContextMenu(null)}
/>
)}
{/* Import modal */} {/* Import modal */}
{showImport && ( {showImport && (
<div className="modal-overlay" onClick={() => setShowImport(false)}> <div className="modal-overlay" onClick={() => setShowImport(false)}>
@@ -339,8 +389,8 @@ export function Sidebar({
</p> </p>
)} )}
<div className="modal-footer"> <div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setShowImport(false)}>{t("cancel")}</button> <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}> <button id="import-submit-btn" className="btn btn-primary" onClick={handleImport} disabled={importing} type="button">
{importing ? t("importing") : t("importBtn")} {importing ? t("importing") : t("importBtn")}
</button> </button>
</div> </div>
+10 -3
View File
@@ -1,4 +1,4 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useRef } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { Task } from "./TaskList"; import { Task } from "./TaskList";
@@ -7,6 +7,7 @@ import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
interface Props { interface Props {
task: Task; task: Task;
listId: string; listId: string;
listName?: string;
onClose: () => void; onClose: () => void;
onUpdate: (t: Task) => void; onUpdate: (t: Task) => void;
onDelete: () => void; onDelete: () => void;
@@ -18,6 +19,7 @@ interface Props {
export function TaskDetail({ export function TaskDetail({
task, task,
listId, listId,
listName,
onClose, onClose,
onUpdate, onUpdate,
onDelete, onDelete,
@@ -488,8 +490,13 @@ export function TaskDetail({
)} )}
</div> </div>
{/* Footer Timestamp */} {/* Footer Info: Project Badge & Timestamp */}
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between" }}> <div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: "1px solid var(--border)", paddingTop: 8 }}>
{listName && (
<span className="tick-meta-chip" style={{ fontSize: 11, padding: "2px 8px" }}>
📁 {listName}
</span>
)}
<span> <span>
{t("created")}:{" "} {t("created")}:{" "}
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { {new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
+52
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
export interface Task { export interface Task {
id: string; id: string;
@@ -36,6 +37,7 @@ interface TaskItemProps {
onToggle: (id: string, completed: boolean) => void; onToggle: (id: string, completed: boolean) => void;
onUpdateTitle: (id: string, title: string) => void; onUpdateTitle: (id: string, title: string) => void;
onAddSubtask: (title: string, parentId: string) => void; onAddSubtask: (title: string, parentId: string) => void;
onContextMenu: (x: number, y: number, task: Task) => void;
onDeleteTask?: (id: string) => void; onDeleteTask?: (id: string) => void;
} }
@@ -46,6 +48,7 @@ function TaskItem({
onToggle, onToggle,
onUpdateTitle, onUpdateTitle,
onAddSubtask, onAddSubtask,
onContextMenu,
}: TaskItemProps) { }: TaskItemProps) {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
@@ -116,6 +119,10 @@ function TaskItem({
<div <div
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`} className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
onClick={() => onSelect(task)} onClick={() => onSelect(task)}
onContextMenu={(e) => {
e.preventDefault();
onContextMenu(e.clientX, e.clientY, task);
}}
id={`task-${task.id}`} id={`task-${task.id}`}
style={{ group: "item" } as React.CSSProperties} style={{ group: "item" } as React.CSSProperties}
> >
@@ -406,6 +413,7 @@ export function TaskList({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editingHeader, setEditingHeader] = useState(false); const [editingHeader, setEditingHeader] = useState(false);
const [headerTitle, setHeaderTitle] = useState(""); const [headerTitle, setHeaderTitle] = useState("");
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; task: Task } | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const headerInputRef = useRef<HTMLInputElement>(null); const headerInputRef = useRef<HTMLInputElement>(null);
@@ -652,6 +660,7 @@ export function TaskList({
onToggle={handleToggle} onToggle={handleToggle}
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)} onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
onAddSubtask={handleAddSubtaskInline} onAddSubtask={handleAddSubtaskInline}
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
/> />
))} ))}
{showCompleted && completedTasks.length > 0 && ( {showCompleted && completedTasks.length > 0 && (
@@ -677,12 +686,55 @@ export function TaskList({
onToggle={handleToggle} onToggle={handleToggle}
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)} onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
onAddSubtask={handleAddSubtaskInline} onAddSubtask={handleAddSubtaskInline}
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Context Menu on Task */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={[
{
label: contextMenu.task.completed ? t("showDone") : t("completedSection"),
icon: contextMenu.task.completed ? "↩️" : "✅",
onClick: () => handleToggle(contextMenu.task.id, !contextMenu.task.completed),
},
{
label: t("taskTitlePlaceholder"),
icon: "✏️",
onClick: () => onTaskSelect(contextMenu.task),
},
{
label: t("subtasks"),
icon: "↳",
onClick: () => {
onTaskSelect(contextMenu.task);
},
},
{ divider: true, label: "" },
{
label: t("deleteTaskConfirm").split("?")[0],
icon: "🗑️",
danger: true,
onClick: async () => {
if (isDemo) {
setTasks((prev) => prev.filter((t) => t.id !== contextMenu.task.id));
} else {
await fetch(`/api/tasks/${contextMenu.task.id}`, { method: "DELETE" });
onRefresh();
}
},
},
]}
onClose={() => setContextMenu(null)}
/>
)}
{/* Add task bar */} {/* Add task bar */}
<div className="add-task-bar"> <div className="add-task-bar">
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" /> <button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" />
+77
View File
@@ -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>
);
}