diff --git a/frontend/src/lib/stores/filesCache.ts b/frontend/src/lib/stores/filesCache.ts new file mode 100644 index 0000000..338e11b --- /dev/null +++ b/frontend/src/lib/stores/filesCache.ts @@ -0,0 +1,85 @@ +import { api } from '$lib/api/client'; +import type { File, FileCursorPage } from '$lib/api/types'; + +/** The sort/order/filter that identifies a particular files listing. */ +export interface FilesQuery { + sort: string; + order: string; + filter: string | null; +} + +/** + * A snapshot of the files grid, kept in memory so that opening a file and + * returning restores the same list (and scroll position) instead of reloading + * page 1 from the top. The file viewer also reads this to derive prev/next and + * extends it as the user pages past the loaded set. + */ +export interface FilesSnapshot { + query: FilesQuery; + files: File[]; + nextCursor: string | null; + hasMore: boolean; + scrollTop: number; + /** ID of the file the user opened — restore the grid centred on this. */ + lastOpenedId: string | null; +} + +/** Stable string identity for a query, used to tell whether a snapshot still + * applies to the current sort/order/filter. */ +export function queryKey(q: FilesQuery): string { + return `${q.sort}|${q.order}|${q.filter ?? ''}`; +} + +let snapshot: FilesSnapshot | null = null; +let loading = false; + +/** Save (replace) the current grid snapshot. */ +export function saveFilesSnapshot(s: FilesSnapshot): void { + snapshot = s; +} + +/** Read the snapshot without consuming it. */ +export function peekFilesSnapshot(): FilesSnapshot | null { + return snapshot; +} + +/** Forget the snapshot (e.g. on logout). */ +export function clearFilesSnapshot(): void { + snapshot = null; +} + +/** Record the file currently being viewed so back-navigation lands on it. */ +export function setLastOpened(id: string): void { + if (snapshot) snapshot = { ...snapshot, lastOpenedId: id }; +} + +/** + * Append the next page to the snapshot using its own query/cursor. The file + * viewer calls this to extend the cached list as the user pages forward, so + * prev/next keep working past the originally loaded set and the grid restores + * correctly on return. No-op when there is nothing cached, no further pages, or + * a load is already in flight. + */ +export async function loadMoreIntoSnapshot(limit: number): Promise { + if (!snapshot || !snapshot.hasMore || loading) return; + loading = true; + try { + const q = snapshot.query; + const params = new URLSearchParams({ limit: String(limit), sort: q.sort, order: q.order }); + if (snapshot.nextCursor) params.set('cursor', snapshot.nextCursor); + if (q.filter) params.set('filter', q.filter); + const res = await api.get(`/files?${params}`); + // Re-read snapshot: it may have been replaced while the request was in flight. + if (!snapshot) return; + snapshot = { + ...snapshot, + files: [...snapshot.files, ...(res.items ?? [])], + nextCursor: res.next_cursor ?? null, + hasMore: !!res.next_cursor, + }; + } catch { + // Non-critical: leave the snapshot unchanged. + } finally { + loading = false; + } +} diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index d5dfdb2..8ff2bde 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -17,6 +17,12 @@ import { parseDslFilter } from '$lib/utils/dsl'; import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types'; import { appSettings } from '$lib/stores/appSettings'; + import { + saveFilesSnapshot, + peekFilesSnapshot, + queryKey, + type FilesSnapshot, + } from '$lib/stores/filesCache'; let scrollContainer = $state(); @@ -93,8 +99,21 @@ let prevKey = $state(''); $effect(() => { - if (resetKey !== prevKey) { - prevKey = resetKey; + const key = resetKey; + if (key === prevKey) return; + const firstRun = prevKey === ''; + prevKey = key; + + // On the first mount, restore the grid the user left when opening a file + // (same sort/order/filter) so back-navigation keeps their place. Any later + // change means the query itself changed → reset and reload from the top. + const snap = peekFilesSnapshot(); + if (firstRun && snap && queryKey(snap.query) === key) { + files = snap.files; + nextCursor = snap.nextCursor; + hasMore = snap.hasMore; + void tick().then(() => restoreScroll(snap)); + } else { files = []; nextCursor = null; hasMore = true; @@ -102,6 +121,21 @@ } }); + // Scroll the grid so the last-opened file is centred; fall back to the saved + // scroll offset if that card isn't present (e.g. nothing was opened). + function restoreScroll(snap: FilesSnapshot) { + if (!scrollContainer) return; + const idx = snap.lastOpenedId ? files.findIndex((f) => f.id === snap.lastOpenedId) : -1; + if (idx >= 0) { + const card = scrollContainer.querySelector(`[data-file-index="${idx}"]`); + if (card) { + card.scrollIntoView({ block: 'center' }); + return; + } + } + scrollContainer.scrollTop = snap.scrollTop; + } + async function loadMore() { if (loading || !hasMore) return; loading = true; @@ -144,7 +178,18 @@ } function openFile(file: File) { - if (file.id) goto(`/files/${file.id}`); + if (!file.id) return; + // Snapshot the grid so returning from the viewer restores this exact list + // and scroll position instead of reloading page 1 from the top. + saveFilesSnapshot({ + query: { sort: sortState.sort, order: sortState.order, filter: filterParam }, + files, + nextCursor, + hasMore, + scrollTop: scrollContainer?.scrollTop ?? 0, + lastOpenedId: file.id, + }); + goto(`/files/${file.id}`); } // ---- Selection logic ---- diff --git a/frontend/src/routes/files/[id]/+page.svelte b/frontend/src/routes/files/[id]/+page.svelte index a91359f..1817ebf 100644 --- a/frontend/src/routes/files/[id]/+page.svelte +++ b/frontend/src/routes/files/[id]/+page.svelte @@ -6,6 +6,8 @@ import { api, ApiError } from '$lib/api/client'; import { authStore } from '$lib/stores/auth'; import { fileSorting } from '$lib/stores/sorting'; + import { appSettings } from '$lib/stores/appSettings'; + import { peekFilesSnapshot, setLastOpened, loadMoreIntoSnapshot } from '$lib/stores/filesCache'; import TagPicker from '$lib/components/file/TagPicker.svelte'; import type { File, Tag, FileCursorPage } from '$lib/api/types'; @@ -61,7 +63,7 @@ dirty = false; void fetchPreview(id); - void loadNeighbors(id); + resolveNeighbors(id); } catch (e) { error = e instanceof ApiError ? e.message : 'Failed to load file'; } finally { @@ -84,7 +86,32 @@ } } - async function loadNeighbors(id: string) { + // Derive prev/next from the shared grid snapshot so paging is symmetric and + // instant and matches the order the user was browsing. As we approach the end + // of the cached list, prefetch the next page into the snapshot so forward + // paging continues and the grid restores correctly on return. + function resolveNeighbors(id: string) { + const snap = peekFilesSnapshot(); + const idx = snap ? snap.files.findIndex((f) => f.id === id) : -1; + if (snap && idx >= 0) { + prevFile = idx > 0 ? snap.files[idx - 1] : null; + nextFile = idx < snap.files.length - 1 ? snap.files[idx + 1] : null; + if (idx >= snap.files.length - 3 && snap.hasMore) { + void loadMoreIntoSnapshot(get(appSettings).fileLoadLimit).then(() => { + if (page.params.id !== id) return; // user navigated on; ignore + const s2 = peekFilesSnapshot(); + const i2 = s2 ? s2.files.findIndex((f) => f.id === id) : -1; + if (s2 && i2 >= 0) nextFile = i2 < s2.files.length - 1 ? s2.files[i2 + 1] : null; + }); + } + return; + } + // No cached grid (e.g. a deep link straight to this file) — fall back to + // an anchored window from the API. + void loadNeighborsAnchor(id); + } + + async function loadNeighborsAnchor(id: string) { const sort = get(fileSorting); const params = new URLSearchParams({ anchor: id, @@ -138,7 +165,10 @@ // ---- Navigation ---- function navigateTo(f: File | null) { - if (f?.id) goto(`/files/${f.id}`); + if (!f?.id) return; + // Remember where we paged to, so returning to the grid lands here. + setLastOpened(f.id); + goto(`/files/${f.id}`); } function handleKeydown(e: KeyboardEvent) {