refactor(web): rewrite monolithic App.tsx to modular components styled with Shadcn UI

This commit is contained in:
tianma committed 2026-06-28 00:21:02 +08:00
1 parent 881efe3a8b
commit b201d7dfb9
27 files changed
+9119 -8542

No files matched your search

+14 -8506
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+215
View File
@@ -0,0 +1,215 @@
import type { ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import {
Undo2,
Redo2,
Bold,
Italic,
Strikethrough,
Code2,
List,
ListOrdered,
Quote,
SquareCode,
Minus,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { getActiveBlockValue } from "@/lib/app-helpers";
const EditorToolbarButton = ({
active = false,
children,
disabled = false,
onClick,
title,
}: {
active?: boolean;
children: ReactNode;
disabled?: boolean;
onClick: () => void;
title: string;
}) => (
<button
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md border text-slate-700 transition disabled:pointer-events-none disabled:opacity-40",
active
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-transparent bg-transparent hover:border-slate-200 hover:bg-slate-50"
)}
type="button"
title={title}
aria-label={title}
aria-pressed={active || undefined}
disabled={disabled}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
const ToolbarDivider = () => <div className="h-6 w-px shrink-0 bg-slate-200" />;
export const EditorToolbar = ({ editor, readOnly }: { editor: Editor | null; readOnly: boolean }) => {
const disabled = readOnly || !editor;
const blockValue = getActiveBlockValue(editor);
const canRun = (command: (editor: Editor) => boolean) => {
if (!editor || readOnly) {
return false;
}
return command(editor);
};
const run = (command: (editor: Editor) => void) => {
if (!editor || readOnly) {
return;
}
command(editor);
};
const setBlock = (value: string) => {
run((current) => {
const chain = current.chain().focus();
if (value === "paragraph") {
chain.setParagraph().run();
return;
}
if (value === "heading-1") {
chain.setHeading({ level: 1 }).run();
return;
}
if (value === "heading-2") {
chain.setHeading({ level: 2 }).run();
return;
}
if (value === "heading-3") {
chain.setHeading({ level: 3 }).run();
}
});
};
return (
<div className="relative border-t border-slate-200 bg-white">
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-4 bg-gradient-to-r from-white to-transparent sm:hidden" />
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-4 bg-gradient-to-l from-white to-transparent sm:hidden" />
<div
className="flex min-h-12 items-center gap-2 overflow-x-auto px-3 py-2 [scrollbar-width:none] sm:px-5 [&::-webkit-scrollbar]:hidden"
role="toolbar"
aria-label="编辑器工具栏"
>
<Select
value={blockValue}
disabled={disabled}
onValueChange={(value) => setBlock(value)}
>
<SelectTrigger className="h-8 w-28 text-xs text-slate-800 border-slate-200 bg-white">
<SelectValue placeholder="正文" />
</SelectTrigger>
<SelectContent className="bg-white border border-slate-200 rounded-md py-1 shadow-md">
<SelectItem value="paragraph">正文</SelectItem>
<SelectItem value="heading-1">标题 1</SelectItem>
<SelectItem value="heading-2">标题 2</SelectItem>
<SelectItem value="heading-3">标题 3</SelectItem>
</SelectContent>
</Select>
<ToolbarDivider />
<EditorToolbarButton
title="撤销"
disabled={!canRun((current) => current.can().chain().focus().undo().run())}
onClick={() => run((current) => current.chain().focus().undo().run())}
>
<Undo2 className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title="重做"
disabled={!canRun((current) => current.can().chain().focus().redo().run())}
onClick={() => run((current) => current.chain().focus().redo().run())}
>
<Redo2 className="h-4 w-4" />
</EditorToolbarButton>
<ToolbarDivider />
<EditorToolbarButton
title="加粗"
active={Boolean(editor?.isActive("bold"))}
disabled={!canRun((current) => current.can().chain().focus().toggleBold().run())}
onClick={() => run((current) => current.chain().focus().toggleBold().run())}
>
<Bold className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title="斜体"
active={Boolean(editor?.isActive("italic"))}
disabled={!canRun((current) => current.can().chain().focus().toggleItalic().run())}
onClick={() => run((current) => current.chain().focus().toggleItalic().run())}
>
<Italic className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title="删除线"
active={Boolean(editor?.isActive("strike"))}
disabled={!canRun((current) => current.can().chain().focus().toggleStrike().run())}
onClick={() => run((current) => current.chain().focus().toggleStrike().run())}
>
<Strikethrough className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title="行内代码"
active={Boolean(editor?.isActive("code"))}
disabled={!canRun((current) => current.can().chain().focus().toggleCode().run())}
onClick={() => run((current) => current.chain().focus().toggleCode().run())}
>
<Code2 className="h-4 w-4" />
</EditorToolbarButton>
<ToolbarDivider />
<EditorToolbarButton
title="无序列表"
active={Boolean(editor?.isActive("bulletList"))}
disabled={disabled}
onClick={() => run((current) => current.chain().focus().toggleBulletList().run())}
>
<List className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title="有序列表"
active={Boolean(editor?.isActive("orderedList"))}
disabled={disabled}
onClick={() => run((current) => current.chain().focus().toggleOrderedList().run())}
>
<ListOrdered className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title="引用"
active={Boolean(editor?.isActive("blockquote"))}
disabled={disabled}
onClick={() => run((current) => current.chain().focus().toggleBlockquote().run())}
>
<Quote className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title="代码块"
active={Boolean(editor?.isActive("codeBlock"))}
disabled={disabled}
onClick={() => run((current) => current.chain().focus().toggleCodeBlock().run())}
>
<SquareCode className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title="分割线"
disabled={disabled}
onClick={() => run((current) => current.chain().focus().setHorizontalRule().run())}
>
<Minus className="h-4 w-4" />
</EditorToolbarButton>
</div>
</div>
);
};
+72
View File
@@ -0,0 +1,72 @@
import { useState, type FormEvent } from "react";
import { LockKeyhole } from "lucide-react";
import { Button } from "@/components/ui/button";
interface LoginScreenProps {
error: string | null;
isSubmitting: boolean;
onSubmit: (payload: { username: string; password: string }) => void;
}
export const LoginScreen = ({ error, isSubmitting, onSubmit }: LoginScreenProps) => {
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!username.trim() || !password) {
return;
}
onSubmit({ username: username.trim(), password });
};
return (
<main className="flex h-[100dvh] items-center justify-center bg-emerald-50 px-4 py-8 text-slate-950">
<section className="w-full max-w-[380px] rounded-md border border-emerald-100 bg-white p-5 shadow-panel">
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-emerald-200 bg-emerald-100 text-emerald-900">
<LockKeyhole className="h-5 w-5" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-semibold leading-tight tracking-normal">登录 EdgeEver</h1>
<p className="mt-1 text-sm text-slate-500">自托管笔记工作区</p>
</div>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">账号</span>
<input
autoComplete="username"
className="h-10 w-full rounded-md border border-emerald-100 bg-emerald-50/50 px-3 text-sm outline-none transition focus:border-emerald-300 focus:bg-white"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">密码</span>
<input
autoComplete="current-password"
className="h-10 w-full rounded-md border border-emerald-100 bg-emerald-50/50 px-3 text-sm outline-none transition focus:border-emerald-300 focus:bg-white"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{error ? (
<div className="rounded-md border border-rose-100 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
<Button className="w-full justify-center" size="md" type="submit" variant="solid" disabled={isSubmitting}>
<LockKeyhole className="h-4 w-4" />
{isSubmitting ? "登录中" : "登录"}
</Button>
</form>
</section>
</main>
);
};
+431
View File
@@ -0,0 +1,431 @@
import { useRef, useState, useEffect, type DragEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react";
import type { MemoSummary } from "@edgeever/shared";
import { cn } from "@/lib/utils";
import type { MemoListDensity } from "@/lib/app-helpers";
import { getMemoTitle, MEMO_DRAG_MIME, setMemoDragPreview } from "@/lib/app-helpers";
const MEMO_LONG_PRESS_DELAY_MS = 520;
const MEMO_LONG_PRESS_MOVE_TOLERANCE_PX = 14;
const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches;
const formatMemoPreviewDate = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const memoDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
if (memoDay === today) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" }).format(date);
}
if (memoDay === today - 24 * 60 * 60 * 1000) {
return "昨天";
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "numeric",
day: "numeric",
}).format(date);
};
export const MemoCard = ({
memo,
selected,
checked,
dragMemoIds,
isTrashView,
selectionMode,
listDensity,
multiSelectKeyDown,
onOpen,
onDelete,
onRestore,
onOpenContextMenu,
onOpenSelectionContextMenu,
onOpenSelectionKeyboardContextMenu,
onOpenKeyboardContextMenu,
onToggle,
}: {
memo: MemoSummary;
selected: boolean;
checked: boolean;
dragMemoIds: string[];
isTrashView: boolean;
selectionMode: boolean;
listDensity: MemoListDensity;
multiSelectKeyDown: boolean;
onOpen: () => void;
onDelete: () => void;
onRestore: () => void;
onOpenContextMenu: (event: MouseEvent<HTMLElement>) => void;
onOpenSelectionContextMenu: (event: MouseEvent<HTMLElement>) => void;
onOpenSelectionKeyboardContextMenu: (target: HTMLElement) => void;
onOpenKeyboardContextMenu: (target: HTMLElement) => void;
onToggle: (event?: MouseEvent<HTMLElement>) => void;
}) => {
const handledModifierPointerRef = useRef(false);
const longPressTimerRef = useRef<number | null>(null);
const longPressPointRef = useRef<{ x: number; y: number } | null>(null);
const [modifierHoverActive, setModifierHoverActive] = useState(false);
const memoTitle = getMemoTitle(memo.title);
const memoExcerpt = memo.excerpt.trim() || "空笔记";
const showSelectionControl = selectionMode || checked || multiSelectKeyDown || modifierHoverActive;
const selectionControlLabel = checked ? `取消选择 ${memoTitle}` : `选择 ${memoTitle}`;
const shouldToggleSelection = (event: MouseEvent<HTMLElement>) =>
event.ctrlKey || event.metaKey || event.shiftKey || multiSelectKeyDown;
const clearLongPressTimer = () => {
if (longPressTimerRef.current === null) {
return;
}
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
};
const resetLongPress = () => {
clearLongPressTimer();
longPressPointRef.current = null;
};
useEffect(() => () => resetLongPress(), []);
useEffect(() => {
if (!modifierHoverActive) {
return;
}
const handleModifierKeyUp = (event: KeyboardEvent) => {
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {
setModifierHoverActive(false);
}
};
const handleWindowBlur = () => setModifierHoverActive(false);
window.addEventListener("keyup", handleModifierKeyUp);
window.addEventListener("blur", handleWindowBlur);
return () => {
window.removeEventListener("keyup", handleModifierKeyUp);
window.removeEventListener("blur", handleWindowBlur);
};
}, [modifierHoverActive]);
const markModifierPointerHandled = () => {
handledModifierPointerRef.current = true;
window.setTimeout(() => {
handledModifierPointerRef.current = false;
}, 450);
};
const handleModifierToggle = (event: MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
markModifierPointerHandled();
onToggle(event);
};
const handleLongPressSelection = () => {
markModifierPointerHandled();
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
navigator.vibrate(8);
}
onToggle();
};
const handleMouseDown = (event: MouseEvent<HTMLButtonElement>) => {
if (shouldToggleSelection(event)) {
handleModifierToggle(event);
}
};
const handleMouseMove = (event: MouseEvent<HTMLButtonElement>) => {
const modifierActive = event.ctrlKey || event.metaKey || event.shiftKey;
setModifierHoverActive((current) => (current === modifierActive ? current : modifierActive));
};
const handleMouseLeave = () => {
setModifierHoverActive(false);
};
const handlePointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.pointerType !== "touch" || selectionMode) {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Some browsers may already release capture during gesture cancellation.
}
clearLongPressTimer();
longPressPointRef.current = { x: event.clientX, y: event.clientY };
longPressTimerRef.current = window.setTimeout(() => {
longPressTimerRef.current = null;
longPressPointRef.current = null;
handleLongPressSelection();
}, MEMO_LONG_PRESS_DELAY_MS);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.pointerType !== "touch" || !longPressPointRef.current) {
return;
}
const xDistance = Math.abs(event.clientX - longPressPointRef.current.x);
const yDistance = Math.abs(event.clientY - longPressPointRef.current.y);
if (xDistance > MEMO_LONG_PRESS_MOVE_TOLERANCE_PX || yDistance > MEMO_LONG_PRESS_MOVE_TOLERANCE_PX) {
resetLongPress();
}
};
const handlePointerEnd = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.pointerType === "touch") {
try {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
} catch {
// Gesture cancellation can race with pointer capture release.
}
resetLongPress();
}
};
const handleDragStart = (event: DragEvent<HTMLElement>) => {
if (isTrashView || !isDesktopViewport() || dragMemoIds.length === 0) {
event.preventDefault();
return;
}
const dragLabel = dragMemoIds.length > 1 ? `移动 ${dragMemoIds.length} 条笔记` : `移动「${memoTitle}」`;
resetLongPress();
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(MEMO_DRAG_MIME, JSON.stringify(dragMemoIds));
event.dataTransfer.setData("text/plain", dragLabel);
setMemoDragPreview(event.dataTransfer, dragLabel);
};
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (handledModifierPointerRef.current) {
event.preventDefault();
event.stopPropagation();
handledModifierPointerRef.current = false;
return;
}
if (selectionMode) {
event.preventDefault();
event.stopPropagation();
onToggle(event);
return;
}
if (shouldToggleSelection(event)) {
handleModifierToggle(event);
return;
}
onOpen();
};
const handleContextMenu = (event: MouseEvent<HTMLButtonElement>) => {
if (longPressPointRef.current) {
event.preventDefault();
event.stopPropagation();
resetLongPress();
handleLongPressSelection();
return;
}
if (selectionMode) {
event.preventDefault();
event.stopPropagation();
onOpenSelectionContextMenu(event);
return;
}
event.preventDefault();
event.stopPropagation();
if (handledModifierPointerRef.current) {
return;
}
if (shouldToggleSelection(event)) {
markModifierPointerHandled();
onToggle(event);
return;
}
if (isDesktopViewport()) {
onOpenContextMenu(event);
}
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
const opensContextMenu = event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
if (!opensContextMenu) {
return;
}
if (selectionMode) {
event.preventDefault();
event.stopPropagation();
onOpenSelectionKeyboardContextMenu(event.currentTarget);
return;
}
event.preventDefault();
event.stopPropagation();
onOpenKeyboardContextMenu(event.currentTarget);
};
return (
<article
data-memo-id={memo.id}
draggable={!isTrashView}
onDragStart={handleDragStart}
className={cn(
"group overflow-hidden border border-slate-100 bg-white transition lg:rounded-none lg:border-x-0 lg:border-t-0 lg:border-slate-200 lg:shadow-none lg:last:border-b-0 transition-all duration-200 select-none",
listDensity === "compact" ? "rounded-md shadow-none" : "rounded-lg shadow-[0_4px_16px_rgba(15,23,42,0.045)]",
!selectionMode && selected
? "lg:bg-slate-100"
: checked
? "bg-[#f3f7f1] ring-1 ring-[#cbd9c4] lg:bg-slate-100 lg:ring-0"
: "active:bg-slate-50 lg:hover:bg-slate-50"
)}
>
<div className={cn("flex min-h-[132px] items-center", listDensity === "compact" && "min-h-[84px] lg:min-h-[76px]")}>
{showSelectionControl && (
<button
className="ml-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2 lg:ml-4 lg:h-8 lg:w-8"
type="button"
title={selectionControlLabel}
aria-label={selectionControlLabel}
aria-pressed={checked}
onClick={(event) => {
event.stopPropagation();
onToggle(event);
}}
>
<span
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full border transition-all duration-150",
checked
? "border-[#627f58] bg-[#627f58] text-white shadow-[0_4px_10px_rgba(98,127,88,0.22)]"
: "border-slate-300 bg-white text-transparent"
)}
aria-hidden="true"
>
<Check className="h-5 w-5 stroke-[3]" />
</span>
</button>
)}
<button
className={cn(
"min-w-0 flex-1 px-4 py-4 text-left touch-pan-y focus-visible:bg-slate-50 focus-visible:shadow-[inset_3px_0_0_#627f58] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500/60 [-webkit-touch-callout:none] lg:py-4 transition-all duration-200",
listDensity === "compact" && "py-3",
showSelectionControl && "pl-3 lg:pl-3",
!isTrashView && !multiSelectKeyDown && "lg:cursor-grab lg:active:cursor-grabbing",
multiSelectKeyDown && "cursor-copy"
)}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onPointerLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onLostPointerCapture={resetLongPress}
onClick={handleClick}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
title="Ctrl/Cmd 点击切换选择,Shift 点击连续选择,可拖到左侧笔记本移动,移动端长按进入选择"
>
<div className={cn("mb-2 flex min-w-0 items-center gap-1.5 text-base font-semibold leading-6 text-slate-900 lg:text-base", listDensity === "compact" && "mb-1")}>
{memo.isPinned && <Star className="h-4 w-4 shrink-0 fill-current text-[#627f58]" />}
<span className="min-w-0 truncate">{memoTitle}</span>
</div>
<div
className={cn(
"line-clamp-2 min-h-10 text-[15px] leading-5 text-slate-600",
listDensity === "compact" && "line-clamp-1 min-h-0 text-sm"
)}
>
{memoExcerpt}
</div>
<div className={cn("mt-5 flex flex-wrap items-center gap-2", listDensity === "compact" && "mt-2")}>
<time className="text-xs font-medium text-slate-400 lg:text-sm lg:font-normal lg:text-slate-500">{formatMemoPreviewDate(memo.updatedAt)}</time>
{memo.tags.slice(0, 3).map((tag) => (
<span key={tag} className="rounded-sm bg-emerald-50 px-1.5 py-0.5 text-xs font-medium text-emerald-700">
#{tag}
</span>
))}
</div>
</button>
{!selectionMode && (
<div
className={cn(
"mr-3 mt-4 hidden shrink-0 flex-col gap-1 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100 lg:flex",
selected && "opacity-100",
listDensity === "compact" && "lg:mt-3"
)}
>
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2"
type="button"
title="更多操作"
aria-label="更多操作"
aria-haspopup="menu"
onClick={(event) => {
event.stopPropagation();
onOpenKeyboardContextMenu(event.currentTarget);
}}
>
<MoreHorizontal className="h-4 w-4" />
</button>
<button
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2",
isTrashView ? "hover:bg-emerald-50 hover:text-emerald-700" : "hover:bg-rose-50 hover:text-rose-700"
)}
type="button"
title={isTrashView ? "恢复笔记" : "删除笔记"}
aria-label={isTrashView ? "恢复笔记" : "删除笔记"}
onClick={(event) => {
event.stopPropagation();
if (isTrashView) {
onRestore();
return;
}
onDelete();
}}
>
{isTrashView ? <RotateCcw className="h-4 w-4" /> : <Trash2 className="h-4 w-4" />}
</button>
</div>
)}
</div>
</article>
);
};
File diff suppressed because it is too large. Load diff
+396
View File
@@ -0,0 +1,396 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft,
Plus,
LayoutList,
Notebook as NotebookIcon,
Tags,
Archive,
Trash2,
KeyRound,
LogOut,
CloudOff,
AlertTriangle,
RefreshCw,
CheckCircle2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
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, SyncQueueSummary } from "@/lib/app-helpers";
import { buildNotebookTree, hasEdgeEverDragData } from "@/lib/app-helpers";
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 transition-all duration-200",
active ? "bg-emerald-50 text-emerald-800" : "text-slate-700 hover:bg-slate-50 hover:text-slate-950"
)}
type="button"
aria-current={active ? "page" : undefined}
onClick={onClick}
>
<span className="shrink-0">{icon}</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
);
const getSyncStatusLabel = (summary: SyncQueueSummary, isOnline: boolean, isSyncing: boolean) => {
if (!isOnline) {
return summary.total > 0 ? `离线,${summary.total} 项待同步` : "离线";
}
if (isSyncing || summary.syncing > 0) {
return "同步中";
}
if (summary.conflict > 0) {
return `${summary.conflict} 项同步冲突`;
}
if (summary.error > 0) {
return `${summary.error} 项等待重试`;
}
if (summary.pending > 0) {
return `${summary.pending} 项待同步`;
}
return "已同步";
};
const SyncStatusBar = ({
summary,
isOnline,
isSyncing,
onSyncNow,
}: {
summary: SyncQueueSummary;
isOnline: boolean;
isSyncing: boolean;
onSyncNow: () => void;
}) => {
const hasQueuedWork = summary.total > 0;
const label = getSyncStatusLabel(summary, isOnline, isSyncing);
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-emerald-200 bg-emerald-50 text-emerald-800"
: "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="立即同步"
aria-label="立即同步"
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,
onOpenSettings,
onCreateMemo,
canCreateMemo,
isCreatingMemo,
syncSummary,
isOnline,
isSyncingQueuedChanges,
onSyncQueuedChanges,
imageCompressionEnabled,
onImageCompressionChange,
authRequired,
onLogout,
isLoggingOut,
}: {
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;
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;
}) => {
const notebookScrollRef = useRef<HTMLDivElement | null>(null);
const notebookDragScrollFrameRef = useRef<number | null>(null);
const [expandSiblingsRequest, setExpandSiblingsRequest] = useState<{ parentId: string | null; token: number } | null>(null);
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 tree = buildNotebookTree(notebooks);
const isLoading = notebooksQuery.isLoading;
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:h-16 lg:items-center lg:pb-0 lg:pt-0">
<div>
<div className="text-base font-semibold tracking-normal lg:hidden">笔记本</div>
<div className="hidden text-base font-semibold tracking-normal lg:block">EdgeEver</div>
<div className="text-xs text-slate-500">{user?.username ?? "边缘笔记工作区"}</div>
</div>
<div className="flex items-center gap-1">
<Button className="lg:hidden" size="icon" variant="ghost" title="返回笔记列表" aria-label="返回笔记列表" onClick={onBackToList}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" title="新建笔记本" aria-label="新建笔记本" onClick={() => onCreateNotebook(null)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</header>
<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-emerald-100 bg-white shadow-[0_10px_28px_rgba(15,23,42,0.08)] lg:flex">
<button
className="flex h-14 min-w-0 flex-1 items-center gap-3 px-3 text-left transition hover:bg-emerald-50 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
title="新建笔记"
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_rgba(5,150,105,0.28)]">
<Plus className="h-6 w-6" />
</span>
<span className="min-w-0 truncate text-sm font-semibold text-slate-950">新建笔记</span>
</button>
</div>
<nav className="mb-3 space-y-1" aria-label="笔记入口">
<SidebarNavButton
active={view === "notebook" && selectedNotebookId === null}
icon={<LayoutList className="h-4 w-4" />}
label="全部笔记"
onClick={onBackToList}
/>
</nav>
<div className="mb-2 flex items-center justify-between gap-2 px-2 text-xs font-semibold uppercase text-slate-500">
<span className="inline-flex min-w-0 items-center gap-2">
<NotebookIcon className="h-4 w-4" />
笔记本
</span>
<button
className="flex h-6 w-6 items-center justify-center rounded-md text-slate-500 transition hover:bg-emerald-50 hover:text-emerald-800 lg:hidden"
type="button"
title="新建笔记本"
aria-label="新建笔记本"
onClick={() => onCreateNotebook(null)}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{isLoading ? (
<div className="mb-4 px-2 py-3 text-sm text-slate-500">加载中</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>
)}
<nav className="space-y-1 border-t border-slate-100 pt-3" aria-label="辅助入口">
<SidebarNavButton icon={<Tags className="h-4 w-4" />} label="标签" onClick={onOpenTags} />
<SidebarNavButton icon={<Archive className="h-4 w-4" />} label="附件" onClick={onOpenAssets} />
<SidebarNavButton
active={view === "trash"}
icon={<Trash2 className="h-4 w-4" />}
label="回收站"
onClick={onOpenTrash}
/>
<SidebarNavButton icon={<KeyRound className="h-4 w-4" />} label="MCP Token" onClick={onOpenSettings} />
</nav>
</div>
<footer className="border-t border-slate-200 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 bg-white/70 backdrop-blur-sm">
<SyncStatusBar
summary={syncSummary}
isOnline={isOnline}
isSyncing={isSyncingQueuedChanges}
onSyncNow={onSyncQueuedChanges}
/>
<label className="mb-3 flex min-h-10 items-center justify-between gap-3 rounded-md border border-slate-200 bg-white px-3 py-2 cursor-pointer hover:bg-slate-50/50 transition duration-200">
<span className="min-w-0 text-sm font-medium text-slate-700 select-none">是否压缩笔记内图片</span>
<Switch
checked={imageCompressionEnabled}
onCheckedChange={onImageCompressionChange}
aria-label="是否压缩笔记内图片"
/>
</label>
{authRequired && (
<Button className="w-full justify-center" size="md" variant="ghost" onClick={onLogout} disabled={isLoggingOut}>
<LogOut className="h-4 w-4" />
退出登录
</Button>
)}
</footer>
</div>
);
};
@@ -0,0 +1,338 @@
import { useState, useEffect, useRef, type DragEvent } from "react";
import { ChevronDown, ChevronRight, Notebook as NotebookIcon, Plus, Pencil, Trash2 } from "lucide-react";
import type { NotebookNode, NotebookDropPosition } from "@/lib/app-helpers";
import {
hasMemoDragData,
hasNotebookDragData,
getMemoDragIds,
getNotebookDropPosition,
notebookTreeContainsId,
focusNotebookTreeButton,
NOTEBOOK_DRAG_MIME,
} from "@/lib/app-helpers";
import { cn } from "@/lib/utils";
import type { Notebook } from "@edgeever/shared";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
const NOTEBOOK_DRAG_EXPAND_DELAY_MS = 520;
export const NotebookTreeItem = ({
node,
depth,
selectedNotebookId,
onSelect,
onCreateNotebook,
onRenameNotebook,
onDeleteNotebook,
onMoveNotebook,
onMoveMemos,
onDragScroll,
expandSiblingsRequest,
onExpandSiblings,
}: {
node: NotebookNode;
depth: number;
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;
onDragScroll: (event: DragEvent<HTMLDivElement>) => void;
expandSiblingsRequest: { parentId: string | null; token: number } | null;
onExpandSiblings: (parentId: string | null) => void;
}) => {
const [open, setOpen] = useState(true);
const hasChildren = node.children.length > 0;
const selected = node.id === selectedNotebookId;
const isInbox = node.slug === "inbox";
const hasSelectedDescendant = selectedNotebookId ? notebookTreeContainsId(node.children, selectedNotebookId) : false;
const [dropPosition, setDropPosition] = useState<NotebookDropPosition | null>(null);
const expandTimerRef = useRef<number | null>(null);
const clearExpandTimer = () => {
if (expandTimerRef.current === null) {
return;
}
window.clearTimeout(expandTimerRef.current);
expandTimerRef.current = null;
};
useEffect(() => () => clearExpandTimer(), []);
useEffect(() => {
if (hasSelectedDescendant) {
setOpen(true);
}
}, [hasSelectedDescendant]);
useEffect(() => {
if (!expandSiblingsRequest || expandSiblingsRequest.parentId !== node.parentId || !hasChildren) {
return;
}
setOpen(true);
}, [expandSiblingsRequest, hasChildren, node.parentId]);
const scheduleDragExpand = (position: NotebookDropPosition) => {
if (!hasChildren || open || position !== "inside") {
clearExpandTimer();
return;
}
if (expandTimerRef.current !== null) {
return;
}
expandTimerRef.current = window.setTimeout(() => {
expandTimerRef.current = null;
setOpen(true);
}, NOTEBOOK_DRAG_EXPAND_DELAY_MS);
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
onDragScroll(event);
if (hasMemoDragData(event.dataTransfer)) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
setDropPosition("inside");
scheduleDragExpand("inside");
return;
}
if (!hasNotebookDragData(event.dataTransfer)) {
return;
}
const position = getNotebookDropPosition(event);
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDropPosition(position);
scheduleDragExpand(position);
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const memoIds = getMemoDragIds(event.dataTransfer);
const notebookId = event.dataTransfer.getData(NOTEBOOK_DRAG_MIME);
const position = getNotebookDropPosition(event);
setDropPosition(null);
clearExpandTimer();
if (memoIds.length > 0) {
onMoveMemos(memoIds, node.id);
setOpen(true);
return;
}
if (!notebookId || notebookId === node.id) {
return;
}
onMoveNotebook(notebookId, node.id, position);
setOpen(true);
};
return (
<div>
<ContextMenu>
<ContextMenuTrigger asChild>
<div
data-notebook-id={node.id}
className={cn(
"group flex h-9 items-center gap-1 rounded-md px-2 text-sm transition-all duration-200 select-none",
selected
? "bg-[#f3f7f1] font-medium text-[#526d49]"
: hasSelectedDescendant
? "bg-[#f3f7f1]/70 text-[#526d49] hover:bg-[#f3f7f1]"
: "text-slate-700 hover:bg-slate-50",
dropPosition === "inside" && "ring-2 ring-[#9eb093]",
dropPosition === "inside" && hasChildren && !open && "bg-[#f3f7f1]",
dropPosition === "before" && "shadow-[inset_0_2px_0_0_#627f58]",
dropPosition === "after" && "shadow-[inset_0_-2px_0_0_#627f58]"
)}
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(NOTEBOOK_DRAG_MIME, node.id);
event.dataTransfer.setData("text/plain", node.id);
}}
onDragOver={handleDragOver}
onDragLeave={() => {
setDropPosition(null);
clearExpandTimer();
}}
onDragEnd={clearExpandTimer}
onDrop={handleDrop}
style={{ paddingLeft: `${8 + depth * 14}px` }}
>
{hasChildren ? (
<button
className="flex h-6 w-5 items-center justify-center rounded hover:bg-slate-100/50 transition-colors"
type="button"
onClick={() => setOpen((value) => !value)}
title="展开/折叠"
aria-label={open ? `收起 ${node.name}` : `展开 ${node.name}`}
aria-expanded={open}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="h-6 w-5 shrink-0" aria-hidden="true" />
)}
<button
data-notebook-tree-button
className="flex min-w-0 flex-1 items-center gap-2 text-left"
type="button"
aria-label={selected ? `当前:${node.name}` : `切换到 ${node.name}`}
aria-current={selected ? "page" : undefined}
aria-expanded={hasChildren ? open : undefined}
onClick={() => onSelect(node.id)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
event.preventDefault();
event.stopPropagation();
focusNotebookTreeButton(
event.currentTarget,
event.key === "Home" ? "first" : event.key === "End" ? "last" : event.key === "ArrowDown" ? "next" : "previous"
);
return;
}
if (event.key === "*" || event.key === "Multiply") {
event.preventDefault();
event.stopPropagation();
onExpandSiblings(node.parentId);
return;
}
if (event.key === "ArrowRight" && hasChildren && !open) {
event.preventDefault();
event.stopPropagation();
setOpen(true);
return;
}
if (event.key === "ArrowLeft" && hasChildren && open) {
event.preventDefault();
event.stopPropagation();
setOpen(false);
return;
}
}}
>
<NotebookIcon className={cn("h-4 w-4 shrink-0 transition-colors duration-200", selected || hasSelectedDescendant ? "text-[#627f58]" : "text-slate-500")} />
<span className="truncate">{node.name}</span>
</button>
<button
className={cn(
"hidden h-6 w-6 items-center justify-center rounded-md group-focus-within:flex group-hover:flex transition-colors duration-150",
selected ? "hover:bg-[#e6efe1]" : "hover:bg-slate-100"
)}
type="button"
title="新建子笔记本"
aria-label={`在 ${node.name} 下新建子笔记本`}
onClick={(event) => {
event.stopPropagation();
onCreateNotebook(node.id);
}}
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
className={cn(
"hidden h-6 w-6 items-center justify-center rounded-md group-focus-within:flex group-hover:flex transition-colors duration-150",
selected ? "hover:bg-[#e6efe1]" : "hover:bg-slate-100"
)}
type="button"
title="重命名笔记本"
aria-label={`重命名 ${node.name}`}
onClick={(event) => {
event.stopPropagation();
onRenameNotebook(node);
}}
>
<Pencil className="h-3.5 w-3.5" />
</button>
{!isInbox ? (
<button
className="hidden h-6 w-6 items-center justify-center rounded-md text-rose-600 hover:bg-rose-50 group-focus-within:flex group-hover:flex transition-colors duration-150"
type="button"
title="删除笔记本"
aria-label={`删除 ${node.name}`}
onClick={(event) => {
event.stopPropagation();
onDeleteNotebook(node);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48 bg-white border border-slate-200 rounded-md py-1 shadow-md">
<ContextMenuItem
className="flex h-9 items-center gap-2 px-3 text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={() => onCreateNotebook(node.id)}
>
<Plus className="h-4 w-4" />
新建子笔记本
</ContextMenuItem>
<ContextMenuItem
className="flex h-9 items-center gap-2 px-3 text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={() => onRenameNotebook(node)}
>
<Pencil className="h-4 w-4" />
重命名
</ContextMenuItem>
{!isInbox && (
<>
<ContextMenuSeparator className="my-1 h-px bg-slate-100" />
<ContextMenuItem
className="flex h-9 items-center gap-2 px-3 text-sm text-rose-700 hover:bg-rose-50 cursor-pointer outline-none"
onClick={() => onDeleteNotebook(node)}
>
<Trash2 className="h-4 w-4" />
删除笔记本
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
{hasChildren && open ? (
<div className="mt-1 space-y-1">
{node.children.map((child) => (
<NotebookTreeItem
key={child.id}
node={child}
depth={depth + 1}
selectedNotebookId={selectedNotebookId}
onSelect={onSelect}
onCreateNotebook={onCreateNotebook}
onRenameNotebook={onRenameNotebook}
onDeleteNotebook={onDeleteNotebook}
onMoveNotebook={onMoveNotebook}
onMoveMemos={onMoveMemos}
onDragScroll={onDragScroll}
expandSiblingsRequest={expandSiblingsRequest}
onExpandSiblings={onExpandSiblings}
/>
))}
</div>
) : null}
</div>
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,96 @@
import { useQuery } from "@tanstack/react-query";
import { Archive, HardDrive, ImageIcon, File as FileIcon, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { api } from "@/lib/api";
import { formatDateTime } from "@/lib/utils";
export const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** exponent;
return `${exponent === 0 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[exponent]}`;
};
export const AssetsDialog = ({ onClose }: { onClose: () => void }) => {
const resourcesQuery = useQuery({
queryKey: ["resources"],
queryFn: () => api.listResources(),
});
const resources = resourcesQuery.data?.resources ?? [];
const summary = resourcesQuery.data?.summary ?? {
totalCount: 0,
totalBytes: 0,
imageCount: 0,
attachmentCount: 0,
};
return (
<Dialog open={true} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-[760px] p-0 overflow-hidden border border-slate-200 bg-white shadow-lg rounded-lg">
<DialogHeader className="flex flex-row items-start justify-between gap-3 border-b border-slate-200 px-5 py-4 text-left">
<div className="min-w-0">
<DialogTitle className="flex items-center gap-2 text-base font-semibold text-slate-950">
<Archive className="h-4 w-4 text-emerald-700" />
附件
</DialogTitle>
<DialogDescription className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="inline-flex items-center gap-1">
<HardDrive className="h-3.5 w-3.5" />
{formatBytes(summary.totalBytes)}
</span>
<span>{summary.totalCount} files</span>
<span>{summary.imageCount} images</span>
</DialogDescription>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto p-5">
{resourcesQuery.isLoading ? (
<div className="px-2 py-8 text-center text-sm text-slate-500">加载中</div>
) : resources.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
暂无附件
</div>
) : (
<div className="space-y-2">
{resources.map((resource) => (
<a
key={resource.id}
className="flex min-h-16 items-center gap-3 rounded-md border border-slate-200 bg-white px-3 py-2 text-left transition hover:border-slate-300 hover:bg-slate-50"
href={resource.url}
target="_blank"
rel="noreferrer"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-white text-emerald-700">
{resource.kind === "image" ? <ImageIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-950">
{resource.filename || resource.id}
</span>
<span className="mt-1 block truncate text-xs text-slate-500">
{formatBytes(resource.byteSize)} · {resource.mimeType ?? resource.kind} ·{" "}
{formatDateTime(resource.createdAt)}
</span>
<span className="mt-1 block truncate text-xs text-slate-400">
{resource.memoDeleted
? "已删除笔记"
: resource.memoTitle || resource.memoExcerpt || resource.memoId}
</span>
</span>
<ExternalLink className="h-4 w-4 shrink-0 text-slate-400" />
</a>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,186 @@
import { useState, useEffect, useRef } from "react";
import { AlertTriangle, ShieldCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { Notebook } from "@edgeever/shared";
// Types derived from App.tsx
export type MemoDeleteConfirmation = { kind: "single" | "bulk"; memoIds: string[]; permanent: boolean };
export type NotebookNameDialogState =
| { mode: "create"; parentId: string | null }
| { mode: "rename"; notebook: Notebook };
export const AppConfirmDialog = ({
cancelLabel = "取消",
confirmLabel,
description,
hideCancel = false,
isWorking = false,
title,
tone = "danger",
closeOnBrowserBack,
onCancel,
onConfirm,
}: {
cancelLabel?: string;
confirmLabel: string;
description: string;
hideCancel?: boolean;
isWorking?: boolean;
title: string;
tone?: "danger" | "neutral" | "primary";
closeOnBrowserBack?: boolean;
onCancel: () => void;
onConfirm: () => void;
}) => {
const toneClassName =
tone === "danger"
? "bg-rose-50 text-rose-700"
: tone === "primary"
? "bg-emerald-50 text-emerald-700"
: "bg-slate-100 text-slate-600";
const confirmVariant = tone === "danger" ? "danger" : "solid";
const Icon = tone === "danger" ? AlertTriangle : ShieldCheck;
return (
<Dialog open={true} onOpenChange={(open) => { if (!open && !isWorking) onCancel(); }}>
<DialogContent className="max-w-md p-0 overflow-hidden border border-slate-200 bg-white shadow-lg rounded-lg">
<DialogHeader className="flex flex-row items-start gap-4 border-b border-slate-200 px-5 py-5 text-left">
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-full", toneClassName)}>
<Icon className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<DialogTitle className="text-base font-semibold text-slate-950">
{title}
</DialogTitle>
<DialogDescription className="mt-1 text-sm leading-5 text-slate-500">
{description}
</DialogDescription>
</div>
</DialogHeader>
<DialogFooter className="flex flex-col-reverse gap-2 px-5 py-4 sm:flex-row sm:justify-end border-t border-slate-50 bg-slate-50/50">
{!hideCancel && (
<Button className="justify-center" variant="outline" onClick={onCancel} disabled={isWorking}>
{cancelLabel}
</Button>
)}
<Button className="justify-center" variant={confirmVariant} onClick={onConfirm} disabled={isWorking}>
{isWorking ? "处理中" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const MemoDeleteConfirmDialog = ({
confirmation,
isDeleting,
onCancel,
onConfirm,
}: {
confirmation: MemoDeleteConfirmation;
isDeleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}) => {
const count = confirmation.memoIds.length;
const isBulk = confirmation.kind === "bulk" || count > 1;
const title = confirmation.permanent ? (isBulk ? `永久删除 ${count} 条笔记` : "永久删除笔记") : isBulk ? `删除 ${count} 条笔记` : "删除笔记";
const description = confirmation.permanent ? "这个操作不可恢复。" : "删除后可以在回收站恢复。";
const confirmLabel = confirmation.permanent ? "永久删除" : "删除";
return (
<AppConfirmDialog
title={title}
description={description}
confirmLabel={confirmLabel}
tone="danger"
isWorking={isDeleting}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
};
export const NotebookNameDialog = ({
dialog,
isSaving,
onCancel,
onSubmit,
}: {
dialog: NotebookNameDialogState;
isSaving: boolean;
onCancel: () => void;
onSubmit: (name: string) => void;
}) => {
const initialName = dialog.mode === "rename" ? dialog.notebook.name : "";
const [name, setName] = useState(initialName);
const inputRef = useRef<HTMLInputElement | null>(null);
const trimmedName = name.trim();
const unchanged = dialog.mode === "rename" && trimmedName === dialog.notebook.name;
const title = dialog.mode === "create" ? "新建笔记本" : "重命名笔记本";
const submitLabel = dialog.mode === "create" ? "创建" : "保存";
useEffect(() => {
setName(initialName);
}, [initialName]);
useEffect(() => {
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
}, []);
return (
<Dialog open={true} onOpenChange={(open) => { if (!open && !isSaving) onCancel(); }}>
<DialogContent className="max-w-md p-0 overflow-hidden border border-slate-200 bg-white shadow-lg rounded-lg">
<form
onSubmit={(event) => {
event.preventDefault();
if (!trimmedName || unchanged || isSaving) {
return;
}
onSubmit(trimmedName);
}}
>
<DialogHeader className="flex flex-row items-start justify-between gap-3 border-b border-slate-200 px-5 py-5 text-left">
<div className="min-w-0">
<DialogTitle className="text-base font-semibold text-slate-950">
{title}
</DialogTitle>
<DialogDescription className="mt-1 text-sm leading-5 text-slate-500">
{dialog.mode === "create" ? "创建一个新的笔记本,用来整理相关笔记。" : "更新后会立即同步到笔记本树。"}
</DialogDescription>
</div>
</DialogHeader>
<div className="px-5 py-5">
<label className="block text-xs font-semibold uppercase text-slate-500" htmlFor="notebook-name-input">
名称
</label>
<input
id="notebook-name-input"
ref={inputRef}
className="mt-2 h-11 w-full rounded-md border border-slate-200 bg-white px-3 text-base text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-emerald-300 focus:ring-2 focus:ring-emerald-500/20"
value={name}
disabled={isSaving}
maxLength={80}
placeholder="笔记本名称"
onChange={(event) => setName(event.target.value)}
/>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 px-5 py-4 sm:flex-row sm:justify-end border-t border-slate-50 bg-slate-50/50">
<Button className="justify-center" type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
取消
</Button>
<Button className="justify-center" type="submit" variant="solid" disabled={!trimmedName || unchanged || isSaving}>
{isSaving ? "保存中" : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,182 @@
import { useState, useMemo, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { History, RotateCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { api } from "@/lib/api";
import { cn, formatDateTime } from "@/lib/utils";
import { getMemoTitle } from "@/lib/app-helpers";
import { AppConfirmDialog } from "./ConfirmDialogs";
import type { MemoDetail } from "@edgeever/shared";
const summarizeMarkdownDiff = (left: string, right: string) => {
const leftLines = left.split("\n");
const rightLines = right.split("\n");
const maxLines = Math.max(leftLines.length, rightLines.length);
let changed = 0;
for (let index = 0; index < maxLines; index += 1) {
if ((leftLines[index] ?? "") !== (rightLines[index] ?? "")) {
changed += 1;
}
}
return { changed };
};
const formatRevisionActor = (actor: string) => {
if (actor.startsWith("user:")) {
return "user";
}
if (actor.startsWith("agent:")) {
return "agent";
}
return actor || "system";
};
export const RevisionPreview = ({ title, markdown }: { title: string; markdown: string }) => (
<div className="min-h-[260px] border-b border-slate-200 p-4 sm:border-b-0 sm:border-r">
<div className="mb-3 text-xs font-semibold uppercase text-slate-500">{title}</div>
<pre className="max-h-[54dvh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-slate-200 bg-slate-50 p-3 text-sm leading-6 text-slate-700">
{markdown || "空笔记"}
</pre>
</div>
);
export const RevisionHistoryDialog = ({
memo,
currentMarkdown,
onClose,
onRestored,
}: {
memo: MemoDetail;
currentMarkdown: string;
onClose: () => void;
onRestored: (memo: MemoDetail) => Promise<void>;
}) => {
const [selectedRevisionId, setSelectedRevisionId] = useState<string | null>(null);
const [restoreRevisionConfirmationId, setRestoreRevisionConfirmationId] = useState<string | null>(null);
const revisionsQuery = useQuery({
queryKey: ["memo-revisions", memo.id],
queryFn: () => api.listMemoRevisions(memo.id),
});
const revisions = revisionsQuery.data?.revisions ?? [];
const selectedRevision =
revisions.find((revision) => revision.id === selectedRevisionId) ?? revisions[0] ?? null;
const diffSummary = useMemo(
() => summarizeMarkdownDiff(selectedRevision?.contentMarkdown ?? "", currentMarkdown),
[currentMarkdown, selectedRevision?.contentMarkdown]
);
const restoreMutation = useMutation({
mutationFn: (revisionId: string) => api.restoreMemoRevision(memo.id, revisionId),
onSuccess: async (data) => {
setRestoreRevisionConfirmationId(null);
await onRestored(data.memo);
},
});
useEffect(() => {
if (!selectedRevisionId && revisions.length > 0) {
setSelectedRevisionId(revisions[0].id);
}
}, [revisions, selectedRevisionId]);
return (
<Dialog open={true} onOpenChange={(open) => { if (!open && !restoreRevisionConfirmationId) onClose(); }}>
<DialogContent className="max-w-[980px] p-0 overflow-hidden border border-slate-200 bg-white shadow-lg rounded-lg">
<DialogHeader className="flex flex-row items-start justify-between gap-3 border-b border-slate-200 px-5 py-4 text-left">
<div className="min-w-0">
<DialogTitle className="flex items-center gap-2 text-base font-semibold text-slate-950">
<History className="h-4 w-4 text-emerald-700" />
版本历史
</DialogTitle>
<DialogDescription className="mt-1 truncate text-xs text-slate-500">
{getMemoTitle(memo.title)}
</DialogDescription>
</div>
</DialogHeader>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] sm:grid-cols-[280px_minmax(0,1fr)] sm:grid-rows-1">
<aside className="min-h-0 border-b border-slate-200 p-3 sm:border-b-0 sm:border-r max-h-[220px] sm:max-h-[60vh] overflow-y-auto">
{revisionsQuery.isLoading ? (
<div className="px-2 py-8 text-center text-sm text-slate-500">加载中</div>
) : revisions.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
暂无历史版本
</div>
) : (
<div className="space-y-2">
{revisions.map((revision) => (
<button
key={revision.id}
className={cn(
"block w-full rounded-md border px-3 py-2 text-left transition",
selectedRevision?.id === revision.id
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
)}
onClick={() => setSelectedRevisionId(revision.id)}
>
<span className="block text-sm font-semibold text-slate-950">
Revision {revision.revision}
</span>
<span className="mt-1 block truncate text-xs text-slate-500">
{formatDateTime(revision.createdAt)}
</span>
<span className="mt-1 block truncate text-xs text-slate-400">
{formatRevisionActor(revision.createdBy)}
</span>
</button>
))}
</div>
)}
</aside>
<div className="flex min-h-0 flex-col">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-4 py-3 bg-slate-50/50">
<div className="text-xs font-medium text-slate-500">
{selectedRevision ? `${diffSummary.changed} changed lines` : "No revision selected"}
</div>
<Button
size="sm"
variant="solid"
disabled={!selectedRevision || memo.isDeleted || restoreMutation.isPending}
onClick={() => {
if (selectedRevision) {
setRestoreRevisionConfirmationId(selectedRevision.id);
}
}}
>
<RotateCcw className="h-4 w-4" />
恢复该版本
</Button>
</div>
<div className="grid min-h-0 flex-1 gap-0 overflow-y-auto sm:grid-cols-2 max-h-[50vh]">
<RevisionPreview title="历史版本" markdown={selectedRevision?.contentMarkdown ?? ""} />
<RevisionPreview title="当前内容" markdown={currentMarkdown} />
</div>
</div>
</div>
</DialogContent>
{restoreRevisionConfirmationId && (
<AppConfirmDialog
title="恢复到这个历史版本"
description="当前内容会被这个历史版本替换,恢复后仍会产生新的历史记录。"
confirmLabel="恢复"
isWorking={restoreMutation.isPending}
tone="primary"
onCancel={() => setRestoreRevisionConfirmationId(null)}
onConfirm={() => restoreMutation.mutate(restoreRevisionConfirmationId)}
/>
)}
</Dialog>
);
};
@@ -0,0 +1,207 @@
import { useState, type FormEvent } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { KeyRound, ShieldCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { api } from "@/lib/api";
import { cn, formatDateTime } from "@/lib/utils";
import { AppConfirmDialog } from "./ConfirmDialogs";
import type { ApiToken } from "@edgeever/shared";
const DEFAULT_TOKEN_SCOPES = ["read:notebooks", "read:memos", "read:tags"];
export const SettingsDialog = ({ onClose }: { onClose: () => void }) => {
const queryClient = useQueryClient();
const [name, setName] = useState("MCP Agent");
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(() => new Set(DEFAULT_TOKEN_SCOPES));
const [createdToken, setCreatedToken] = useState<{ token: string; apiToken: ApiToken } | null>(null);
const [tokenRevokeConfirmation, setTokenRevokeConfirmation] = useState<ApiToken | null>(null);
const tokensQuery = useQuery({
queryKey: ["api-tokens"],
queryFn: () => api.listApiTokens(),
});
const availableScopes = tokensQuery.data?.availableScopes ?? [
"read:notebooks",
"write:notebooks",
"read:memos",
"write:memos",
"read:resources",
"write:resources",
"read:tags",
"write:tags",
];
const createMutation = useMutation({
mutationFn: api.createApiToken,
onSuccess: async (data) => {
setCreatedToken(data);
setName("");
setSelectedScopes(new Set(DEFAULT_TOKEN_SCOPES));
await queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
},
});
const revokeMutation = useMutation({
mutationFn: api.revokeApiToken,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
},
});
const tokens = tokensQuery.data?.apiTokens ?? [];
const toggleScope = (scope: string) => {
setSelectedScopes((current) => {
const next = new Set(current);
if (next.has(scope)) {
next.delete(scope);
} else {
next.add(scope);
}
return next;
});
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const scopes = Array.from(selectedScopes);
if (!name.trim() || scopes.length === 0) {
return;
}
createMutation.mutate({ name: name.trim(), scopes });
};
return (
<Dialog open={true} onOpenChange={(open) => { if (!open && !tokenRevokeConfirmation) onClose(); }}>
<DialogContent className="max-w-[820px] p-0 overflow-hidden border border-slate-200 bg-white shadow-lg rounded-lg">
<DialogHeader className="flex flex-row items-start justify-between gap-3 border-b border-slate-200 px-5 py-4 text-left">
<div className="min-w-0">
<DialogTitle className="flex items-center gap-2 text-base font-semibold text-slate-950">
<KeyRound className="h-4 w-4 text-emerald-700" />
设置
</DialogTitle>
<DialogDescription className="mt-1 truncate text-xs text-slate-500">
API Token / MCP
</DialogDescription>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto p-5">
{createdToken && (
<div className="mb-4 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-emerald-950">
<ShieldCheck className="h-4 w-4" />
Token 已生成
</div>
<div className="flex gap-2">
<input
className="h-9 min-w-0 flex-1 rounded-md border border-emerald-200 bg-white px-3 font-mono text-xs text-slate-900 outline-none"
readOnly
value={createdToken.token}
/>
<Button
size="sm"
variant="solid"
type="button"
onClick={() => void navigator.clipboard?.writeText(createdToken.token)}
>
复制
</Button>
</div>
<div className="mt-2 text-xs text-emerald-800">明文 Token 只显示这一次。</div>
</div>
)}
<form className="mb-5 rounded-md border border-slate-200 bg-slate-50 p-3" onSubmit={handleSubmit}>
<div className="mb-3 flex flex-col gap-2 sm:flex-row">
<input
className="h-9 min-w-0 flex-1 rounded-md border border-slate-200 bg-white px-3 text-sm outline-none focus:border-emerald-300"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Token 名称"
/>
<Button size="md" variant="solid" type="submit" disabled={createMutation.isPending}>
<KeyRound className="h-4 w-4" />
生成 Token
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{availableScopes.map((scope) => (
<label
key={scope}
className="flex min-h-9 items-center gap-2 rounded-md border border-slate-200 bg-white px-2 text-sm text-slate-700"
>
<input
type="checkbox"
checked={selectedScopes.has(scope)}
onChange={() => toggleScope(scope)}
className="h-4 w-4 shrink-0 rounded border-emerald-300 text-emerald-600"
/>
<span className="min-w-0 truncate font-mono text-xs">{scope}</span>
</label>
))}
</div>
</form>
<div className="space-y-2">
{tokensQuery.isLoading ? (
<div className="px-2 py-8 text-center text-sm text-slate-500">加载中</div>
) : tokens.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
暂无 API Token
</div>
) : (
tokens.map((token) => (
<div
key={token.id}
className={cn(
"flex min-h-16 items-center gap-3 rounded-md border px-3 py-2",
token.isRevoked ? "border-slate-200 bg-slate-50 opacity-70" : "border-slate-200 bg-white"
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-950">{token.name}</span>
<span className="mt-1 block truncate text-xs text-slate-500">
{token.scopes.join(", ") || "no scopes"}
</span>
<span className="mt-1 block text-xs text-slate-400">
{token.lastUsedAt ? `Last used ${formatDateTime(token.lastUsedAt)}` : "Never used"}
</span>
</span>
<Button
size="sm"
variant="danger"
disabled={token.isRevoked || revokeMutation.isPending}
onClick={() => setTokenRevokeConfirmation(token)}
>
撤销
</Button>
</div>
))
)}
</div>
</div>
</DialogContent>
{tokenRevokeConfirmation && (
<AppConfirmDialog
title={`撤销 Token「${tokenRevokeConfirmation.name}」`}
description="撤销后,正在使用这个 Token 的 MCP 或 Agent 客户端将无法继续访问。"
confirmLabel="撤销"
isWorking={revokeMutation.isPending}
tone="danger"
onCancel={() => setTokenRevokeConfirmation(null)}
onConfirm={() => {
revokeMutation.mutate(tokenRevokeConfirmation.id, {
onSuccess: () => setTokenRevokeConfirmation(null),
});
}}
/>
)}
</Dialog>
);
};
@@ -0,0 +1,194 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Tags, X, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { api } from "@/lib/api";
import { cn, formatDateTime } from "@/lib/utils";
import { AppConfirmDialog } from "./ConfirmDialogs";
import type { TagSummary } from "@edgeever/shared";
export const TagsDialog = ({ onClose }: { onClose: () => void }) => {
const queryClient = useQueryClient();
const [editingTagName, setEditingTagName] = useState<string | null>(null);
const [editingTagValue, setEditingTagValue] = useState("");
const [tagDeleteConfirmation, setTagDeleteConfirmation] = useState<TagSummary | null>(null);
const tagsQuery = useQuery({
queryKey: ["tags"],
queryFn: () => api.listTags(),
});
const renameMutation = useMutation({
mutationFn: ({ tag, name }: { tag: string; name: string }) => api.renameTag(tag, name),
onSuccess: async () => {
setEditingTagName(null);
setEditingTagValue("");
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["tags"] }),
queryClient.invalidateQueries({ queryKey: ["memos"] }),
queryClient.invalidateQueries({ queryKey: ["memo"] }),
]);
},
});
const deleteMutation = useMutation({
mutationFn: api.deleteTag,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["tags"] }),
queryClient.invalidateQueries({ queryKey: ["memos"] }),
queryClient.invalidateQueries({ queryKey: ["memo"] }),
]);
},
});
const tags = tagsQuery.data?.tags ?? [];
const handleRename = (tag: TagSummary) => {
setEditingTagName(tag.name);
setEditingTagValue(tag.name);
};
const handleCancelRename = () => {
setEditingTagName(null);
setEditingTagValue("");
};
const handleSubmitRename = (tag: TagSummary) => {
const nextName = editingTagValue.trim();
if (!nextName || nextName === tag.name || renameMutation.isPending) {
return;
}
renameMutation.mutate({ tag: tag.name, name: nextName });
};
const handleDelete = (tag: TagSummary) => {
setTagDeleteConfirmation(tag);
};
return (
<Dialog open={true} onOpenChange={(open) => { if (!open && !tagDeleteConfirmation) onClose(); }}>
<DialogContent className="max-w-[680px] p-0 overflow-hidden border border-slate-200 bg-white shadow-lg rounded-lg">
<DialogHeader className="flex flex-row items-start justify-between gap-3 border-b border-slate-200 px-5 py-4 text-left">
<div className="min-w-0">
<DialogTitle className="flex items-center gap-2 text-base font-semibold text-slate-950">
<Tags className="h-4 w-4 text-emerald-700" />
标签
</DialogTitle>
<DialogDescription className="mt-1 text-xs text-slate-500">
{tags.length} tags
</DialogDescription>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto p-5">
{tagsQuery.isLoading ? (
<div className="px-2 py-8 text-center text-sm text-slate-500">加载中</div>
) : tags.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
暂无标签
</div>
) : (
<div className="space-y-2">
{tags.map((tag) => {
const isEditing = editingTagName === tag.name;
const nextName = editingTagValue.trim();
return (
<div
key={tag.name}
className={cn(
"flex min-h-12 items-center gap-3 rounded-md border border-slate-200 bg-white px-3 py-2",
isEditing && "border-emerald-200 bg-emerald-50/30"
)}
>
<span className="min-w-0 flex-1">
{isEditing ? (
<form
className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center"
onSubmit={(event) => {
event.preventDefault();
handleSubmitRename(tag);
}}
>
<label className="sr-only" htmlFor={`tag-rename-${tag.name}`}>
标签名称
</label>
<input
id={`tag-rename-${tag.name}`}
className="h-9 min-w-0 flex-1 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-emerald-300 focus:ring-2 focus:ring-emerald-500/20"
value={editingTagValue}
autoFocus
disabled={renameMutation.isPending}
maxLength={80}
onChange={(event) => setEditingTagValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
handleCancelRename();
}
}}
/>
<div className="flex shrink-0 gap-2">
<Button
className="justify-center"
size="sm"
type="submit"
variant="solid"
disabled={!nextName || nextName === tag.name || renameMutation.isPending}
>
保存
</Button>
<Button size="sm" type="button" variant="outline" onClick={handleCancelRename} disabled={renameMutation.isPending}>
取消
</Button>
</div>
</form>
) : (
<>
<span className="block truncate text-sm font-semibold text-slate-950">#{tag.name}</span>
<span className="mt-1 block text-xs text-slate-500">
{tag.memoCount} 条笔记{tag.updatedAt ? ` · ${formatDateTime(tag.updatedAt)}` : ""}
</span>
</>
)}
</span>
{!isEditing && (
<>
<Button size="icon" variant="ghost" title="重命名标签" aria-label={`重命名标签 ${tag.name}`} onClick={() => handleRename(tag)}>
<Pencil className="h-4 w-4" />
</Button>
<Button size="icon" variant="danger" title="删除标签" aria-label={`删除标签 ${tag.name}`} onClick={() => handleDelete(tag)}>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
);
})}
</div>
)}
</div>
</DialogContent>
{tagDeleteConfirmation && (
<AppConfirmDialog
title={`删除标签 #${tagDeleteConfirmation.name}`}
description={`将从 ${tagDeleteConfirmation.memoCount} 条笔记中移除这个标签,笔记内容不会被删除。`}
confirmLabel="删除标签"
isWorking={deleteMutation.isPending}
tone="danger"
onCancel={() => setTagDeleteConfirmation(null)}
onConfirm={() => {
deleteMutation.mutate(tagDeleteConfirmation.name, {
onSuccess: () => setTagDeleteConfirmation(null),
});
}}
/>
)}
</Dialog>
);
};
@@ -0,0 +1,104 @@
import { LayoutList, File as FileIcon, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
export type MemoTemplate = {
id: string;
title: string;
description: string;
contentMarkdown: string;
tags: string[];
};
export const MEMO_TEMPLATES: MemoTemplate[] = [
{
id: "quick-note",
title: "速记",
description: "适合临时记录想法、链接和灵感。",
contentMarkdown: "## 速记\n\n- \n\n## 后续动作\n\n- [ ] ",
tags: ["template", "quick-note"],
},
{
id: "meeting",
title: "会议记录",
description: "议题、结论和待办放在同一页。",
contentMarkdown: "## 会议记录\n\n时间:\n参与人:\n\n## 议题\n\n- \n\n## 结论\n\n- \n\n## 待办\n\n- [ ] ",
tags: ["template", "meeting"],
},
{
id: "checklist",
title: "清单",
description: "快速列出待办、采购、项目检查项。",
contentMarkdown: "## 清单\n\n- [ ] \n- [ ] \n- [ ] ",
tags: ["template", "checklist"],
},
{
id: "reading",
title: "读书笔记",
description: "摘录、观点和下一步阅读整理。",
contentMarkdown: "## 读书笔记\n\n书名:\n作者:\n\n## 摘录\n\n> \n\n## 我的观点\n\n\n## 延伸问题\n\n- ",
tags: ["template", "reading"],
},
{
id: "daily",
title: "每日复盘",
description: "记录今天完成了什么、卡在哪里。",
contentMarkdown: "## 每日复盘\n\n## 今天完成\n\n- \n\n## 遇到的问题\n\n- \n\n## 明天优先级\n\n- [ ] ",
tags: ["template", "daily"],
},
];
export const TemplatesDialog = ({
canCreateMemo,
isCreating,
onClose,
onCreateMemo,
}: {
canCreateMemo: boolean;
isCreating: boolean;
onClose: () => void;
onCreateMemo: (template: MemoTemplate) => void;
}) => {
return (
<Dialog open={true} onOpenChange={(open) => { if (!open && !isCreating) onClose(); }}>
<DialogContent className="max-w-[620px] p-0 overflow-hidden border border-slate-200 bg-white shadow-lg rounded-lg">
<DialogHeader className="flex flex-row items-start justify-between gap-3 border-b border-slate-200 px-5 py-4 text-left">
<div className="min-w-0">
<DialogTitle className="flex items-center gap-2 text-base font-semibold text-slate-950">
<LayoutList className="h-4 w-4 text-emerald-700" />
模板
</DialogTitle>
<DialogDescription className="mt-1 text-xs text-slate-500">
选择一个模板,直接创建新笔记。
</DialogDescription>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto p-5">
<div className="grid gap-3 sm:grid-cols-2">
{MEMO_TEMPLATES.map((template) => (
<button
key={template.id}
className="group flex min-h-28 flex-col rounded-md border border-slate-200 bg-white p-3 text-left transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-50"
type="button"
disabled={!canCreateMemo || isCreating}
onClick={() => onCreateMemo(template)}
>
<span className="flex h-9 w-9 items-center justify-center rounded-md border border-slate-200 bg-white text-emerald-700 transition group-hover:border-slate-300">
<FileIcon className="h-4 w-4" />
</span>
<span className="mt-3 text-sm font-semibold text-slate-950">{template.title}</span>
<span className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{template.description}</span>
</button>
))}
</div>
{!canCreateMemo && (
<div className="mt-4 rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-xs text-amber-800">
当前无法创建笔记,请先选择可用笔记本。
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
+46 -34
View File
@@ -1,39 +1,51 @@
import { cn } from "@/lib/utils";
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
type ButtonVariant = "solid" | "soft" | "ghost" | "outline" | "danger";
type ButtonSize = "sm" | "md" | "icon";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#627f58]/70 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
solid: "bg-[#627f58] text-white hover:bg-[#526d49] border-[#627f58]",
soft: "bg-slate-50 text-slate-700 hover:bg-slate-100 border-slate-200",
ghost: "bg-transparent text-slate-600 hover:bg-slate-50 hover:text-slate-900 border-transparent",
outline: "bg-white text-slate-700 hover:bg-slate-50 border border-slate-200",
danger: "bg-rose-50 text-rose-700 hover:bg-rose-100 border border-rose-100",
},
size: {
default: "h-9 px-4 py-2 text-sm",
sm: "h-8 gap-1.5 px-2.5 text-xs",
md: "h-9 gap-2 px-3 text-sm",
icon: "h-8 w-8 p-0",
},
},
defaultVariants: {
variant: "soft",
size: "md",
},
}
);
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
const variantClassName: Record<ButtonVariant, string> = {
solid: "bg-[#627f58] text-white hover:bg-[#526d49] border-[#627f58]",
soft: "bg-slate-50 text-slate-700 hover:bg-slate-100 border-slate-200",
ghost: "bg-transparent text-slate-600 hover:bg-slate-50 hover:text-slate-900 border-transparent",
outline: "bg-white text-slate-700 hover:bg-slate-50 border-slate-200",
danger: "bg-rose-50 text-rose-700 hover:bg-rose-100 border-rose-100",
};
const sizeClassName: Record<ButtonSize, string> = {
sm: "h-8 gap-1.5 px-2.5 text-xs",
md: "h-9 gap-2 px-3 text-sm",
icon: "h-8 w-8 justify-center p-0",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant = "soft", size = "md", ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex shrink-0 items-center rounded-md border font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#627f58]/70 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
variantClassName[variant],
sizeClassName[size],
className
)}
{...props}
/>
));
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+189
View File
@@ -0,0 +1,189 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-slate-100 focus:text-slate-900 data-[state=open]:bg-slate-100 data-[state=open]:text-slate-900",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-slate-950",
inset && "pl-8",
className
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-slate-500", className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+116
View File
@@ -0,0 +1,116 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-1/2 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-1/2 rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[#627f58] focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight text-slate-950",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,192 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+154
View File
@@ -0,0 +1,154 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#627f58] focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#627f58] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[#627f58] data-[state=unchecked]:bg-slate-200",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
));
Switch.displayName = SwitchPrimitive.Root.displayName;
export { Switch };
+537
View File
@@ -0,0 +1,537 @@
import type { MemoSummary, Notebook, TiptapDoc } from "@edgeever/shared";
import { DEFAULT_MEMO_TITLE } from "@edgeever/shared";
import { buildNotebookTree, type NotebookNode } from "./utils";
import * as React from "react";
import type { ReactNode } from "react";
export type Pane = "notebooks" | "memos" | "editor";
export type MemoView = "notebook" | "trash";
export type MemoFilterMode = "all" | "tagged" | "untagged" | "pinned";
export type MemoSortMode = "updated-desc" | "created-desc" | "title-asc";
export type MemoListDensity = "preview" | "compact";
export type MobileBottomNavItem = "home" | "search" | "templates" | "settings";
export type MemoContextMenuState = { memo: MemoSummary; x: number; y: number };
export type MemoSelectionContextMenuState = { x: number; y: number };
export type NotebookContextMenuState = { notebook: NotebookNode; x: number; y: number };
export type MemoDeleteConfirmation = { kind: "single" | "bulk"; memoIds: string[]; permanent: boolean };
export type NotebookNameDialogState =
| { mode: "create"; parentId: string | null }
| { mode: "rename"; notebook: Notebook };
export type AppNoticeDialogState = { title: string; description: string };
export type MemoTemplate = {
id: string;
title: string;
description: string;
contentMarkdown: string;
tags: string[];
};
export const MEMO_TEMPLATES: MemoTemplate[] = [
{
id: "quick-note",
title: "速记",
description: "适合临时记录想法、链接和灵感。",
contentMarkdown: "## 速记\n\n- \n\n## 后续动作\n\n- [ ] ",
tags: ["template", "quick-note"],
},
{
id: "meeting",
title: "会议记录",
description: "议题、结论和待办放在同一页。",
contentMarkdown: "## 会议记录\n\n时间:\n参与人:\n\n## 议题\n\n- \n\n## 结论\n\n- \n\n## 待办\n\n- [ ] ",
tags: ["template", "meeting"],
},
{
id: "checklist",
title: "清单",
description: "快速列出待办、采购、项目检查项。",
contentMarkdown: "## 清单\n\n- [ ] \n- [ ] \n- [ ] ",
tags: ["template", "checklist"],
},
{
id: "reading",
title: "读书笔记",
description: "摘录、观点和下一步阅读整理。",
contentMarkdown: "## 读书笔记\n\n书名:\n作者:\n\n## 摘录\n\n> \n\n## 我的观点\n\n\n## 延伸问题\n\n- ",
tags: ["template", "reading"],
},
{
id: "daily",
title: "每日复盘",
description: "记录今天完成了什么、卡在哪里。",
contentMarkdown: "## 每日复盘\n\n## 今天完成\n\n- \n\n## 遇到的问题\n\n- \n\n## 明天优先级\n\n- [ ] ",
tags: ["template", "daily"],
},
];
export const isTextEntryTarget = (target: EventTarget | null) =>
target instanceof HTMLElement &&
Boolean(target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .ProseMirror"));
export const getNotebookAncestorIds = (nodes: NotebookNode[], targetNotebookId: string) => {
const walk = (items: NotebookNode[], ancestors: string[]): string[] | null => {
for (const node of items) {
if (node.id === targetNotebookId) {
return node.children.length > 0 ? [...ancestors, node.id] : ancestors;
}
const result = walk(node.children, [...ancestors, node.id]);
if (result) {
return result;
}
}
return null;
};
return walk(nodes, []) ?? [];
};
export const getExpandableNotebookIds = (nodes: NotebookNode[]) => {
const ids: string[] = [];
const walk = (items: NotebookNode[]) => {
for (const node of items) {
if (node.children.length > 0) {
ids.push(node.id);
walk(node.children);
}
}
};
walk(nodes);
return ids;
};
export const filterNotebookTree = (nodes: NotebookNode[], search: string): NotebookNode[] => {
const query = search.trim().toLocaleLowerCase("zh-CN");
if (!query) {
return nodes;
}
const walk = (items: NotebookNode[]): NotebookNode[] => {
const filteredNodes: NotebookNode[] = [];
for (const node of items) {
const children = walk(node.children);
const matched = node.name.toLocaleLowerCase("zh-CN").includes(query);
if (matched) {
filteredNodes.push({ ...node, children: node.children });
continue;
}
if (children.length > 0) {
filteredNodes.push({ ...node, children });
}
}
return filteredNodes;
};
return walk(nodes);
};
export { buildNotebookTree, type NotebookNode };
export { localDb, type MemoUpdateSyncPayload, type SyncQueueItem, type LocalDraft } from "./local-db";
export {
emptySyncQueueSummary,
getMemoUpdateQueueId,
queueMemoUpdate,
observeSyncQueue,
syncQueuedChanges,
shouldQueueMemoSaveError,
type SyncQueueSummary,
type SyncRunResult,
} from "./sync-queue";
export { compressImageForUpload } from "./image-compression";
export { docToMarkdown } from "@edgeever/shared";
export { DEFAULT_MEMO_TITLE };
export const IMAGE_COMPRESSION_STORAGE_KEY = "edgeever.imageCompressionEnabled";
export const MEMO_LIST_DENSITY_STORAGE_KEY = "edgeever.memoListDensity";
export const MEMO_LIST_WIDTH_STORAGE_KEY = "edgeever.memoListWidth";
export const DEFAULT_MEMO_LIST_WIDTH_PX = 360;
export const MIN_MEMO_LIST_WIDTH_PX = 300;
export const MAX_MEMO_LIST_WIDTH_PX = 540;
export const MEMO_DRAG_MIME = "application/x-edgeever-memos";
export const NOTEBOOK_DRAG_MIME = "application/x-edgeever-notebook";
export const MEMO_SORT_OPTIONS: Array<{ value: MemoSortMode; label: string }> = [
{ value: "updated-desc", label: "最近更新" },
{ value: "created-desc", label: "创建时间" },
{ value: "title-asc", label: "标题 A-Z" },
];
export const MEMO_FILTER_OPTIONS: Array<{ value: MemoFilterMode; label: string }> = [
{ value: "all", label: "全部" },
{ value: "pinned", label: "置顶" },
{ value: "tagged", label: "有标签" },
{ value: "untagged", label: "无标签" },
];
export const getMemoTitle = (title: string | null | undefined) => title?.trim() || DEFAULT_MEMO_TITLE;
export const getActiveBlockValue = (editor: any): string => {
if (!editor) {
return "paragraph";
}
if (editor.isActive("heading", { level: 1 })) {
return "heading-1";
}
if (editor.isActive("heading", { level: 2 })) {
return "heading-2";
}
if (editor.isActive("heading", { level: 3 })) {
return "heading-3";
}
return "paragraph";
};
export const readImageCompressionPreference = () => {
try {
return window.localStorage.getItem(IMAGE_COMPRESSION_STORAGE_KEY) !== "false";
} catch {
return true;
}
};
export const writeImageCompressionPreference = (enabled: boolean) => {
try {
window.localStorage.setItem(IMAGE_COMPRESSION_STORAGE_KEY, enabled ? "true" : "false");
} catch {
// Local storage can be unavailable in private or restricted browser contexts.
}
};
export const readMemoListDensityPreference = (): MemoListDensity => {
try {
const density = window.localStorage.getItem(MEMO_LIST_DENSITY_STORAGE_KEY);
return density === "compact" ? "compact" : "preview";
} catch {
return "preview";
}
};
export const writeMemoListDensityPreference = (density: MemoListDensity) => {
try {
window.localStorage.setItem(MEMO_LIST_DENSITY_STORAGE_KEY, density);
} catch {
// Local storage can be unavailable in private or restricted browser contexts.
}
};
export const clampMemoListWidth = (width: number) =>
Math.min(MAX_MEMO_LIST_WIDTH_PX, Math.max(MIN_MEMO_LIST_WIDTH_PX, Math.round(width)));
export const readMemoListWidthPreference = () => {
try {
const width = Number(window.localStorage.getItem(MEMO_LIST_WIDTH_STORAGE_KEY));
return Number.isFinite(width) ? clampMemoListWidth(width) : DEFAULT_MEMO_LIST_WIDTH_PX;
} catch {
return DEFAULT_MEMO_LIST_WIDTH_PX;
}
};
export const writeMemoListWidthPreference = (width: number) => {
try {
window.localStorage.setItem(MEMO_LIST_WIDTH_STORAGE_KEY, String(clampMemoListWidth(width)));
} catch {
// Local storage can be unavailable in private or restricted browser contexts.
}
};
export const compareDateDesc = (first: string, second: string) => {
const firstTime = Date.parse(first);
const secondTime = Date.parse(second);
if (Number.isNaN(firstTime) && Number.isNaN(secondTime)) {
return 0;
}
if (Number.isNaN(firstTime)) {
return 1;
}
if (Number.isNaN(secondTime)) {
return -1;
}
return secondTime - firstTime;
};
export const sortMemos = (memos: MemoSummary[], sortMode: MemoSortMode, prioritizePinned = true) =>
[...memos].sort((first, second) => {
if (prioritizePinned && first.isPinned !== second.isPinned) {
return first.isPinned ? -1 : 1;
}
if (sortMode === "title-asc") {
const titleCompare = getMemoTitle(first.title).localeCompare(getMemoTitle(second.title), "zh-CN", {
numeric: true,
sensitivity: "base",
});
if (titleCompare !== 0) {
return titleCompare;
}
return compareDateDesc(first.updatedAt, second.updatedAt);
}
if (sortMode === "created-desc") {
return compareDateDesc(first.createdAt, second.createdAt);
}
return compareDateDesc(first.updatedAt, second.updatedAt);
});
export const filterMemos = (memos: MemoSummary[], filterMode: MemoFilterMode) => {
if (filterMode === "tagged") {
return memos.filter((memo) => memo.tags.length > 0);
}
if (filterMode === "untagged") {
return memos.filter((memo) => memo.tags.length === 0);
}
if (filterMode === "pinned") {
return memos.filter((memo) => memo.isPinned);
}
return memos;
};
export const getMemoMonthGroup = (date: Date) => {
if (Number.isNaN(date.getTime())) {
return { key: "unknown", label: "未知时间" };
}
return {
key: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`,
label: new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "long" }).format(date),
};
};
export const groupMemosByDate = (memos: MemoSummary[], sortMode: MemoSortMode) => {
const groups: Array<{ key: string; label: string; items: MemoSummary[] }> = [];
for (const memo of memos) {
const date = new Date(sortMode === "created-desc" ? memo.createdAt : memo.updatedAt);
const { key, label } = getMemoMonthGroup(date);
const current = groups[groups.length - 1];
if (current?.key === key) {
current.items.push(memo);
continue;
}
groups.push({ key, label, items: [memo] });
}
return groups;
};
export const groupMemosByTitle = (memos: MemoSummary[]) => {
const groups: Array<{ key: string; label: string; items: MemoSummary[] }> = [];
for (const memo of memos) {
const title = getMemoTitle(memo.title);
const firstChar = title.trim().charAt(0).toLocaleUpperCase("zh-CN");
const label = firstChar || "#";
const current = groups[groups.length - 1];
if (current?.key === label) {
current.items.push(memo);
continue;
}
groups.push({ key: label, label, items: [memo] });
}
return groups;
};
export const groupMemos = (memos: MemoSummary[], sortMode: MemoSortMode) =>
sortMode === "title-asc" ? groupMemosByTitle(memos) : groupMemosByDate(memos, sortMode);
export type NotebookMoveOption = { id: string; name: string; selectLabel: string; slug: string | null; depth: number };
export const getNotebookMoveOptions = (notebooks: Notebook[]) => {
const options: NotebookMoveOption[] = [];
const walk = (nodes: NotebookNode[], depth: number) => {
for (const node of nodes) {
options.push({
id: node.id,
name: node.name,
selectLabel: `${"\u00A0\u00A0".repeat(depth)}${depth > 0 ? "└ " : ""}${node.name}`,
slug: node.slug,
depth,
});
walk(node.children, depth + 1);
}
};
walk(buildNotebookTree(notebooks), 0);
return options;
};
export const toggleMemoSelection = (current: Set<string>, memoId: string) => {
const next = new Set(current);
if (next.has(memoId)) {
next.delete(memoId);
} else {
next.add(memoId);
}
return next;
};
export const hasMemoDragData = (dataTransfer: DataTransfer) => Array.from(dataTransfer.types).includes(MEMO_DRAG_MIME);
export const hasNotebookDragData = (dataTransfer: DataTransfer) => Array.from(dataTransfer.types).includes(NOTEBOOK_DRAG_MIME);
export const hasEdgeEverDragData = (dataTransfer: DataTransfer) => hasMemoDragData(dataTransfer) || hasNotebookDragData(dataTransfer);
export const getMemoDragIds = (dataTransfer: DataTransfer) => {
if (!hasMemoDragData(dataTransfer)) {
return [];
}
try {
const parsed = JSON.parse(dataTransfer.getData(MEMO_DRAG_MIME)) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((value): value is string => typeof value === "string" && Boolean(value.trim()));
} catch {
return [];
}
};
export type NotebookDropPosition = "before" | "inside" | "after";
export const getNotebookDropPosition = (event: React.DragEvent<HTMLElement> | DragEvent): NotebookDropPosition => {
const currentTarget = event.currentTarget as HTMLElement;
const rect = currentTarget.getBoundingClientRect();
const offset = event.clientY - rect.top;
if (offset < rect.height * 0.28) {
return "before";
}
if (offset > rect.height * 0.72) {
return "after";
}
return "inside";
};
export const getNotebookDropSortOrder = (
notebooks: Notebook[],
target: Notebook,
position: Exclude<NotebookDropPosition, "inside">
) => {
const siblings = notebooks
.filter((notebook) => notebook.parentId === target.parentId)
.sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name));
const targetIndex = siblings.findIndex((notebook) => notebook.id === target.id);
const insertionIndex = targetIndex < 0 ? siblings.length : position === "before" ? targetIndex : targetIndex + 1;
const previous = siblings[insertionIndex - 1];
const next = siblings[insertionIndex];
if (!previous && !next) {
return target.sortOrder + (position === "before" ? -1000 : 1000);
}
if (!previous) {
return next.sortOrder - 1000;
}
if (!next) {
return previous.sortOrder + 1000;
}
return Math.floor((previous.sortOrder + next.sortOrder) / 2);
};
export const focusNotebookTreeButton = (
currentButton: HTMLButtonElement,
direction: "next" | "previous" | "first" | "last"
) => {
const tree = currentButton.closest<HTMLElement>("[data-notebook-tree]");
if (!tree) {
return;
}
const buttons = Array.from(tree.querySelectorAll<HTMLButtonElement>("[data-notebook-tree-button]")).filter(
(button) => !button.disabled
);
const currentIndex = buttons.indexOf(currentButton);
if (currentIndex < 0 || buttons.length === 0) {
return;
}
const nextIndex =
direction === "first"
? 0
: direction === "last"
? buttons.length - 1
: direction === "next"
? Math.min(currentIndex + 1, buttons.length - 1)
: Math.max(currentIndex - 1, 0);
const nextButton = buttons[nextIndex];
if (!nextButton || nextButton === currentButton) {
return;
}
nextButton.focus({ preventScroll: true });
nextButton.scrollIntoView({ block: "nearest" });
};
export const notebookTreeContainsId = (nodes: NotebookNode[], targetNotebookId: string): boolean => {
for (const node of nodes) {
if (node.id === targetNotebookId || notebookTreeContainsId(node.children, targetNotebookId)) {
return true;
}
}
return false;
};
export const setMemoDragPreview = (dataTransfer: DataTransfer, label: string) => {
const dragImage = document.createElement("div");
dragImage.textContent = label;
Object.assign(dragImage.style, {
position: "fixed",
top: "-9999px",
left: "-9999px",
maxWidth: "220px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
border: "1px solid #cbd9c4",
borderRadius: "6px",
background: "#f3f7f1",
boxShadow: "0 14px 30px rgba(15, 23, 42, 0.16)",
color: "#1f2a1f",
font: "600 13px system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
padding: "8px 10px",
pointerEvents: "none",
zIndex: "9999",
});
document.body.appendChild(dragImage);
dataTransfer.setDragImage(dragImage, 16, 18);
window.setTimeout(() => dragImage.remove(), 0);
};
+507
View File
@@ -0,0 +1,507 @@
import { useEffect, useRef, useCallback, type RefObject, type PointerEvent as ReactPointerEvent } from "react";
const APP_BACK_HISTORY_MARKER = "__edgeever_app_back__";
const BOTTOM_SHEET_DISMISS_DISTANCE_PX = 72;
const BOTTOM_SHEET_DISMISS_VELOCITY_PX_PER_MS = 0.55;
export const DIALOG_FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export const getFocusableElements = (node: HTMLElement | null) => {
if (!node) {
return [];
}
return Array.from(node.querySelectorAll<HTMLElement>(DIALOG_FOCUSABLE_SELECTOR)).filter((element) => {
const isVisible = element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0;
return isVisible && !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true";
});
};
export const isTextEntryTarget = (target: EventTarget | null) =>
target instanceof HTMLElement &&
Boolean(target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .ProseMirror"));
export const useDismissableLayer = <T extends HTMLElement>(open: boolean, onClose: () => void) => {
const ref = useRef<T | null>(null);
useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const node = ref.current;
if (!node || node.contains(event.target as Node)) {
return;
}
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, open]);
return ref;
};
export type BrowserBackLayer = {
id: string;
onBack: () => void;
};
let browserBackLayerCounter = 0;
let browserBackLayerPopStateAttached = false;
let browserBackLayerSuppressNextPopState = false;
const browserBackLayerStack: BrowserBackLayer[] = [];
const getBrowserBackMarker = () =>
window.history.state && typeof window.history.state === "object"
? (window.history.state as Record<string, unknown>)[APP_BACK_HISTORY_MARKER]
: null;
const removeBrowserBackLayer = (id: string) => {
for (let index = browserBackLayerStack.length - 1; index >= 0; index -= 1) {
if (browserBackLayerStack[index]?.id === id) {
browserBackLayerStack.splice(index, 1);
}
}
};
const hasBrowserBackLayer = (id: string) => browserBackLayerStack.some((layer) => layer.id === id);
const ensureBrowserBackLayerListener = () => {
if (browserBackLayerPopStateAttached) {
return;
}
window.addEventListener("popstate", () => {
if (browserBackLayerSuppressNextPopState) {
browserBackLayerSuppressNextPopState = false;
return;
}
const layer = browserBackLayerStack.at(-1);
if (!layer) {
return;
}
removeBrowserBackLayer(layer.id);
layer.onBack();
});
browserBackLayerPopStateAttached = true;
};
export const useBrowserBackLayer = (active: boolean, onBack: () => void) => {
const idRef = useRef("");
const onBackRef = useRef(onBack);
if (!idRef.current) {
browserBackLayerCounter += 1;
idRef.current = `edgeever-back-layer-${browserBackLayerCounter}`;
}
useEffect(() => {
onBackRef.current = onBack;
}, [onBack]);
useEffect(() => {
if (!active) {
return;
}
ensureBrowserBackLayerListener();
const id = idRef.current;
const currentState =
window.history.state && typeof window.history.state === "object" ? window.history.state : {};
const layer: BrowserBackLayer = {
id,
onBack: () => onBackRef.current(),
};
browserBackLayerStack.push(layer);
window.history.pushState(
{
...currentState,
[APP_BACK_HISTORY_MARKER]: id,
},
"",
window.location.href
);
return () => {
removeBrowserBackLayer(id);
window.setTimeout(() => {
if (hasBrowserBackLayer(id) || getBrowserBackMarker() !== id) {
return;
}
browserBackLayerSuppressNextPopState = true;
window.history.back();
}, 0);
};
}, [active]);
};
export const useFloatingMenuControls = <T extends HTMLElement>(ref: RefObject<T | null>, open: boolean, onClose: () => void) => {
useEffect(() => {
if (!open) {
return;
}
const node = ref.current;
if (!node) {
return;
}
const previouslyFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const getMenuItems = () => {
const menuNode = node.querySelector<HTMLElement>("[role='menu']") ?? node;
return getFocusableElements(menuNode).filter((element) => element.getAttribute("role") === "menuitem" || element.tagName === "BUTTON");
};
const restoreFocusToTrigger = () => {
if (!previouslyFocusedElement?.isConnected) {
return;
}
window.setTimeout(() => {
const activeElement = document.activeElement;
const hasExternalFocus =
activeElement instanceof HTMLElement && activeElement !== document.body && !node.contains(activeElement);
if (hasExternalFocus) {
return;
}
previouslyFocusedElement.focus();
}, 0);
};
const focusTimer = window.setTimeout(() => getMenuItems()[0]?.focus(), 0);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
restoreFocusToTrigger();
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") {
return;
}
const menuItems = getMenuItems();
if (menuItems.length === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
const currentIndex = menuItems.findIndex((element) => element === document.activeElement);
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? menuItems.length - 1
: event.key === "ArrowDown"
? (Math.max(currentIndex, 0) + 1) % menuItems.length
: (currentIndex <= 0 ? menuItems.length : currentIndex) - 1;
menuItems[nextIndex]?.focus();
};
node.addEventListener("keydown", handleKeyDown);
return () => {
window.clearTimeout(focusTimer);
node.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, open, ref]);
};
export const useFocusTrap = <T extends HTMLElement>(ref: RefObject<T | null>, active = true) => {
useEffect(() => {
if (!active) {
return;
}
const node = ref.current;
if (!node) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab") {
return;
}
const focusableElements = getFocusableElements(node);
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
if (document.activeElement === firstElement || !node.contains(document.activeElement)) {
event.preventDefault();
lastElement.focus();
}
return;
}
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
};
node.addEventListener("keydown", handleKeyDown);
return () => node.removeEventListener("keydown", handleKeyDown);
}, [active, ref]);
};
export const useModalLayerControls = <T extends HTMLElement>(
ref: RefObject<T | null>,
onClose: () => void,
{
active = true,
closeOnEscape = true,
closeOnBrowserBack = false,
initialFocus = true,
restoreFocus = true,
}: {
active?: boolean;
closeOnEscape?: boolean;
closeOnBrowserBack?: boolean;
initialFocus?: boolean;
restoreFocus?: boolean;
} = {}
) => {
useFocusTrap(ref, active);
useBrowserBackLayer(active && closeOnBrowserBack, onClose);
useEffect(() => {
if (!active) {
return;
}
const previouslyFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusTimer = initialFocus
? window.setTimeout(() => {
const node = ref.current;
if (!node || node.contains(document.activeElement)) {
return;
}
getFocusableElements(node)[0]?.focus();
}, 0)
: null;
const node = ref.current;
if (!node) {
return () => {
if (focusTimer !== null) {
window.clearTimeout(focusTimer);
}
};
}
const handleKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
if (!closeOnEscape || event.key !== "Escape" || event.defaultPrevented) {
return;
}
if (isTextEntryTarget(event.target)) {
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
if (target.value) {
return;
}
}
}
event.preventDefault();
onClose();
};
node.addEventListener("keydown", handleKeyDown);
return () => {
if (focusTimer !== null) {
window.clearTimeout(focusTimer);
}
node.removeEventListener("keydown", handleKeyDown);
if (restoreFocus && previouslyFocusedElement?.isConnected) {
previouslyFocusedElement.focus({ preventScroll: true });
}
};
}, [active, closeOnEscape, initialFocus, onClose, ref, restoreFocus]);
};
export const useBottomSheetSwipeToClose = <T extends HTMLElement>(sheetRef: RefObject<T | null>, onClose: () => void) => {
const cleanupDragRef = useRef<(() => void) | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const cleanupDrag = useCallback(() => {
cleanupDragRef.current?.();
cleanupDragRef.current = null;
}, []);
const resetSheetPosition = useCallback(() => {
const sheet = sheetRef.current;
if (!sheet) {
return;
}
sheet.style.transition = "transform 180ms cubic-bezier(0.2, 0, 0, 1)";
sheet.style.transform = "";
sheet.style.willChange = "";
window.setTimeout(() => {
if (sheetRef.current === sheet) {
sheet.style.transition = "";
}
}, 190);
}, [sheetRef]);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (event.pointerType === "mouse" || !event.isPrimary) {
return;
}
const sheet = sheetRef.current;
if (!sheet) {
return;
}
event.preventDefault();
event.stopPropagation();
cleanupDrag();
const pointerId = event.pointerId;
const startX = event.clientX;
const startY = event.clientY;
const startTime = performance.now();
let latestY = startY;
let dragging = false;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
const deltaY = Math.max(0, moveEvent.clientY - startY);
const deltaX = Math.abs(moveEvent.clientX - startX);
if (!dragging && deltaX > deltaY * 1.4 && deltaX > 18) {
return;
}
if (deltaY <= 0) {
return;
}
latestY = moveEvent.clientY;
dragging = true;
moveEvent.preventDefault();
sheet.style.transition = "none";
sheet.style.transform = `translate3d(0, ${deltaY}px, 0)`;
sheet.style.willChange = "transform";
};
const handlePointerEnd = (endEvent: PointerEvent) => {
if (endEvent.pointerId !== pointerId) {
return;
}
const deltaY = Math.max(0, latestY - startY);
const elapsedMs = Math.max(performance.now() - startTime, 1);
const velocity = deltaY / elapsedMs;
cleanupDrag();
if (deltaY >= BOTTOM_SHEET_DISMISS_DISTANCE_PX || velocity >= BOTTOM_SHEET_DISMISS_VELOCITY_PX_PER_MS) {
sheet.style.transition = "transform 160ms cubic-bezier(0.32, 0.72, 0, 1)";
sheet.style.transform = "translate3d(0, 110%, 0)";
sheet.style.willChange = "transform";
window.setTimeout(() => onCloseRef.current(), 120);
return;
}
resetSheetPosition();
};
const handlePointerCancel = (cancelEvent: PointerEvent) => {
if (cancelEvent.pointerId !== pointerId) {
return;
}
cleanupDrag();
resetSheetPosition();
};
window.addEventListener("pointermove", handlePointerMove, { passive: false });
window.addEventListener("pointerup", handlePointerEnd);
window.addEventListener("pointercancel", handlePointerCancel);
cleanupDragRef.current = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerCancel);
};
},
[cleanupDrag, resetSheetPosition, sheetRef]
);
useEffect(() => cleanupDrag, [cleanupDrag]);
return handlePointerDown;
};
+34
View File
@@ -2,6 +2,40 @@
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 80 11% 96%;
--foreground: 224 71% 4%;
--card: 0 0% 100%;
--card-foreground: 224 71% 4%;
--popover: 0 0% 100%;
--popover-foreground: 224 71% 4%;
--primary: 102 18% 42%;
--primary-foreground: 80 11% 96%;
--secondary: 90 16% 95%;
--secondary-foreground: 102 18% 42%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 90 16% 95%;
--accent-foreground: 102 18% 42%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 102 18% 42%;
--radius: 0.5rem;
}
}
:root {
color-scheme: light;
font-synthesis: none;
+55 -1
View File
@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss";
import tailwindAnimate from "tailwindcss-animate";
export default {
content: {
@@ -21,7 +22,45 @@ export default {
boxShadow: {
panel: "0 1px 2px rgb(15 23 42 / 0.07), 0 14px 34px rgb(36 56 39 / 0.08)",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
emerald: {
50: "#f4f6f1",
100: "#e5ebdf",
@@ -36,7 +75,22 @@ export default {
950: "#101a13",
},
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
plugins: [tailwindAnimate],
} satisfies Config;
+96
View File
@@ -7,6 +7,13 @@
"dependencies": {
"@edgeever/shared": "workspace:*",
"@hono/zod-validator": "latest",
"@radix-ui/react-context-menu": "^2.3.1",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.1",
"@radix-ui/react-tooltip": "^1.2.10",
"@tanstack/react-query": "latest",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-placeholder": "latest",
@@ -21,6 +28,7 @@
"react": "latest",
"react-dom": "latest",
"tailwind-merge": "latest",
"tailwindcss-animate": "^1.0.7",
"wasm-webp": "^0.1.0",
"workbox-window": "^7.4.1",
"zod": "latest",
@@ -327,6 +335,8 @@
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@hono/zod-validator": ["@hono/zod-validator@0.8.0", "", { "peerDependencies": { "hono": ">=4.10.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w=="],
@@ -411,6 +421,74 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.1", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.12", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.6", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.6", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.1", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.2", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="],
@@ -605,6 +683,8 @@
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
@@ -697,6 +777,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dexie": ["dexie@4.4.4", "", {}, "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
@@ -781,6 +863,8 @@
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@@ -1055,6 +1139,12 @@
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -1161,6 +1251,8 @@
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="],
"tempy": ["tempy@0.6.0", "", { "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", "type-fest": "^0.16.0", "unique-string": "^2.0.0" } }, "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw=="],
@@ -1219,6 +1311,10 @@
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+11 -1
View File
@@ -9,7 +9,9 @@
],
"scripts": {
"dev": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api\" \"bun run dev:web\"",
"dev:api": "bun scripts/run-wrangler.mjs dev --local --port 8787",
"dev:api": "bun run dev:api:tianma",
"dev:local": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:local\" \"bun run dev:web\"",
"dev:api:local": "bun scripts/run-wrangler.mjs dev --local --port 8787",
"dev:tianma": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:tianma\" \"bun run dev:web\"",
"dev:api:tianma": "EDGE_EVER_INSTANCE=tianma bun scripts/run-wrangler.mjs dev --remote --port 8787",
"dev:web": "vite --config apps/web/vite.config.ts --host 127.0.0.1 --port 5173",
@@ -40,6 +42,13 @@
"dependencies": {
"@edgeever/shared": "workspace:*",
"@hono/zod-validator": "latest",
"@radix-ui/react-context-menu": "^2.3.1",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.1",
"@radix-ui/react-tooltip": "^1.2.10",
"@tanstack/react-query": "latest",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-placeholder": "latest",
@@ -54,6 +63,7 @@
"react": "latest",
"react-dom": "latest",
"tailwind-merge": "latest",
"tailwindcss-animate": "^1.0.7",
"wasm-webp": "^0.1.0",
"workbox-window": "^7.4.1",
"zod": "latest"