perf(frontend): lazy-load file tags on scroll into view
The file viewer fetched /files/:id/tags eagerly alongside the file on open, even though the Tags section sits below a full-viewport preview and is usually never seen — needless DB load per file open. Defer the tags fetch until the Tags section scrolls into view via an IntersectionObserver (200px rootMargin pre-load). Re-fetches when paging to another file while the section stays on-screen; shows a "Loading tags…" placeholder until loaded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,14 @@
|
|||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
let tagsVisible = $state(false);
|
||||||
|
let tagsLoading = $state(false);
|
||||||
|
let tagsLoadedFor = $state<string | null>(null);
|
||||||
|
let tagsLoaded = $derived(tagsLoadedFor === fileId);
|
||||||
|
|
||||||
// Editable fields (initialised on load)
|
// Editable fields (initialised on load)
|
||||||
let notes = $state('');
|
let notes = $state('');
|
||||||
let contentDatetime = $state('');
|
let contentDatetime = $state('');
|
||||||
@@ -48,13 +56,11 @@
|
|||||||
async function loadPage(id: string) {
|
async function loadPage(id: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
// Drop the previous file's tags; they reload lazily when scrolled to.
|
||||||
|
fileTags = [];
|
||||||
try {
|
try {
|
||||||
const [fileData, tags] = await Promise.all([
|
const fileData = await api.get<File>(`/files/${id}`);
|
||||||
api.get<File>(`/files/${id}`),
|
|
||||||
api.get<Tag[]>(`/files/${id}/tags`),
|
|
||||||
]);
|
|
||||||
file = fileData;
|
file = fileData;
|
||||||
fileTags = tags;
|
|
||||||
notes = fileData.notes ?? '';
|
notes = fileData.notes ?? '';
|
||||||
contentDatetime = fileData.content_datetime
|
contentDatetime = fileData.content_datetime
|
||||||
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
|
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
|
||||||
@@ -152,10 +158,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Tags ----
|
// ---- Tags (lazy) ----
|
||||||
|
// Fetch the current file's tags the first time the Tags section is visible.
|
||||||
|
// Re-runs when fileId changes while the section is still on-screen (e.g.
|
||||||
|
// keyboard paging while scrolled down).
|
||||||
|
$effect(() => {
|
||||||
|
const id = fileId;
|
||||||
|
if (id && tagsVisible && tagsLoadedFor !== id && !tagsLoading) {
|
||||||
|
void loadTags(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTags(id: string) {
|
||||||
|
tagsLoading = true;
|
||||||
|
try {
|
||||||
|
const tags = await api.get<Tag[]>(`/files/${id}/tags`);
|
||||||
|
if (page.params.id !== id) return; // user navigated on; ignore
|
||||||
|
fileTags = tags;
|
||||||
|
tagsLoadedFor = id;
|
||||||
|
} catch {
|
||||||
|
// non-critical — a later scroll into view retries
|
||||||
|
} finally {
|
||||||
|
tagsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Svelte action: flips tagsVisible while the Tags section is in (or near) the
|
||||||
|
// viewport. rootMargin pre-loads just before it scrolls fully into view.
|
||||||
|
function tagsSentinel(node: HTMLElement) {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
tagsVisible = entries[0]?.isIntersecting ?? false;
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' },
|
||||||
|
);
|
||||||
|
observer.observe(node);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
observer.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function addTag(tagId: string) {
|
async function addTag(tagId: string) {
|
||||||
const updated = await api.put<Tag[]>(`/files/${fileId}/tags/${tagId}`);
|
const updated = await api.put<Tag[]>(`/files/${fileId}/tags/${tagId}`);
|
||||||
fileTags = updated;
|
fileTags = updated;
|
||||||
|
tagsLoadedFor = fileId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeTag(tagId: string) {
|
async function removeTag(tagId: string) {
|
||||||
@@ -307,10 +355,14 @@
|
|||||||
{saving ? 'Saving…' : 'Save changes'}
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags (loaded lazily on scroll) -->
|
||||||
<section class="section">
|
<section class="section" use:tagsSentinel>
|
||||||
<div class="field-label">Tags</div>
|
<div class="field-label">Tags</div>
|
||||||
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
|
{#if tagsLoaded}
|
||||||
|
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
|
||||||
|
{:else}
|
||||||
|
<p class="tags-loading">Loading tags…</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- EXIF -->
|
<!-- EXIF -->
|
||||||
@@ -585,6 +637,14 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Tags ---- */
|
||||||
|
.tags-loading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- EXIF ---- */
|
/* ---- EXIF ---- */
|
||||||
.exif {
|
.exif {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user