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 <noreply@anthropic.com>
This commit is contained in:
@@ -246,5 +246,7 @@ export const api = {
|
||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
upload: <T>(path: string, formData: FormData) =>
|
||||
request<T>(path, { method: 'POST', body: formData })
|
||||
request<T>(path, { method: 'POST', body: formData }),
|
||||
uploadPut: <T>(path: string, formData: FormData) =>
|
||||
request<T>(path, { method: 'PUT', body: formData })
|
||||
};
|
||||
|
||||
@@ -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<HTMLInputElement | null>(null);
|
||||
// The picked replacement awaiting confirmation (browser File, not our API type).
|
||||
let pendingReplacement = $state<globalThis.File | null>(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<File>(`/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<HTMLElement>();
|
||||
let tagsSection = $state<HTMLElement>();
|
||||
@@ -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 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="pool-btn"
|
||||
onclick={pickReplacement}
|
||||
disabled={replacing}
|
||||
aria-label="Replace file content"
|
||||
title="Replace file content"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M5 8a5 5 0 0 1 8.5-2.5L15 7M15 12a5 5 0 0 1-8.5 2.5L5 13"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 4v3h-3M5 16v-3h3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
class="replace-input"
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
bind:this={fileInput}
|
||||
onchange={onReplacePicked}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -380,6 +465,13 @@
|
||||
<div class="preview-placeholder failed"></div>
|
||||
{/if}
|
||||
|
||||
{#if replacing}
|
||||
<div class="preview-busy" role="status" aria-label="Replacing file">
|
||||
<span class="spinner"></span>
|
||||
<span>Replacing…</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Prev / Next -->
|
||||
{#if prevId}
|
||||
<button
|
||||
@@ -508,6 +600,16 @@
|
||||
<PoolPicker fileIds={[file.id!]} onClose={() => (poolPickerOpen = false)} />
|
||||
{/if}
|
||||
|
||||
{#if pendingReplacement}
|
||||
<ConfirmDialog
|
||||
message={`Replace this file's content with "${pendingReplacement.name}"? The original is overwritten and cannot be recovered; tags, pools and other metadata are kept.`}
|
||||
confirmLabel="Replace"
|
||||
danger
|
||||
onConfirm={doReplace}
|
||||
onCancel={() => (pendingReplacement = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.viewer-page {
|
||||
display: flex;
|
||||
@@ -599,6 +701,15 @@
|
||||
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pool-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.replace-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Preview ---- */
|
||||
.preview-wrap {
|
||||
position: relative;
|
||||
@@ -630,6 +741,35 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-busy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user