refactor(web): rewrite monolithic App.tsx to modular components styled with Shadcn UI
This commit is contained in:
1 parent
881efe3a8b
commit
b201d7dfb9
27 files changed
+9119
-8542
No files matched your search
+14
-8506
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user