fix: decouple attachment manager from selected note
This commit is contained in:
1 parent
e26387b74d
commit
19b24113a4
5 files changed
+17
-200
No files matched your search
@@ -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
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "标签",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user