feat(web): redesign assets pane with grid layout, search, lightbox and drag-drop upload

This commit is contained in:
tianma committed 2026-06-30 17:59:53 +08:00
1 parent 08cef1c981
commit 7dd12e9915
4 files changed
+534 -28

No files matched your search

+515 -27
View File
@@ -1,8 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import { Archive, HardDrive, ImageIcon, File as FileIcon, ExternalLink, ChevronLeft } from "lucide-react";
import { useState, useCallback, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDropzone } from "react-dropzone";
import Lightbox from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import Thumbnails from "yet-another-react-lightbox/plugins/thumbnails";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/thumbnails.css";
import {
Archive,
HardDrive,
ImageIcon,
File as FileIcon,
ExternalLink,
ChevronLeft,
Search,
Grid,
List,
UploadCloud,
FileText,
FileSpreadsheet,
FileArchive,
Music,
Video,
X,
Loader2,
FileUp,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import { formatDateTime } from "@/lib/utils";
import { compressImageForUpload } from "@/lib/image-compression";
import type { MemoDetail } from "@edgeever/shared";
export const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) {
@@ -16,15 +47,95 @@ export const formatBytes = (bytes: number) => {
return `${exponent === 0 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[exponent]}`;
};
const DOCUMENT_MIME_TYPES = new Set([
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/markdown",
"application/json",
"application/xml",
"text/html",
"text/css",
"text/javascript",
]);
const getFileIcon = (mimeType: string | null, filename: string | null) => {
const mime = (mimeType || "").toLowerCase();
const ext = (filename || "").split(".").pop()?.toLowerCase() || "";
if (mime.startsWith("image/")) return <ImageIcon className="h-8 w-8 text-emerald-500" />;
if (mime.startsWith("audio/")) return <Music className="h-8 w-8 text-sky-500" />;
if (mime.startsWith("video/")) return <Video className="h-8 w-8 text-rose-500" />;
if (mime === "application/pdf" || ext === "pdf") {
return <FileText className="h-8 w-8 text-rose-600" />;
}
if (
mime.includes("spreadsheet") ||
mime.includes("excel") ||
ext === "xls" ||
ext === "xlsx" ||
ext === "csv"
) {
return <FileSpreadsheet className="h-8 w-8 text-green-600" />;
}
if (
mime.includes("word") ||
mime.includes("officedocument.wordprocessingml") ||
ext === "doc" ||
ext === "docx"
) {
return <FileText className="h-8 w-8 text-blue-600" />;
}
if (
mime.includes("zip") ||
mime.includes("tar") ||
mime.includes("rar") ||
mime.includes("gzip") ||
ext === "zip" ||
ext === "rar" ||
ext === "tar" ||
ext === "gz"
) {
return <FileArchive className="h-8 w-8 text-amber-500" />;
}
return <FileIcon className="h-8 w-8 text-slate-400" />;
};
interface AssetsPaneProps {
onClose: () => void;
activeMemo?: MemoDetail | null;
}
export const AssetsPane = ({ onClose }: AssetsPaneProps) => {
export const AssetsPane = ({ onClose, activeMemo }: AssetsPaneProps) => {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
// States
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<"all" | "image" | "document" | "other">("all");
const [layoutMode, setLayoutMode] = useState<"grid" | "list">(() => {
return (localStorage.getItem("assets_layout_mode") as "grid" | "list") || "grid";
});
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [uploadState, setUploadState] = useState<"idle" | "compressing" | "uploading" | "error" | "success">("idle");
const [uploadProgress, setUploadProgress] = useState("");
// Query resources
const resourcesQuery = useQuery({
queryKey: ["resources"],
queryFn: () => api.listResources(),
});
const resources = resourcesQuery.data?.resources ?? [];
const summary = resourcesQuery.data?.summary ?? {
totalCount: 0,
@@ -33,15 +144,122 @@ export const AssetsPane = ({ onClose }: AssetsPaneProps) => {
attachmentCount: 0,
};
// Drag and Drop Upload Handler
const handleUploadFiles = useCallback(
async (files: File[]) => {
if (!activeMemo || activeMemo.isDeleted || files.length === 0) return;
const targetMemoId = activeMemo.id;
setUploadState("uploading");
try {
let count = 0;
for (const file of files) {
count++;
setUploadProgress(`正在上传第 ${count}/${files.length} 个文件...`);
const isImage = file.type.startsWith("image/");
// Compress images if enabled
const shouldCompress = isImage;
if (shouldCompress) {
setUploadState("compressing");
setUploadProgress(`正在压缩: ${file.name}`);
}
const uploadFile = shouldCompress ? (await compressImageForUpload(file)).file : file;
setUploadState("uploading");
setUploadProgress(`正在上传: ${uploadFile.name}`);
await api.uploadMemoResource(targetMemoId, uploadFile);
}
void queryClient.invalidateQueries({ queryKey: ["resources"] });
setUploadState("success");
setTimeout(() => setUploadState("idle"), 2000);
} catch (err) {
setUploadState("error");
setTimeout(() => setUploadState("idle"), 3000);
}
},
[activeMemo, queryClient]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: handleUploadFiles,
disabled: !activeMemo || activeMemo.isDeleted,
noClick: true,
});
// Filter Logic
const filteredResources = useMemo(() => {
return resources.filter((resource) => {
// 1. Kind/Type Filter
const isDoc = DOCUMENT_MIME_TYPES.has(resource.mimeType || "") || resource.kind === "attachment";
if (filterType === "image" && resource.kind !== "image") return false;
if (filterType === "document" && (!isDoc || resource.kind === "image")) return false;
if (filterType === "other" && (resource.kind === "image" || isDoc)) return false;
// 2. Search Text Query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
const filenameMatch = (resource.filename || "").toLowerCase().includes(query);
const memoTitleMatch = (resource.memoTitle || "").toLowerCase().includes(query);
const memoExcerptMatch = (resource.memoExcerpt || "").toLowerCase().includes(query);
return filenameMatch || memoTitleMatch || memoExcerptMatch;
}
return true;
});
}, [resources, filterType, searchQuery]);
// Lightbox slides
const imageResources = useMemo(() => {
return filteredResources.filter((r) => r.kind === "image");
}, [filteredResources]);
const slides = useMemo(() => {
return imageResources.map((r) => ({
src: r.url,
alt: r.filename || "",
title: r.filename || "",
}));
}, [imageResources]);
const handleImageClick = (resourceId: string) => {
const idx = imageResources.findIndex((r) => r.id === resourceId);
if (idx !== -1) {
setLightboxIndex(idx);
}
};
const handleManualUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleManualFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleUploadFiles(Array.from(e.target.files));
}
};
return (
<div className="flex h-full min-h-0 flex-col bg-white">
<div
{...getRootProps()}
className="relative flex h-full min-h-0 flex-col bg-white select-none outline-none"
>
<input {...getInputProps()} />
{/* Header */}
<header className="flex h-[calc(4rem+env(safe-area-inset-top))] shrink-0 items-end justify-between border-b border-slate-200 px-6 pb-3 pt-[env(safe-area-inset-top)] lg:h-16 lg:items-center lg:pb-0 lg:pt-0">
<div className="flex items-center gap-3">
<Button
size="icon"
variant="ghost"
title="返回编辑器"
aria-label="返回编辑器"
title="返回"
aria-label="返回"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-lg hover:bg-slate-100"
>
@@ -66,50 +284,320 @@ export const AssetsPane = ({ onClose }: AssetsPaneProps) => {
</div>
</header>
<div className="flex-1 overflow-y-auto p-6 bg-slate-50/30">
{/* Toolbar (Filters, Search, Layout mode) */}
<div className="flex flex-col gap-3 border-b border-slate-100 bg-white p-4 shrink-0 sm:flex-row sm:items-center sm:justify-between">
{/* Category Filters */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 sm:pb-0 scrollbar-none">
{(["all", "image", "document", "other"] as const).map((type) => (
<button
key={type}
onClick={() => setFilterType(type)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
filterType === type
? "bg-emerald-50 text-emerald-700 border border-emerald-200/50 shadow-[0_1px_2px_rgba(0,0,0,0.02)]"
: "text-slate-500 hover:bg-slate-50 border border-transparent"
}`}
>
{type === "all" && "全部"}
{type === "image" && "图片"}
{type === "document" && "文档"}
{type === "other" && "其他"}
</button>
))}
</div>
{/* Search & Layout Toggles */}
<div className="flex items-center gap-2">
{/* Search box */}
<div className="relative flex-1 sm:w-60">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="搜索附件名或来源笔记..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8.5 w-full rounded-lg border border-slate-200 bg-slate-50/50 pl-8.5 pr-8 text-xs text-slate-800 placeholder-slate-400 transition-colors focus:border-emerald-500/50 focus:bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500/20"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-650"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Layout switches */}
<div className="flex items-center gap-0.5 rounded-lg border border-slate-200 p-0.5 bg-slate-50/50">
<button
onClick={() => setLayoutMode("grid")}
title="网格视图"
className={`rounded-md p-1 transition-colors ${
layoutMode === "grid" ? "bg-white text-emerald-700 shadow-sm" : "text-slate-400 hover:text-slate-600"
}`}
>
<Grid className="h-4 w-4" />
</button>
<button
onClick={() => setLayoutMode("list")}
title="列表视图"
className={`rounded-md p-1 transition-colors ${
layoutMode === "list" ? "bg-white text-emerald-700 shadow-sm" : "text-slate-400 hover:text-slate-600"
}`}
>
<List className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* Target Upload Memo Info Banner */}
{activeMemo ? (
<div className="flex items-center justify-between border-b border-emerald-100 bg-emerald-50/30 px-6 py-2 shrink-0">
<p className="truncate text-[11px] font-medium text-emerald-800">
📌 当前关联笔记:《{activeMemo.title || activeMemo.excerpt || "未命名笔记"}》
</p>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleManualFileChange}
multiple
className="hidden"
/>
<button
onClick={handleManualUploadClick}
disabled={uploadState !== "idle"}
className="inline-flex items-center gap-1 rounded bg-emerald-600 px-2 py-1 text-[10px] font-semibold text-white transition-colors hover:bg-emerald-700 active:bg-emerald-800 disabled:bg-emerald-450"
>
{uploadState === "idle" ? (
<>
<FileUp className="h-3 w-3" />
上传附件
</>
) : (
<>
<Loader2 className="h-3 w-3 animate-spin" />
处理中...
</>
)}
</button>
</div>
</div>
) : (
<div className="border-b border-amber-100 bg-amber-50/30 px-6 py-2 text-[11px] font-medium text-amber-800 shrink-0">
⚠️ 提示:在右侧编辑器中打开一篇笔记,即可在此处拖放或上传新文件。
</div>
)}
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto bg-slate-50/30 p-6">
<div className="mx-auto max-w-4xl">
{resourcesQuery.isLoading ? (
<div className="flex items-center justify-center py-24 text-sm text-slate-400">正在加载附件列表...</div>
) : resources.length === 0 ? (
<div className="flex flex-col items-center justify-center py-32 text-slate-400">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600 mb-2" />
<span className="text-xs font-medium">正在加载附件列表...</span>
</div>
) : filteredResources.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-200 bg-white px-6 py-24 text-center">
<Archive className="h-10 w-10 text-slate-350 mb-3 stroke-[1.5]" />
<p className="text-sm font-semibold text-slate-500">暂无附件</p>
<p className="mt-1 text-xs text-slate-400">插入到笔记中的图片或文件会显示在这里</p>
<p className="text-sm font-semibold text-slate-500">
{searchQuery || filterType !== "all" ? "未找到匹配附件" : "暂无附件"}
</p>
<p className="mt-1 text-xs text-slate-400">
{searchQuery || filterType !== "all"
? "请尝试更改搜索词或分类过滤器"
: "拖拽文件到这里,或在上方上传文件"}
</p>
</div>
) : layoutMode === "grid" ? (
/* Grid View */
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{filteredResources.map((resource) => (
<div
key={resource.id}
className="group relative flex flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition-all duration-200 hover:border-emerald-500/40 hover:shadow-md"
>
{/* Thumbnail area */}
<div
onClick={() => {
if (resource.kind === "image") {
handleImageClick(resource.id);
} else {
window.open(resource.url, "_blank", "noreferrer");
}
}}
className="relative aspect-square w-full cursor-pointer overflow-hidden bg-slate-50 flex items-center justify-center border-b border-slate-100"
>
{resource.kind === "image" ? (
<img
src={resource.url}
alt={resource.filename || ""}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-103"
loading="lazy"
/>
) : (
<div className="flex flex-col items-center gap-1.5 p-3 text-center">
{getFileIcon(resource.mimeType, resource.filename)}
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[9px] font-bold text-slate-500 uppercase tracking-wider">
{(resource.filename || "").split(".").pop() || "FILE"}
</span>
</div>
)}
{/* Hover detail overlay */}
<div className="absolute inset-0 bg-slate-900/40 opacity-0 transition-opacity duration-200 group-hover:opacity-100 flex items-center justify-center">
<span className="rounded bg-white/90 px-2.5 py-1.5 text-[11px] font-semibold text-slate-800 shadow flex items-center gap-1">
{resource.kind === "image" ? "大图预览" : "下载/打开"}
<ExternalLink className="h-3 w-3" />
</span>
</div>
</div>
{/* Metadata area */}
<div className="flex flex-col p-3 min-w-0">
<span
title={resource.filename || resource.id}
className="truncate text-xs font-bold text-slate-800 leading-snug group-hover:text-emerald-700 transition-colors"
>
{resource.filename || resource.id}
</span>
<span className="mt-1 flex items-center justify-between text-[10px] font-medium text-slate-400">
<span>{formatBytes(resource.byteSize)}</span>
<span>{(resource.mimeType?.split("/")[1] || resource.kind).toUpperCase()}</span>
</span>
<span
title={
resource.memoDeleted
? "已删除笔记"
: `来自:${resource.memoTitle || resource.memoExcerpt || resource.memoId}`
}
className="mt-1.5 truncate text-[9px] text-slate-400 border-t border-slate-50 pt-1"
>
📄 {resource.memoDeleted ? "已删除笔记" : resource.memoTitle || resource.memoExcerpt || "未命名笔记"}
</span>
</div>
</div>
))}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{resources.map((resource) => (
<a
/* List View */
<div className="flex flex-col gap-2.5">
{filteredResources.map((resource) => (
<div
key={resource.id}
className="group relative flex min-h-16 items-center gap-3.5 rounded-xl border border-slate-200/80 bg-white p-3.5 text-left transition-all duration-200 hover:border-emerald-500/35 hover:shadow-[0_8px_20px_-4px_rgb(var(--brand-green-rgb)/0.06)]"
href={resource.url}
target="_blank"
rel="noreferrer"
className="group relative flex items-center gap-3.5 rounded-xl border border-slate-200/80 bg-white p-3.5 text-left transition-all duration-200 hover:border-emerald-500/35 hover:shadow-sm"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-slate-100 bg-slate-50/50 text-emerald-500 transition-colors group-hover:bg-emerald-50/60 group-hover:border-emerald-500/20">
{resource.kind === "image" ? <ImageIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
</span>
<span className="min-w-0 flex-1">
{/* Left Icon/Thumbnail */}
<div
onClick={() => {
if (resource.kind === "image") {
handleImageClick(resource.id);
} else {
window.open(resource.url, "_blank", "noreferrer");
}
}}
className="relative flex h-12 w-12 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-slate-100 bg-slate-50/50"
>
{resource.kind === "image" ? (
<img
src={resource.url}
alt={resource.filename || ""}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
/>
) : (
getFileIcon(resource.mimeType, resource.filename)
)}
</div>
{/* Mid Info */}
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-slate-800 leading-snug group-hover:text-emerald-700 transition-colors">
{resource.filename || resource.id}
</span>
<span className="mt-1.5 block truncate text-[11px] font-medium text-slate-400">
<span className="mt-1 block truncate text-[11px] font-medium text-slate-400">
{formatBytes(resource.byteSize)} · {resource.mimeType?.split("/")[1] || resource.kind} ·{" "}
{formatDateTime(resource.createdAt)}
</span>
<span className="mt-1 block truncate text-[11px] text-slate-500">
<span className="mt-1 block truncate text-[10px] text-slate-500">
来源笔记:{resource.memoDeleted
? "已删除笔记"
: resource.memoTitle || resource.memoExcerpt || resource.memoId}
</span>
</span>
<ExternalLink className="h-4 w-4 shrink-0 text-slate-350 opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
</a>
</div>
{/* Right Actions */}
<a
href={resource.url}
target="_blank"
rel="noreferrer"
title="在新窗口打开"
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-350 hover:bg-slate-50 hover:text-emerald-600 opacity-0 group-hover:opacity-100 transition-all duration-150"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
))}
</div>
)}
</div>
</div>
{/* Drag Active Overlay */}
{isDragActive && activeMemo && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-emerald-900/80 backdrop-blur-sm p-6 text-center text-white transition-all duration-200">
<div className="rounded-2xl border-2 border-dashed border-emerald-200 bg-emerald-950/40 p-12 flex flex-col items-center max-w-md shadow-2xl">
<UploadCloud className="h-16 w-16 text-emerald-300 animate-bounce mb-4" />
<h3 className="text-lg font-bold">将文件释放于此</h3>
<p className="mt-2 text-sm text-emerald-200 leading-relaxed">
文件将上传并附加到当前正在编辑的笔记:
<span className="block mt-1 font-bold text-white">《{activeMemo.title || activeMemo.excerpt || "未命名"}》</span>
</p>
</div>
</div>
)}
{/* Uploading Status Overlay (Non-intrusive bottom loader) */}
{uploadState !== "idle" && (
<div className="absolute bottom-4 left-1/2 z-50 -translate-x-1/2 flex items-center gap-3 rounded-xl border border-slate-200 bg-white/95 px-5 py-3.5 shadow-xl backdrop-blur-sm">
{uploadState === "compressing" && (
<>
<Loader2 className="h-4.5 w-4.5 animate-spin text-emerald-650" />
<span className="text-xs font-semibold text-slate-700">{uploadProgress}</span>
</>
)}
{uploadState === "uploading" && (
<>
<Loader2 className="h-4.5 w-4.5 animate-spin text-emerald-650" />
<span className="text-xs font-semibold text-slate-700">{uploadProgress}</span>
</>
)}
{uploadState === "success" && (
<>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-50 text-emerald-650 text-xs font-bold font-mono">✓</span>
<span className="text-xs font-semibold text-emerald-700">上传成功!</span>
</>
)}
{uploadState === "error" && (
<>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-rose-50 text-rose-600 text-xs font-bold font-mono">✕</span>
<span className="text-xs font-semibold text-rose-700">上传失败,请稍后重试</span>
</>
)}
</div>
)}
{/* Lightbox Viewer */}
{lightboxIndex !== null && (
<Lightbox
index={lightboxIndex}
slides={slides}
open={lightboxIndex !== null}
close={() => setLightboxIndex(null)}
plugins={[Zoom, Thumbnails]}
/>
)}
</div>
);
};
+1 -1
View File
@@ -1947,7 +1947,7 @@ export const WorkspaceApp = ({
onShowGuide={() => setRightView("evernote-migration")}
/>
) : rightView === "assets" ? (
<AssetsPane onClose={handleCloseAssets} />
<AssetsPane onClose={handleCloseAssets} activeMemo={selectedMemo} />
) : rightView === "evernote-migration" ? (
<EvernoteImportGuidePane onClose={() => setRightView("settings")} />
) : (
+16
View File
@@ -31,6 +31,7 @@
"marked": "^18.0.5",
"react": "latest",
"react-dom": "latest",
"react-dropzone": "^15.0.0",
"react-router": "^8.1.0",
"sharp": "^0.35.2",
"tailwind-merge": "latest",
@@ -38,6 +39,7 @@
"turndown": "^7.2.4",
"vaul": "^1.1.2",
"workbox-window": "^7.4.1",
"yet-another-react-lightbox": "^3.32.0",
"zod": "latest",
},
"devDependencies": {
@@ -724,6 +726,8 @@
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
"attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="],
"autoprefixer": ["autoprefixer@10.5.2", "", { "dependencies": { "browserslist": "^4.28.4", "caniuse-lite": "^1.0.30001799", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@@ -870,6 +874,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -1060,6 +1066,8 @@
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@1.21.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ=="],
@@ -1144,6 +1152,8 @@
"pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="],
"prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="],
@@ -1178,6 +1188,10 @@
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"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=="],
@@ -1436,6 +1450,8 @@
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"yet-another-react-lightbox": ["yet-another-react-lightbox@3.32.0", "", { "peerDependencies": { "@types/react": "^16 || ^17 || ^18 || ^19", "@types/react-dom": "^16 || ^17 || ^18 || ^19", "react": "^16.8.0 || ^17 || ^18 || ^19", "react-dom": "^16.8.0 || ^17 || ^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FWODOMrE07i3O5MeWRcYlcnAUk518zkUYKAe307pVW5pkey3hKMcAIWn8yMIURzGvbd3m9eghWO9CocEZmQPIg=="],
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
+2
View File
@@ -62,6 +62,7 @@
"marked": "^18.0.5",
"react": "latest",
"react-dom": "latest",
"react-dropzone": "^15.0.0",
"react-router": "^8.1.0",
"sharp": "^0.35.2",
"tailwind-merge": "latest",
@@ -69,6 +70,7 @@
"turndown": "^7.2.4",
"vaul": "^1.1.2",
"workbox-window": "^7.4.1",
"yet-another-react-lightbox": "^3.32.0",
"zod": "latest"
},
"devDependencies": {