feat(frontend): open file viewer as overlay over the mounted list

The viewer was a separate /files/[id] route, so returning tore down and
reloaded the whole grid. Now opening a file uses SvelteKit shallow
routing (pushState + page.state.fileId): the list stays mounted and the
viewer renders as a full-screen overlay on top of it, like Immich. The
URL still becomes /files/<id> and the back button (or Escape/close)
dismisses the overlay via history.back(), revealing the untouched grid —
no reload — then scrolls it to the last-viewed file instantly.

- Extract the viewer UI/logic into a reusable FileViewer component
  (file fetch, preview, lazy tags, save, prev/next, keyboard).
- List: neighbours come straight from its own files[]; paging past the
  loaded set pulls the next page by cursor (prefetch near the end).
- Paging uses replaceState so one back press returns to the grid.
- /files/[id] remains as a thin standalone fallback for deep links /
  hard reloads, resolving neighbours via the anchor API and returning
  to the grid with ?anchor=<id>.
- Remove the now-unused filesCache snapshot store (the list is never
  unmounted, so there's nothing to snapshot/restore).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:02:25 +03:00
parent 4f8d6a41f9
commit fa491487b7
5 changed files with 755 additions and 843 deletions
-131
View File
@@ -1,131 +0,0 @@
import { browser } from '$app/environment';
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 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, to find the list
* URL to return to, and extends it as the user pages past the loaded set.
*
* Held in a module variable (survives client-side navigation) AND mirrored to
* sessionStorage (survives a full reload / deep navigation within the tab).
*/
export interface FilesSnapshot {
query: FilesQuery;
/** Search string of the list URL this grid was viewed at (e.g. "?filter=x"),
* so the viewer returns to the exact same filtered list rather than bare
* /files — otherwise the filter is lost and the snapshot no longer matches. */
listSearch: string;
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 ?? ''}`;
}
const STORAGE_KEY = 'filesSnapshot';
let snapshot: FilesSnapshot | null = null;
let hydrated = false;
let loading = false;
/** Lazily restore the snapshot from sessionStorage the first time it's read so
* the position survives a page reload, not just client-side navigation. */
function hydrate(): void {
if (hydrated) return;
hydrated = true;
if (!browser) return;
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) snapshot = JSON.parse(raw) as FilesSnapshot;
} catch {
// Corrupt/missing — start fresh.
}
}
function persist(): void {
if (!browser) return;
try {
if (snapshot) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
else sessionStorage.removeItem(STORAGE_KEY);
} catch {
// Quota or serialization failure — non-critical, in-memory copy still works.
}
}
/** Save (replace) the current grid snapshot. */
export function saveFilesSnapshot(s: FilesSnapshot): void {
snapshot = s;
hydrated = true;
persist();
}
/** Read the snapshot without consuming it. */
export function peekFilesSnapshot(): FilesSnapshot | null {
hydrate();
return snapshot;
}
/** Forget the snapshot (e.g. on logout). */
export function clearFilesSnapshot(): void {
snapshot = null;
hydrated = true;
persist();
}
/** Record the file currently being viewed so back-navigation lands on it. */
export function setLastOpened(id: string): void {
hydrate();
if (snapshot) {
snapshot = { ...snapshot, lastOpenedId: id };
persist();
}
}
/**
* 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<void> {
hydrate();
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<FileCursorPage>(`/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,
};
persist();
} catch {
// Non-critical: leave the snapshot unchanged.
} finally {
loading = false;
}
}