Files
edgeever/apps/web/src/components/NotebookPane.tsx
T

529 lines
20 KiB
TypeScript

import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ChevronLeft,
Plus,
LayoutList,
BookPlus,
ArrowDownWideNarrow,
Notebook as NotebookIcon,
Tags,
Archive,
Trash2,
KeyRound,
LogOut,
CloudOff,
AlertTriangle,
RefreshCw,
CheckCircle2,
CircleUserRound,
Download,
RotateCcw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotebookTreeItem } from "./NotebookTreeItem";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { Notebook, AuthUser } from "@edgeever/shared";
import type { NotebookNode, NotebookDropPosition, NotebookSortMode } from "@/lib/app-helpers";
import type { SyncQueueSummary } from "@/lib/sync-queue";
import {
buildNotebookTree,
getNotebookSortOptions,
getNotebookSortComparator,
hasEdgeEverDragData,
readNotebookSortPreference,
writeNotebookSortPreference,
} from "@/lib/app-helpers";
import { usePwaInstall } from "./PwaInstallContext";
const NOTEBOOK_DRAG_SCROLL_EDGE_PX = 56;
const NOTEBOOK_DRAG_SCROLL_MAX_STEP_PX = 18;
const SidebarNavButton = ({
active = false,
icon,
label,
onClick,
}: {
active?: boolean;
icon: ReactNode;
label: string;
onClick: () => void;
}) => (
<button
className={cn(
"flex h-9 w-full items-center gap-3 rounded-md px-3 text-left text-sm font-medium leading-none transition-all duration-200",
active ? "bg-slate-100 text-slate-950" : "text-slate-700 hover:bg-slate-50 hover:text-slate-950"
)}
type="button"
aria-current={active ? "page" : undefined}
onClick={onClick}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">{icon}</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
);
const SidebarShortcutButton = ({
active = false,
icon,
label,
onClick,
}: {
active?: boolean;
icon: ReactNode;
label: string;
onClick: () => void;
}) => (
<button
className={cn(
"flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-md px-1.5 text-xs font-medium transition-colors duration-200",
active ? "bg-slate-100 text-slate-900" : "text-slate-600 hover:bg-slate-50 hover:text-slate-950"
)}
type="button"
aria-current={active ? "page" : undefined}
onClick={onClick}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">{icon}</span>
<span className="min-w-0 truncate">{label}</span>
</button>
);
const SidebarTrashShortcut = ({
active = false,
onOpenTrash,
onEmptyTrash,
}: {
active?: boolean;
onOpenTrash: () => void;
onEmptyTrash: () => void;
}) => {
const { t } = useTranslation();
return (
<div className="group relative min-w-0">
<SidebarShortcutButton active={active} icon={<Trash2 className="h-4 w-4" />} label={t("notebookPane.trash")} onClick={onOpenTrash} />
{!active && (
<div className="pointer-events-none absolute right-0 top-full z-20 w-max pt-1 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100">
<button
className="relative flex h-8 items-center gap-1.5 rounded-md border border-rose-200 bg-white px-2 text-xs font-medium text-rose-700 shadow-lg shadow-slate-900/10 transition-colors before:absolute before:-top-1 before:right-16 before:h-2 before:w-2 before:rotate-45 before:border-l before:border-t before:border-rose-200 before:bg-white hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/70"
type="button"
onClick={onEmptyTrash}
>
<Trash2 className="h-3.5 w-3.5" />
{t("notebookPane.emptyTrash")}
</button>
</div>
)}
</div>
);
};
const SidebarSectionLabel = ({ icon, label }: { icon: ReactNode; label: string }) => (
<div className="flex h-9 items-center gap-3 px-3 text-xs font-medium leading-none tracking-wide text-slate-500">
<span className="flex h-4 w-4 shrink-0 items-center justify-center">{icon}</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
</div>
);
const getSyncStatusLabel = (summary: SyncQueueSummary, isOnline: boolean, isSyncing: boolean, t: ReturnType<typeof useTranslation>["t"]) => {
if (!isOnline) {
return summary.total > 0 ? t("notebookPane.sync.offlineWithPending", { count: summary.total }) : t("notebookPane.sync.offline");
}
if (isSyncing || summary.syncing > 0) {
return t("notebookPane.sync.syncing");
}
if (summary.conflict > 0) {
return t("notebookPane.sync.conflicts", { count: summary.conflict });
}
if (summary.error > 0) {
return t("notebookPane.sync.retry", { count: summary.error });
}
if (summary.pending > 0) {
return t("notebookPane.sync.pending", { count: summary.pending });
}
return t("notebookPane.sync.synced");
};
const SyncStatusBar = ({
summary,
isOnline,
isSyncing,
onSyncNow,
}: {
summary: SyncQueueSummary;
isOnline: boolean;
isSyncing: boolean;
onSyncNow: () => void;
}) => {
const { t } = useTranslation();
const hasQueuedWork = summary.total > 0;
const label = getSyncStatusLabel(summary, isOnline, isSyncing, t);
const statusClassName = !isOnline
? "border-slate-200 bg-slate-50 text-slate-600"
: summary.conflict > 0
? "border-amber-200 bg-amber-50 text-amber-800"
: hasQueuedWork
? "border-slate-200 bg-slate-50 text-slate-700"
: "border-slate-200 bg-white text-slate-500";
return (
<div className={cn("mb-3 flex min-h-10 items-center gap-2 rounded-md border px-3 py-2 transition-all duration-200", statusClassName)}>
{!isOnline ? (
<CloudOff className="h-4 w-4 shrink-0" />
) : summary.conflict > 0 ? (
<AlertTriangle className="h-4 w-4 shrink-0" />
) : hasQueuedWork || isSyncing ? (
<RefreshCw className={cn("h-4 w-4 shrink-0", isSyncing && "animate-spin")} />
) : (
<CheckCircle2 className="h-4 w-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
{hasQueuedWork && (
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/70 disabled:opacity-50 transition-colors"
type="button"
title={t("notebookPane.syncNow")}
aria-label={t("notebookPane.syncNow")}
disabled={!isOnline || isSyncing}
onClick={onSyncNow}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
)}
</div>
);
};
export const NotebookPane = ({
user,
view,
selectedNotebookId,
onSelect,
onCreateNotebook,
onRenameNotebook,
onDeleteNotebook,
onMoveNotebook,
onMoveMemos,
onBackToList,
onOpenTags,
onOpenAssets,
onOpenTrash,
onEmptyTrash,
onOpenSettings,
onCreateMemo,
canCreateMemo,
isCreatingMemo,
syncSummary,
isOnline,
isSyncingQueuedChanges,
onSyncQueuedChanges,
imageCompressionEnabled,
onImageCompressionChange,
authRequired,
onLogout,
isLoggingOut,
demoMode = false,
onResetDemo,
isResettingDemo = false,
}: {
user: AuthUser | null;
view: string;
selectedNotebookId: string | null;
onSelect: (notebookId: string) => void;
onCreateNotebook: (parentId?: string | null) => void;
onRenameNotebook: (notebook: Notebook) => void;
onDeleteNotebook: (notebook: Notebook) => void;
onMoveNotebook: (notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => void;
onMoveMemos: (memoIds: string[], targetNotebookId: string) => void;
onBackToList: () => void;
onOpenTags: () => void;
onOpenAssets: () => void;
onOpenTrash: () => void;
onEmptyTrash: () => void;
onOpenSettings: () => void;
onCreateMemo: () => void;
canCreateMemo: boolean;
isCreatingMemo: boolean;
syncSummary: SyncQueueSummary;
isOnline: boolean;
isSyncingQueuedChanges: boolean;
onSyncQueuedChanges: () => void;
imageCompressionEnabled: boolean;
onImageCompressionChange: (enabled: boolean) => void;
authRequired: boolean;
onLogout: () => void;
isLoggingOut: boolean;
demoMode?: boolean;
onResetDemo?: () => void;
isResettingDemo?: boolean;
}) => {
const { t } = useTranslation();
const { isInstallable, install } = usePwaInstall();
const notebookScrollRef = useRef<HTMLDivElement | null>(null);
const notebookDragScrollFrameRef = useRef<number | null>(null);
const [expandSiblingsRequest, setExpandSiblingsRequest] = useState<{ parentId: string | null; token: number } | null>(null);
const [notebookSortMode, setNotebookSortMode] = useState<NotebookSortMode>(readNotebookSortPreference);
const stopNotebookDragAutoScroll = useCallback(() => {
if (notebookDragScrollFrameRef.current === null) {
return;
}
window.cancelAnimationFrame(notebookDragScrollFrameRef.current);
notebookDragScrollFrameRef.current = null;
}, []);
useEffect(() => () => stopNotebookDragAutoScroll(), [stopNotebookDragAutoScroll]);
const handleExpandNotebookSiblings = useCallback((parentId: string | null) => {
setExpandSiblingsRequest((current: { parentId: string | null; token: number } | null) => ({ parentId, token: (current?.token ?? 0) + 1 }));
}, []);
const handleNotebookScrollDragOver = (event: React.DragEvent<HTMLDivElement>) => {
if (!hasEdgeEverDragData(event.dataTransfer)) {
stopNotebookDragAutoScroll();
return;
}
const scrollContainer = notebookScrollRef.current;
if (!scrollContainer) {
return;
}
const rect = scrollContainer.getBoundingClientRect();
const distanceToTop = event.clientY - rect.top;
const distanceToBottom = rect.bottom - event.clientY;
const topPressure = Math.max(0, NOTEBOOK_DRAG_SCROLL_EDGE_PX - distanceToTop);
const bottomPressure = Math.max(0, NOTEBOOK_DRAG_SCROLL_EDGE_PX - distanceToBottom);
const direction = bottomPressure > 0 ? 1 : topPressure > 0 ? -1 : 0;
if (direction === 0) {
stopNotebookDragAutoScroll();
return;
}
event.preventDefault();
const pressure = Math.max(topPressure, bottomPressure) / NOTEBOOK_DRAG_SCROLL_EDGE_PX;
const scrollStep = Math.max(4, Math.ceil(pressure * NOTEBOOK_DRAG_SCROLL_MAX_STEP_PX)) * direction;
const tick = () => {
scrollContainer.scrollTop += scrollStep;
notebookDragScrollFrameRef.current = window.requestAnimationFrame(tick);
};
if (notebookDragScrollFrameRef.current !== null) {
return;
}
notebookDragScrollFrameRef.current = window.requestAnimationFrame(tick);
};
const notebooksQuery = useQuery({
queryKey: ["notebooks"],
queryFn: () => api.listNotebooks(),
});
const notebooks = notebooksQuery.data?.notebooks ?? [];
const notebookSortOptions = useMemo(() => getNotebookSortOptions(t), [t]);
const tree = useMemo(() => buildNotebookTree(notebooks, getNotebookSortComparator(notebookSortMode)), [notebooks, notebookSortMode]);
const isLoading = notebooksQuery.isLoading;
const activeNotebookSortLabel = notebookSortOptions.find((option) => option.value === notebookSortMode)?.label ?? t("options.notebookSort.nameAsc");
useEffect(() => {
writeNotebookSortPreference(notebookSortMode);
}, [notebookSortMode]);
useEffect(() => {
if (!selectedNotebookId) {
return;
}
window.setTimeout(() => {
const selectedNode = notebookScrollRef.current?.querySelector<HTMLElement>(
`[data-notebook-id="${CSS.escape(selectedNotebookId)}"]`
);
selectedNode?.scrollIntoView({ block: "nearest" });
}, 0);
}, [selectedNotebookId, tree]);
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex h-[calc(4rem+env(safe-area-inset-top))] shrink-0 items-end justify-between border-b border-slate-200 px-4 pb-3 pt-[env(safe-area-inset-top)] lg:hidden">
<div>
<div className="text-base font-semibold tracking-normal">{t("notebookPane.notebooks")}</div>
<div className="text-xs text-slate-500">{user?.username ?? t("notebookPane.workspaceFallback")}</div>
</div>
<div className="flex items-center gap-1">
<Button size="icon" variant="ghost" title={t("notebookPane.backToList")} aria-label={t("notebookPane.backToList")} onClick={onBackToList}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" title={t("notebookPane.newNotebook")} aria-label={t("notebookPane.newNotebook")} onClick={() => onCreateNotebook(null)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</header>
<nav className="grid shrink-0 grid-cols-3 gap-1 border-b border-slate-100 px-3 py-2" aria-label={t("notebookPane.secondaryEntries")}>
<SidebarShortcutButton icon={<Tags className="h-4 w-4" />} label={t("mobileSheets.tags")} onClick={onOpenTags} />
<SidebarShortcutButton icon={<Archive className="h-4 w-4" />} label={t("mobileSheets.assets")} onClick={onOpenAssets} />
<SidebarTrashShortcut active={view === "trash"} onOpenTrash={onOpenTrash} onEmptyTrash={onEmptyTrash} />
</nav>
<div
ref={notebookScrollRef}
className="flex-1 overflow-y-auto px-3 py-4"
onDragEnd={stopNotebookDragAutoScroll}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
stopNotebookDragAutoScroll();
}
}}
onDragOver={handleNotebookScrollDragOver}
onDrop={stopNotebookDragAutoScroll}
>
<div className="mb-4 hidden overflow-hidden rounded-full border border-slate-200 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.06)] lg:flex">
<button
className="flex h-14 min-w-0 flex-1 items-center gap-3 px-3 text-left transition-all duration-200 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
title={t("notebookPane.newMemo")}
onClick={onCreateMemo}
disabled={!canCreateMemo || isCreatingMemo}
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white shadow-[0_8px_18px_rgb(var(--brand-green-rgb)/0.28)] transition-transform duration-200 group-hover:scale-105">
<Plus className="h-6 w-6" />
</span>
<span className="min-w-0 truncate text-sm font-semibold text-slate-950">{t("notebookPane.newMemo")}</span>
</button>
</div>
<nav className="mb-3 space-y-1" aria-label={t("notebookPane.entries")}>
<SidebarNavButton
active={view === "notebook" && selectedNotebookId === null}
icon={<LayoutList className="h-4 w-4" />}
label={t("notebookPane.allMemos")}
onClick={onBackToList}
/>
{demoMode && onResetDemo && (
<SidebarNavButton
icon={<RotateCcw className={cn("h-4 w-4 text-slate-500", isResettingDemo && "animate-spin text-emerald-600")} />}
label={isResettingDemo ? t("demo.resetting") : t("demo.resetButton")}
onClick={onResetDemo}
/>
)}
</nav>
<div className="group mb-2 flex items-center justify-between gap-2">
<SidebarSectionLabel icon={<NotebookIcon className="h-4 w-4" />} label={t("notebookPane.notebooks")} />
<div className="flex items-center gap-1 opacity-100 transition-opacity duration-200 lg:opacity-0 lg:group-hover:opacity-100 lg:group-focus-within:opacity-100">
<button
className="flex h-6 w-6 items-center justify-center rounded-md text-slate-500 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/70"
type="button"
title={t("notebookPane.newNotebook")}
aria-label={t("notebookPane.newNotebook")}
onClick={() => onCreateNotebook(null)}
>
<BookPlus className="h-3.5 w-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-6 w-6 items-center justify-center rounded-md text-slate-500 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/70"
type="button"
title={t("notebookPane.sortTitle", { label: activeNotebookSortLabel })}
aria-label={t("notebookPane.sortTitle", { label: activeNotebookSortLabel })}
>
<ArrowDownWideNarrow className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
{notebookSortOptions.map((option) => (
<DropdownMenuCheckboxItem
key={option.value}
checked={notebookSortMode === option.value}
onSelect={() => setNotebookSortMode(option.value)}
>
{option.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{isLoading ? (
<div className="mb-4 px-2 py-3 text-sm text-slate-500">{t("notebookPane.loading")}</div>
) : (
<div className="mb-4 space-y-1" data-notebook-tree>
{tree.map((node) => (
<NotebookTreeItem
key={node.id}
node={node}
depth={0}
selectedNotebookId={selectedNotebookId}
onSelect={onSelect}
onCreateNotebook={onCreateNotebook}
onRenameNotebook={onRenameNotebook}
onDeleteNotebook={onDeleteNotebook}
onMoveNotebook={onMoveNotebook}
onMoveMemos={onMoveMemos}
onDragScroll={handleNotebookScrollDragOver}
expandSiblingsRequest={expandSiblingsRequest}
onExpandSiblings={handleExpandNotebookSiblings}
/>
))}
</div>
)}
</div>
<footer className="border-t border-slate-200 bg-white/80 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 backdrop-blur-sm">
<div className="space-y-1">
{isInstallable && (
<button
onClick={install}
className="flex h-8 w-full items-center gap-3 rounded-md px-3 text-left text-sm font-semibold leading-none text-emerald-700 hover:bg-emerald-50 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/70"
type="button"
title={t("pwa.sidebarInstallTitle") || "安装桌面客户端"}
aria-label={t("pwa.sidebarInstallTitle") || "安装桌面客户端"}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<Download className="h-4 w-4 text-emerald-600" />
</span>
<span className="min-w-0 flex-1 truncate">{t("pwa.sidebarInstall") || "安装桌面客户端"}</span>
</button>
)}
<button
onClick={onOpenSettings}
className="flex h-8 w-full items-center gap-3 rounded-md px-3 text-left text-sm font-medium leading-none text-slate-700 transition-colors duration-200 hover:bg-slate-50 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/70"
type="button"
title={t("notebookPane.profile")}
aria-label={t("notebookPane.profile")}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<CircleUserRound className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 truncate">{t("notebookPane.profile")}</span>
</button>
</div>
</footer>
</div>
);
};