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
+85 -50
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { afterNavigate, goto, replaceState } from '$app/navigation';
import { afterNavigate, goto, pushState, replaceState } from '$app/navigation';
import { api } from '$lib/api/client';
import { ApiError } from '$lib/api/client';
import FileCard from '$lib/components/file/FileCard.svelte';
import FileViewer from '$lib/components/file/FileViewer.svelte';
import FileUpload from '$lib/components/file/FileUpload.svelte';
import FilterBar from '$lib/components/file/FilterBar.svelte';
import Header from '$lib/components/layout/Header.svelte';
@@ -17,11 +18,6 @@
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,
} from '$lib/stores/filesCache';
let scrollContainer = $state<HTMLElement | undefined>();
@@ -98,49 +94,34 @@
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
let prevKey = $state('');
// Restore the grid DATA on entry. Scroll restoration is handled separately in
// afterNavigate (below), which runs after SvelteKit's own scroll reset.
// Reset + reload when the query (sort/order/filter) changes or on first mount.
// The viewer opens as an overlay now (the list is never unmounted), so there's
// no snapshot to restore — except a deep-link return carrying an anchor.
$effect(() => {
const key = resetKey;
if (key === prevKey) return;
const firstRun = prevKey === '';
prevKey = key;
// On entry, restore the grid the user left when opening a file (same
// sort/order/filter) so back-navigation keeps their place. A 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;
} else {
files = [];
nextCursor = null;
hasMore = true;
error = '';
// Deep link / reload carrying a position anchor but no cached grid:
// load a window starting at the anchor so we have something to scroll to.
if (firstRun && anchorParam) {
void loadAroundAnchor(anchorParam);
}
files = [];
nextCursor = null;
hasMore = true;
error = '';
// Deep-link return carrying a position anchor but no loaded grid: load a
// window starting at the anchor instead of page 1, so we can scroll to it.
if (firstRun && anchorParam) {
void loadAroundAnchor(anchorParam);
}
});
// Scroll restoration runs here because afterNavigate fires AFTER SvelteKit has
// applied its own scroll handling, so our position wins instead of being reset
// to the top. The anchor (last-viewed file) is read from the URL.
afterNavigate((nav) => {
// Scroll to an ?anchor= file on a deep-link return. Runs in afterNavigate
// because it fires AFTER SvelteKit's own scroll handling, so our position wins
// instead of being reset to the top.
afterNavigate(() => {
const anchor = page.url.searchParams.get('anchor');
if (anchor) {
scrollToFile(anchor);
consumeAnchor();
return;
}
// Plain entry/reload (no explicit anchor): fall back to the snapshot's
// last-opened file so a refresh still lands near where the user was.
if (nav.type === 'enter') {
scrollToFile(peekFilesSnapshot()?.lastOpenedId ?? null);
}
});
@@ -243,20 +224,50 @@
function openFile(file: File) {
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 },
// Only the filter — never the transient ?anchor — defines the list URL
// to return to.
listSearch: filterParam ? `?filter=${encodeURIComponent(filterParam)}` : '',
files,
nextCursor,
hasMore,
scrollTop: scrollContainer?.scrollTop ?? 0,
lastOpenedId: file.id,
});
goto(`/files/${file.id}`);
// Open the viewer as an overlay on top of the still-mounted grid via
// shallow routing: the URL becomes /files/<id> and the browser back button
// closes it, but the list is never torn down or reloaded.
pushState(`/files/${file.id}`, { fileId: file.id });
}
// ---- Viewer overlay (shallow routing) ----
let activeFileId = $derived(page.state.fileId);
let activeIdx = $derived(activeFileId ? files.findIndex((f) => f.id === activeFileId) : -1);
let viewerPrevId = $derived(activeIdx > 0 ? (files[activeIdx - 1]?.id ?? null) : null);
let viewerNextId = $derived(
activeIdx >= 0 && activeIdx < files.length - 1 ? (files[activeIdx + 1]?.id ?? null) : null,
);
// Paging near the end of the loaded grid: pull the next page by cursor so the
// viewer keeps advancing past what was loaded.
$effect(() => {
if (activeIdx >= 0 && activeIdx >= files.length - 3 && hasMore && !loading) {
void loadMore();
}
});
// When the overlay closes (back / Escape / close button), bring the grid to
// the last-viewed file. The list was never unmounted, so this is instant.
let lastOverlayId: string | null = null;
$effect(() => {
const id = activeFileId;
if (id) {
lastOverlayId = id;
} else if (lastOverlayId) {
const target = lastOverlayId;
lastOverlayId = null;
scrollToFile(target);
}
});
function pageTo(id: string) {
// Replace (not push) so a single back press returns to the grid rather than
// stepping back through every file paged.
replaceState(`/files/${id}`, { fileId: id });
}
function closeViewer() {
history.back();
}
// ---- Selection logic ----
@@ -393,6 +404,20 @@
</FileUpload>
</div>
<!-- File viewer overlay (shallow routing): renders on top of the still-mounted
grid, so closing it reveals the list untouched. -->
{#if activeFileId}
<div class="viewer-overlay">
<FileViewer
fileId={activeFileId}
prevId={viewerPrevId}
nextId={viewerNextId}
onNavigate={pageTo}
onClose={closeViewer}
/>
</div>
{/if}
{#if $selectionActive}
<SelectionBar
onEditTags={() => (tagEditorOpen = true)}
@@ -489,6 +514,16 @@
flex-direction: column;
}
/* Full-screen overlay covering the grid and the bottom navbar (z 100). */
.viewer-overlay {
position: fixed;
inset: 0;
z-index: 200;
background-color: var(--color-bg-primary);
overflow-y: auto;
overscroll-behavior: contain;
}
main {
flex: 1;
overflow-y: auto;