diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index d1a0cd5..01100d6 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -2,9 +2,26 @@ import { get } from 'svelte/store'; import { goto } from '$app/navigation'; import { browser } from '$app/environment'; import { authStore } from '$lib/stores/auth'; +import { clearSection, type SectionKey } from '$lib/stores/sectionCache'; const BASE = '/api/v1'; +// The tags/categories/pools lists are edited on their own detail/new pages, so a +// cached list snapshot goes stale after a write there. Drop the matching +// section's snapshot on any successful mutation so the list refetches on return. +// (Files isn't included — its grid keeps itself consistent via optimistic +// updates, and over-invalidating would needlessly lose the scroll position.) +function invalidateSectionCache(path: string, method: string): void { + if (method === 'GET') return; + const sections: SectionKey[] = ['tags', 'categories', 'pools']; + for (const s of sections) { + if (path === `/${s}` || path.startsWith(`/${s}/`) || path.startsWith(`/${s}?`)) { + clearSection(s); + return; + } + } +} + /** Clear the session and bounce to the login screen. Called when the refresh * token is missing or rejected, so an expired session doesn't strand the user * on a page that only shows errors. */ @@ -104,6 +121,8 @@ async function request(path: string, init?: RequestInit): Promise { ); } + invalidateSectionCache(path, (init?.method ?? 'GET').toUpperCase()); + if (res.status === 204) return undefined as T; return res.json(); } diff --git a/frontend/src/lib/stores/listScroll.ts b/frontend/src/lib/stores/listScroll.ts new file mode 100644 index 0000000..5475b8e --- /dev/null +++ b/frontend/src/lib/stores/listScroll.ts @@ -0,0 +1,19 @@ +// Reapply a restored scroll offset to a list's scroller, retrying across frames +// because the list may not be laid out yet right after a cache rehydrate (and +// SvelteKit resets scroll to the top on navigation, so this has to win after). +export function restoreListScroll(getEl: () => HTMLElement | undefined, top: number): void { + let tries = 12; + const apply = () => { + const el = getEl(); + if (!el) { + if (tries-- > 0) requestAnimationFrame(apply); + return; + } + if (el.scrollHeight > top + el.clientHeight || tries-- <= 0) { + el.scrollTop = top; + return; + } + requestAnimationFrame(apply); + }; + requestAnimationFrame(apply); +} diff --git a/frontend/src/lib/stores/sectionCache.ts b/frontend/src/lib/stores/sectionCache.ts index be7b3ff..ea5eda1 100644 --- a/frontend/src/lib/stores/sectionCache.ts +++ b/frontend/src/lib/stores/sectionCache.ts @@ -10,6 +10,16 @@ export type SectionKey = 'files' | 'tags' | 'categories' | 'pools'; +/** Snapshot shape shared by the offset-paginated lists (tags/categories/pools). */ +export interface OffsetListSnapshot { + /** sort|order|search at capture — guards against restoring a different query. */ + resetKey: string; + search: string; + items: T[]; + total: number; + offset: number; +} + interface Snapshot { /** Scroll offset of the list's scroller at capture time. */ scrollTop: number; diff --git a/frontend/src/routes/categories/+page.svelte b/frontend/src/routes/categories/+page.svelte index b30d659..2007a06 100644 --- a/frontend/src/routes/categories/+page.svelte +++ b/frontend/src/routes/categories/+page.svelte @@ -1,8 +1,11 @@