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;
|
--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
|
||||||
|
============================================ */
|
||||||
|
.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;
|
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 { 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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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