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) }),
|
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
upload: <T>(path: string, formData: FormData) =>
|
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 { authStore } from '$lib/stores/auth';
|
||||||
import TagPicker from '$lib/components/file/TagPicker.svelte';
|
import TagPicker from '$lib/components/file/TagPicker.svelte';
|
||||||
import PoolPicker from '$lib/components/file/PoolPicker.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';
|
import type { File, Tag } from '$lib/api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -36,6 +37,12 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let poolPickerOpen = $state(false);
|
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
|
// 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
|
// 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.
|
// 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;
|
const token = get(authStore).accessToken;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/files/${id}/preview`, {
|
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) {
|
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());
|
previewSrc = URL.createObjectURL(await res.blob());
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 ----
|
// ---- Keyboard ----
|
||||||
let viewerPage = $state<HTMLElement>();
|
let viewerPage = $state<HTMLElement>();
|
||||||
let tagsSection = $state<HTMLElement>();
|
let tagsSection = $state<HTMLElement>();
|
||||||
@@ -242,6 +294,8 @@
|
|||||||
// Escape to clear-then-close). Yield so the viewer's shortcuts don't fire
|
// 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.
|
// behind the modal and don't race the picker for Escape.
|
||||||
if (poolPickerOpen) return;
|
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.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) 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
|
// Letter keys are matched by physical position (e.code) so j/k/e work on any
|
||||||
@@ -359,6 +413,37 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -380,6 +465,13 @@
|
|||||||
<div class="preview-placeholder failed"></div>
|
<div class="preview-placeholder failed"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if replacing}
|
||||||
|
<div class="preview-busy" role="status" aria-label="Replacing file">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span>Replacing…</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Prev / Next -->
|
<!-- Prev / Next -->
|
||||||
{#if prevId}
|
{#if prevId}
|
||||||
<button
|
<button
|
||||||
@@ -508,6 +600,16 @@
|
|||||||
<PoolPicker fileIds={[file.id!]} onClose={() => (poolPickerOpen = false)} />
|
<PoolPicker fileIds={[file.id!]} onClose={() => (poolPickerOpen = false)} />
|
||||||
{/if}
|
{/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>
|
<style>
|
||||||
.viewer-page {
|
.viewer-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -599,6 +701,15 @@
|
|||||||
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
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 ---- */
|
||||||
.preview-wrap {
|
.preview-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -630,6 +741,35 @@
|
|||||||
display: block;
|
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 {
|
.preview-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user