fix: decouple attachment manager from selected note

This commit is contained in:
tianma-if committed 2026-08-10 01:26:38 +08:00
1 parent e26387b74d
commit 19b24113a4
5 files changed
+17 -200

No files matched your search

+4 -173
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
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";
@@ -19,7 +18,6 @@ import {
Search,
Grid,
List,
UploadCloud,
FileText,
FileSpreadsheet,
FileArchive,
@@ -27,15 +25,11 @@ import {
Video,
X,
Loader2,
FileUp,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { formatDateTime } from "@/lib/utils";
import { compressImageForUpload } from "@/lib/image-compression";
import { stageDesktopResource } from "@/lib/desktop-resources";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import type { MemoDetail } from "@edgeever/shared";
import type { EdgeEverRepository } from "@/lib/repository";
export const formatBytes = (bytes: number) => {
@@ -116,14 +110,11 @@ const getFileIcon = (mimeType: string | null, filename: string | null) => {
interface AssetsPaneProps {
onClose: () => void;
activeMemo?: MemoDetail | null;
repository: EdgeEverRepository;
}
export const AssetsPane = ({ onClose, activeMemo, repository }: AssetsPaneProps) => {
export const AssetsPane = ({ onClose, repository }: AssetsPaneProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
// States
const [searchQuery, setSearchQuery] = useState("");
@@ -132,8 +123,6 @@ export const AssetsPane = ({ onClose, activeMemo, repository }: AssetsPaneProps)
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({
@@ -149,63 +138,6 @@ export const AssetsPane = ({ onClose, activeMemo, repository }: 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(t("assets.uploadNth", { current: count, total: files.length }));
const isImage = file.type.startsWith("image/");
// Compress images if enabled
const shouldCompress = isImage;
if (shouldCompress) {
setUploadState("compressing");
setUploadProgress(t("assets.compressingFile", { filename: file.name }));
}
const uploadFile = shouldCompress ? (await compressImageForUpload(file)).file : file;
setUploadState("uploading");
setUploadProgress(t("assets.uploadingFile", { filename: uploadFile.name }));
if (typeof navigator !== "undefined" && !navigator.onLine) {
await repository.uploadMemoResource(targetMemoId, uploadFile);
continue;
}
try {
await repository.uploadMemoResource(targetMemoId, uploadFile);
} catch (error) {
const staged = await stageDesktopResource(targetMemoId, uploadFile);
if (!staged) throw error;
}
}
void queryClient.invalidateQueries({ queryKey: ["resources"] });
setUploadState("success");
setTimeout(() => setUploadState("idle"), 2000);
} catch (err) {
setUploadState("error");
setTimeout(() => setUploadState("idle"), 3000);
}
},
[activeMemo, queryClient, repository, t]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: handleUploadFiles,
disabled: !activeMemo || activeMemo.isDeleted,
noClick: true,
});
// Filter Logic
const filteredResources = useMemo(() => {
return resources.filter((resource) => {
@@ -248,29 +180,11 @@ export const AssetsPane = ({ onClose, activeMemo, repository }: AssetsPaneProps)
}
};
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));
}
};
const activeMemoTitle = activeMemo?.title || activeMemo?.excerpt || t("assets.unnamedMemo");
const activeMemoShortTitle = activeMemo?.title || activeMemo?.excerpt || t("assets.unnamedShort");
const getResourceMemoSource = (resource: { memoDeleted: boolean; memoTitle: string | null; memoExcerpt: string | null; memoId: string }) =>
resource.memoDeleted ? t("assets.deletedMemo") : resource.memoTitle || resource.memoExcerpt || resource.memoId;
return (
<div
{...getRootProps()}
className="relative flex h-full min-h-0 flex-col bg-white select-none outline-none"
>
<input {...getInputProps()} />
<div className="relative flex h-full min-h-0 flex-col bg-white select-none outline-none">
{/* 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">
@@ -369,45 +283,6 @@ export const AssetsPane = ({ onClose, activeMemo, repository }: AssetsPaneProps)
</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">
{t("assets.activeMemo", { title: activeMemoTitle })}
</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" />
{t("assets.uploadAttachment")}
</>
) : (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{t("assets.processing")}
</>
)}
</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">
{t("assets.noActiveMemoHint")}
</div>
)}
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto bg-slate-50/30 p-6">
<div className="mx-auto max-w-4xl">
@@ -559,50 +434,6 @@ export const AssetsPane = ({ onClose, activeMemo, repository }: AssetsPaneProps)
</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">{t("assets.dropTitle")}</h3>
<p className="mt-2 text-sm text-emerald-200 leading-relaxed">
{t("assets.dropDescription")}
<span className="block mt-1 font-bold text-white">{activeMemoShortTitle}</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">{t("assets.uploadSuccess")}</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">{t("assets.uploadError")}</span>
</>
)}
</div>
)}
{/* Lightbox Viewer */}
{lightboxIndex !== null && (
<Lightbox
+1 -1
View File
@@ -2852,7 +2852,7 @@ export const WorkspaceApp = ({
}}
/>
) : rightView === "assets" ? (
<AssetsPane onClose={handleCloseAssets} activeMemo={selectedMemo} repository={repository} />
<AssetsPane onClose={handleCloseAssets} repository={repository} />
) : rightView === "tags" ? (
<TagsPane onClose={handleCloseAssets} repository={repository} />
) : rightView === "templates" ? (
+1 -13
View File
@@ -538,9 +538,6 @@ export const enUS = {
permanentDelete: "Delete permanently",
},
assets: {
uploadNth: "Uploading file {{current}}/{{total}}...",
compressingFile: "Compressing: {{filename}}",
uploadingFile: "Uploading: {{filename}}",
title: "Attachment manager",
fileCount: "{{count}} files",
imageCount: "{{count}} images",
@@ -553,27 +550,18 @@ export const enUS = {
searchPlaceholder: "Search attachment names or source notes...",
gridView: "Grid view",
listView: "List view",
activeMemo: "Current note: “{{title}}”",
unnamedMemo: "Untitled note",
unnamedShort: "Untitled",
uploadAttachment: "Upload attachment",
processing: "Processing...",
noActiveMemoHint: "Tip: open a note in the editor on the right to drag or upload new files here.",
loading: "Loading attachments...",
noMatches: "No matching attachments",
empty: "No attachments yet",
noMatchesDescription: "Try changing the search term or category filter",
emptyDescription: "Drag files here, or upload files above",
emptyDescription: "Images and attachments added to notes will appear here",
previewImage: "Preview image",
downloadOpen: "Download/open",
deletedMemo: "Deleted note",
fromMemo: "From: {{source}}",
sourceMemo: "Source note: {{source}}",
openInNewWindow: "Open in new window",
dropTitle: "Release files here",
dropDescription: "Files will be uploaded and attached to the note currently being edited:",
uploadSuccess: "Upload complete",
uploadError: "Upload failed. Please try again later",
},
tagsDialog: {
title: "Tags",
+1 -13
View File
@@ -538,9 +538,6 @@ export const zhCN = {
permanentDelete: "永久删除",
},
assets: {
uploadNth: "正在上传第 {{current}}/{{total}} 个文件...",
compressingFile: "正在压缩: {{filename}}",
uploadingFile: "正在上传: {{filename}}",
title: "附件管理",
fileCount: "{{count}} 文件",
imageCount: "{{count}} 图片",
@@ -553,27 +550,18 @@ export const zhCN = {
searchPlaceholder: "搜索附件名或来源笔记...",
gridView: "网格视图",
listView: "列表视图",
activeMemo: "当前关联笔记:《{{title}}》",
unnamedMemo: "未命名笔记",
unnamedShort: "未命名",
uploadAttachment: "上传附件",
processing: "处理中...",
noActiveMemoHint: "提示:在右侧编辑器中打开一篇笔记,即可在此处拖放或上传新文件。",
loading: "正在加载附件列表...",
noMatches: "未找到匹配附件",
empty: "暂无附件",
noMatchesDescription: "请尝试更改搜索词或分类过滤器",
emptyDescription: "拖拽文件到这里,或在上方上传文件",
emptyDescription: "在笔记中添加的图片和附件会显示在这里",
previewImage: "大图预览",
downloadOpen: "下载/打开",
deletedMemo: "已删除笔记",
fromMemo: "来自:{{source}}",
sourceMemo: "来源笔记:{{source}}",
openInNewWindow: "在新窗口打开",
dropTitle: "将文件释放于此",
dropDescription: "文件将上传并附加到当前正在编辑的笔记:",
uploadSuccess: "上传成功!",
uploadError: "上传失败,请稍后重试",
},
tagsDialog: {
title: "标签",
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "@playwright/test";
test("keeps the global attachment manager independent from the selected note", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "附件", exact: true }).click();
await expect(page.getByRole("heading", { name: "附件管理", exact: true })).toBeVisible();
await expect(page.getByText("当前关联笔记")).toHaveCount(0);
await expect(page.getByRole("button", { name: "上传附件", exact: true })).toHaveCount(0);
});