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:
+77
-4
@@ -62,8 +62,8 @@
|
||||
--dur-slow: 300ms;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 240px;
|
||||
--detail-width: min(560px, 48vw);
|
||||
--sidebar-width: 260px;
|
||||
--detail-width: min(580px, 50vw);
|
||||
--header-height: 56px;
|
||||
}
|
||||
|
||||
@@ -1680,8 +1680,81 @@ a { color: inherit; text-decoration: none; }
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-only {
|
||||
/* ============================================
|
||||
CUSTOM CONTEXT MENU
|
||||
============================================ */
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { TaskList, Task, List, User } from "../tasks/TaskList";
|
||||
@@ -361,6 +361,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
refresh();
|
||||
}}
|
||||
listId={selectedTask.listId}
|
||||
listName={lists.find((l) => l.id === selectedTask.listId)?.name}
|
||||
isDemo={isDemo}
|
||||
onDemoUpdateTask={handleDemoUpdateTask}
|
||||
onDemoDeleteTask={handleDemoDeleteTask}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
"use client";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useI18n, Language } from "@/lib/i18n";
|
||||
import { useTheme, ThemeMode } from "@/app/providers";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useTheme } from "@/app/providers";
|
||||
import { LanguageSelector } from "@/components/ui/LanguageSelector";
|
||||
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
||||
|
||||
interface User { id: string; name?: string | null; email?: string | null }
|
||||
interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } }
|
||||
@@ -35,8 +36,8 @@ export function Sidebar({
|
||||
onDemoCreateList,
|
||||
onDemoDeleteList,
|
||||
}: SidebarProps) {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const { theme, setTheme, toggleTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const [showNewList, setShowNewList] = useState(false);
|
||||
const [newListName, setNewListName] = useState("");
|
||||
@@ -47,6 +48,9 @@ export function Sidebar({
|
||||
const [importing, setImporting] = useState(false);
|
||||
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 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) || "?";
|
||||
|
||||
// List context menu items
|
||||
const getListContextMenuItems = (listId: string): MenuItem[] => [
|
||||
{
|
||||
label: t("deleteListConfirm").split("?")[0],
|
||||
icon: "🗑️",
|
||||
danger: true,
|
||||
onClick: () => deleteList(listId),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
|
||||
{/* Header */}
|
||||
<div className="sidebar-header">
|
||||
{/* Header with Logo & Controls */}
|
||||
<div className="sidebar-header" style={{ padding: "14px 14px 10px", gap: 8 }}>
|
||||
<div className="sidebar-logo">✓</div>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<span className="sidebar-title">{t("appName")}</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
<span className="sidebar-title" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{t("appName")}
|
||||
</span>
|
||||
{isDemo && (
|
||||
<span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}>
|
||||
{t("demoBadge")}
|
||||
@@ -170,9 +186,8 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Quick Actions (Lang & Theme) */}
|
||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{/* Custom Language Selector Popover */}
|
||||
{/* Top Controls: Language & Theme — Fixed without clipping */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||
<LanguageSelector />
|
||||
|
||||
{/* Theme 3-Way Toggle Button */}
|
||||
@@ -181,20 +196,21 @@ export function Sidebar({
|
||||
id="theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
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" ? (
|
||||
<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" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
) : 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" />
|
||||
</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" />
|
||||
</svg>
|
||||
)}
|
||||
@@ -202,7 +218,7 @@ export function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
{/* Nav List */}
|
||||
<nav className="sidebar-nav">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-label">{t("lists")}</div>
|
||||
@@ -211,6 +227,10 @@ export function Sidebar({
|
||||
key={list.id}
|
||||
className={`sidebar-item${selectedListId === list.id ? " active" : ""}`}
|
||||
onClick={() => onListSelect(list.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, listId: list.id });
|
||||
}}
|
||||
id={`list-item-${list.id}`}
|
||||
>
|
||||
<div className="list-dot" style={{ background: list.color }} />
|
||||
@@ -218,14 +238,15 @@ export function Sidebar({
|
||||
<span className="item-count">{list._count?.tasks || ""}</span>
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: 22, height: 22, opacity: 0.5 }}
|
||||
style={{ width: 22, height: 22, opacity: 0.4 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteList(list.id);
|
||||
}}
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -233,9 +254,9 @@ export function Sidebar({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New list */}
|
||||
{/* New list form */}
|
||||
{showNewList ? (
|
||||
<div style={{ padding: "4px 8px" }}>
|
||||
<div style={{ padding: "4px 10px" }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, padding: "6px 4px 8px" }}>
|
||||
{LIST_COLORS.map((c) => (
|
||||
<div
|
||||
@@ -259,30 +280,25 @@ export function Sidebar({
|
||||
style={{ marginBottom: 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-ghost btn-sm" onClick={() => setShowNewList(false)}>{t("cancel")}</button>
|
||||
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={createList} type="button">
|
||||
{t("create")}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setShowNewList(false)} type="button">
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</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">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
{t("newList")}
|
||||
</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>
|
||||
|
||||
{/* User footer */}
|
||||
{/* User footer with Profile Menu containing Import */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-card" id="user-menu-btn" onClick={() => setUserMenuOpen((p) => !p)} style={{ position: "relative" }}>
|
||||
<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-email">{user.email || (isDemo ? "demo@checkflow.local" : "")}</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 && (
|
||||
<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 ? (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
@@ -315,6 +355,16 @@ export function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Menu on List */}
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
items={getListContextMenuItems(contextMenu.listId)}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Import modal */}
|
||||
{showImport && (
|
||||
<div className="modal-overlay" onClick={() => setShowImport(false)}>
|
||||
@@ -339,8 +389,8 @@ export function Sidebar({
|
||||
</p>
|
||||
)}
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-ghost" onClick={() => setShowImport(false)}>{t("cancel")}</button>
|
||||
<button id="import-submit-btn" className="btn btn-primary" onClick={handleImport} disabled={importing}>
|
||||
<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} type="button">
|
||||
{importing ? t("importing") : t("importBtn")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { Task } from "./TaskList";
|
||||
@@ -7,6 +7,7 @@ import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
|
||||
interface Props {
|
||||
task: Task;
|
||||
listId: string;
|
||||
listName?: string;
|
||||
onClose: () => void;
|
||||
onUpdate: (t: Task) => void;
|
||||
onDelete: () => void;
|
||||
@@ -18,6 +19,7 @@ interface Props {
|
||||
export function TaskDetail({
|
||||
task,
|
||||
listId,
|
||||
listName,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
@@ -488,8 +490,13 @@ export function TaskDetail({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Timestamp */}
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between" }}>
|
||||
{/* Footer Info: Project Badge & Timestamp */}
|
||||
<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>
|
||||
{t("created")}:{" "}
|
||||
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
@@ -36,6 +37,7 @@ interface TaskItemProps {
|
||||
onToggle: (id: string, completed: boolean) => void;
|
||||
onUpdateTitle: (id: string, title: string) => void;
|
||||
onAddSubtask: (title: string, parentId: string) => void;
|
||||
onContextMenu: (x: number, y: number, task: Task) => void;
|
||||
onDeleteTask?: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ function TaskItem({
|
||||
onToggle,
|
||||
onUpdateTitle,
|
||||
onAddSubtask,
|
||||
onContextMenu,
|
||||
}: TaskItemProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
@@ -116,6 +119,10 @@ function TaskItem({
|
||||
<div
|
||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelect(task)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onContextMenu(e.clientX, e.clientY, task);
|
||||
}}
|
||||
id={`task-${task.id}`}
|
||||
style={{ group: "item" } as React.CSSProperties}
|
||||
>
|
||||
@@ -406,6 +413,7 @@ export function TaskList({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingHeader, setEditingHeader] = useState(false);
|
||||
const [headerTitle, setHeaderTitle] = useState("");
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; task: Task } | null>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const headerInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -652,6 +660,7 @@ export function TaskList({
|
||||
onToggle={handleToggle}
|
||||
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
||||
onAddSubtask={handleAddSubtaskInline}
|
||||
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
||||
/>
|
||||
))}
|
||||
{showCompleted && completedTasks.length > 0 && (
|
||||
@@ -677,12 +686,55 @@ export function TaskList({
|
||||
onToggle={handleToggle}
|
||||
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
||||
onAddSubtask={handleAddSubtaskInline}
|
||||
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
||||
/>
|
||||
))}
|
||||
</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 */}
|
||||
<div className="add-task-bar">
|
||||
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user