From 6834b916cdfb11be2bc5c953a8a3e3b0516ddefa Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Wed, 17 Jun 2026 17:33:22 +0300 Subject: [PATCH] feat(frontend): replace file content from the viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend has had PUT /files/:id/content for a while, but nothing in the UI exposed it. Add a "Replace file content" action to the file viewer's top bar: it opens a file picker, confirms before overwriting (the original bytes are replaced irreversibly under the same id; tags/pools/metadata are kept), then uploads via a new api.uploadPut helper. The file id is unchanged, so the preview URL stays the same while the bytes behind it don't — refetch the preview past the browser cache (the server cache is already invalidated by the replace) and re-mint the content token so "open original" serves the new content. A spinner overlay covers the preview during the upload, and the viewer's own shortcuts yield while the confirm is open or a replace is in flight. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/api/client.ts | 4 +- .../src/lib/components/file/FileViewer.svelte | 144 +++++++++++++++++- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 92631e4..065d2bf 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -246,5 +246,7 @@ export const api = { request(path, { method: 'PUT', body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: 'DELETE' }), upload: (path: string, formData: FormData) => - request(path, { method: 'POST', body: formData }) + request(path, { method: 'POST', body: formData }), + uploadPut: (path: string, formData: FormData) => + request(path, { method: 'PUT', body: formData }) }; diff --git a/frontend/src/lib/components/file/FileViewer.svelte b/frontend/src/lib/components/file/FileViewer.svelte index 3ac5cd5..ab3d971 100644 --- a/frontend/src/lib/components/file/FileViewer.svelte +++ b/frontend/src/lib/components/file/FileViewer.svelte @@ -5,6 +5,7 @@ import { authStore } from '$lib/stores/auth'; import TagPicker from '$lib/components/file/TagPicker.svelte'; import PoolPicker from '$lib/components/file/PoolPicker.svelte'; + import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import type { File, Tag } from '$lib/api/types'; interface Props { @@ -36,6 +37,12 @@ let error = $state(''); let poolPickerOpen = $state(false); + // ---- Replace content (PUT /files/:id/content) ---- + let fileInput = $state(null); + // The picked replacement awaiting confirmation (browser File, not our API type). + let pendingReplacement = $state(null); + let replacing = $state(false); + // Tags are loaded lazily — the Tags section sits below a full-viewport // preview, so fetching them on open just hammers the DB for data the user // usually never scrolls to. We fetch only once the section comes into view. @@ -99,13 +106,20 @@ } } - async function fetchPreview(id: string) { + // `bust` bypasses the browser HTTP cache — needed after a content replace, + // where the preview URL is unchanged but the bytes behind it are not (the + // server-side cache was already invalidated by the replace). + async function fetchPreview(id: string, bust = false) { const token = get(authStore).accessToken; try { const res = await fetch(`/api/v1/files/${id}/preview`, { - headers: token ? { Authorization: `Bearer ${token}` } : {} + headers: token ? { Authorization: `Bearer ${token}` } : {}, + cache: bust ? 'reload' : 'default' }); if (res.ok && fileId === id) { + // Free the previous blob before swapping in the new one (the load + // effect nulls it on paging, but a same-file refresh would leak it). + if (previewSrc) URL.revokeObjectURL(previewSrc); previewSrc = URL.createObjectURL(await res.blob()); } } catch { @@ -224,6 +238,44 @@ } } + // ---- Replace content ---- + function pickReplacement() { + fileInput?.click(); + } + + function onReplacePicked(e: Event) { + const input = e.currentTarget as HTMLInputElement; + const picked = input.files?.[0] ?? null; + input.value = ''; // let the same file be picked again later + if (picked) pendingReplacement = picked; // confirm before overwriting + } + + async function doReplace() { + const picked = pendingReplacement; + const id = file?.id; + pendingReplacement = null; + if (!picked || !id || replacing) return; + replacing = true; + error = ''; + try { + const fd = new FormData(); + fd.append('file', picked); + const updated = await api.uploadPut(`/files/${id}/content`, fd); + if (fileId !== id) return; // paged away mid-upload + file = updated; + // Same id, new bytes: refresh the preview past the browser cache and + // re-mint the content token so "open original" serves the new content. + void fetchPreview(id, true); + void fetchContentToken(id); + } catch (e) { + // The backend sends a clear message for 415 ("unsupported MIME type") + // and size limits, so surface it directly. + error = e instanceof ApiError ? e.message : 'Failed to replace file'; + } finally { + replacing = false; + } + } + // ---- Keyboard ---- let viewerPage = $state(); let tagsSection = $state(); @@ -242,6 +294,8 @@ // Escape to clear-then-close). Yield so the viewer's shortcuts don't fire // behind the modal and don't race the picker for Escape. if (poolPickerOpen) return; + // The replace confirm dialog owns Escape; an in-flight replace blocks paging. + if (pendingReplacement || replacing) return; if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.ctrlKey || e.metaKey || e.altKey) return; // Letter keys are matched by physical position (e.code) so j/k/e work on any @@ -359,6 +413,37 @@ /> + + {/if} @@ -380,6 +465,13 @@
{/if} + {#if replacing} +
+ + Replacing… +
+ {/if} + {#if prevId}