feat(web): redesign assets pane with grid layout, search, lightbox and drag-drop upload
This commit is contained in:
1 parent
08cef1c981
commit
7dd12e9915
4 files changed
+534
-28
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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")} />
|
||||
) : (
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user