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
+5 -1
View File
@@ -5,7 +5,11 @@ declare global {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
interface PageState {
/** Set via shallow routing when the file viewer is open as an overlay
* on top of the files list. */
fileId?: string;
}
// interface Platform {}
}
}
@@ -0,0 +1,638 @@
<script lang="ts">
import { get } from 'svelte/store';
import { untrack, onDestroy } from 'svelte';
import { api, ApiError } from '$lib/api/client';
import { authStore } from '$lib/stores/auth';
import TagPicker from '$lib/components/file/TagPicker.svelte';
import type { File, Tag } from '$lib/api/types';
interface Props {
/** File currently shown. Changing it (paging) reloads in place. */
fileId: string;
/** Neighbour ids resolved by the parent; null hides the arrow. */
prevId?: string | null;
nextId?: string | null;
/** Page to a neighbour. */
onNavigate: (id: string) => void;
/** Close the viewer. */
onClose: () => void;
}
let { fileId, prevId = null, nextId = null, onNavigate, onClose }: Props = $props();
let file = $state<File | null>(null);
let fileTags = $state<Tag[]>([]);
let previewSrc = $state<string | null>(null);
let loading = $state(true);
let saving = $state(false);
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)
let notes = $state('');
let contentDatetime = $state('');
let isPublic = $state(false);
let dirty = $state(false);
let exifEntries = $derived(
file?.exif ? Object.entries(file.exif as Record<string, unknown>) : [],
);
// ---- Load (re-runs whenever the file changes, i.e. paging) ----
$effect(() => {
if (!fileId) return;
const id = fileId; // snapshot — don't re-run if other state changes
// Revoke old blob URL without tracking previewSrc as a dependency.
untrack(() => {
if (previewSrc) URL.revokeObjectURL(previewSrc);
previewSrc = null;
});
void loadFile(id);
});
onDestroy(() => {
if (previewSrc) URL.revokeObjectURL(previewSrc);
});
async function loadFile(id: string) {
loading = true;
error = '';
// Drop the previous file's tags; they reload lazily when scrolled to.
fileTags = [];
try {
const fileData = await api.get<File>(`/files/${id}`);
if (fileId !== id) return; // paged on; ignore
file = fileData;
notes = fileData.notes ?? '';
contentDatetime = fileData.content_datetime
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
: '';
isPublic = fileData.is_public ?? false;
dirty = false;
void fetchPreview(id);
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load file';
} finally {
loading = false;
}
}
async function fetchPreview(id: string) {
const token = get(authStore).accessToken;
try {
const res = await fetch(`/api/v1/files/${id}/preview`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.ok && fileId === id) {
previewSrc = URL.createObjectURL(await res.blob());
}
} catch {
// non-critical — thumbnail stays as fallback
}
}
// ---- 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.
$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 (fileId !== id) return; // paged 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) {
const updated = await api.put<Tag[]>(`/files/${fileId}/tags/${tagId}`);
fileTags = updated;
tagsLoadedFor = fileId;
}
async function removeTag(tagId: string) {
await api.delete(`/files/${fileId}/tags/${tagId}`);
fileTags = fileTags.filter((t) => t.id !== tagId);
}
// ---- Save ----
async function save() {
if (!file || saving) return;
saving = true;
error = '';
try {
const updated = await api.patch<File>(`/files/${file.id}`, {
notes: notes.trim() || null,
content_datetime: contentDatetime
? new Date(contentDatetime).toISOString()
: undefined,
is_public: isPublic,
});
file = updated;
dirty = false;
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to save';
} finally {
saving = false;
}
}
// ---- Keyboard ----
function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft') {
if (prevId) onNavigate(prevId);
} else if (e.key === 'ArrowRight') {
if (nextId) onNavigate(nextId);
} else if (e.key === 'Escape') {
onClose();
}
}
// ---- Helpers ----
function formatDatetime(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleString();
}
// EXIF values may be nested arrays/objects (e.g. rationals, GPS); render those
// as JSON instead of the useless "[object Object]".
function formatExifValue(val: unknown): string {
if (val === null || val === undefined) return '—';
if (typeof val === 'object') return JSON.stringify(val);
return String(val);
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="viewer-page">
<!-- Top bar -->
<div class="top-bar">
<button class="back-btn" onclick={onClose} aria-label="Back to files">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<span class="filename">{file?.original_name ?? ''}</span>
</div>
<!-- Preview -->
<div class="preview-wrap">
{#if previewSrc}
<img src={previewSrc} alt={file?.original_name ?? ''} class="preview-img" />
{:else if loading}
<div class="preview-placeholder shimmer"></div>
{:else}
<div class="preview-placeholder failed"></div>
{/if}
<!-- Prev / Next -->
{#if prevId}
<button
class="nav-btn nav-prev"
onclick={() => prevId && onNavigate(prevId)}
aria-label="Previous file"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M11 3L5 9L11 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{/if}
{#if nextId}
<button
class="nav-btn nav-next"
onclick={() => nextId && onNavigate(nextId)}
aria-label="Next file"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M7 3L13 9L7 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{/if}
</div>
<!-- Metadata panel -->
<div class="meta-panel">
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if file}
<!-- File info -->
<div class="info-row">
<span class="mime">{file.mime_type}</span>
<span class="sep">·</span>
<span class="created">Added {formatDatetime(file.created_at)}</span>
</div>
<!-- Edit form -->
<section class="section">
<label class="field-label" for="notes">Notes</label>
<textarea
id="notes"
class="textarea"
rows="3"
bind:value={notes}
oninput={() => (dirty = true)}
placeholder="Add notes…"
></textarea>
</section>
<section class="section">
<label class="field-label" for="datetime">Date taken</label>
<input
id="datetime"
type="datetime-local"
class="input"
bind:value={contentDatetime}
oninput={() => (dirty = true)}
/>
</section>
<section class="section toggle-row">
<span class="field-label">Public</span>
<button
class="toggle"
class:on={isPublic}
onclick={() => { isPublic = !isPublic; dirty = true; }}
role="switch"
aria-checked={isPublic}
aria-label="Public"
>
<span class="thumb"></span>
</button>
</section>
<button
class="save-btn"
onclick={save}
disabled={!dirty || saving}
>
{saving ? 'Saving…' : 'Save changes'}
</button>
<!-- Tags (loaded lazily on scroll) -->
<section class="section" use:tagsSentinel>
<div class="field-label">Tags</div>
{#if tagsLoaded}
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
{:else}
<p class="tags-loading">Loading tags…</p>
{/if}
</section>
<!-- EXIF -->
{#if exifEntries.length > 0}
<section class="section">
<div class="field-label">EXIF</div>
<dl class="exif">
{#each exifEntries as [key, val]}
<dt>{key}</dt>
<dd>{formatExifValue(val)}</dd>
{/each}
</dl>
</section>
{/if}
{:else if !loading}
<p class="empty">File not found.</p>
{/if}
</div>
</div>
<style>
.viewer-page {
display: flex;
flex-direction: column;
min-height: 0;
padding-bottom: 70px; /* clear the bottom navbar in the standalone route */
}
/* ---- Top bar ---- */
.top-bar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background-color: var(--color-bg-primary);
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
min-height: 44px;
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: none;
color: var(--color-text-primary);
cursor: pointer;
flex-shrink: 0;
}
.back-btn:hover {
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.filename {
font-size: 0.9rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Preview ---- */
.preview-wrap {
position: relative;
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
/* Fill viewport below the top bar (44px) */
height: calc(100dvh - 44px);
flex-shrink: 0;
overflow: hidden;
}
.preview-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}
.preview-placeholder {
width: 100%;
height: 100%;
}
.preview-placeholder.shimmer {
background: linear-gradient(
90deg,
#111 25%,
#222 50%,
#111 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
.preview-placeholder.failed {
background-color: #1a1010;
}
/* ---- Nav buttons ---- */
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s;
}
.nav-btn:hover {
background-color: rgba(0, 0, 0, 0.8);
}
.nav-prev { left: 10px; }
.nav-next { right: 10px; }
/* ---- Metadata panel ---- */
.meta-panel {
padding: 14px 14px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.info-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: var(--color-text-muted);
padding-bottom: 10px;
}
.sep { opacity: 0.4; }
.section {
padding: 10px 0;
border-top: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.textarea {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
outline: none;
min-height: 70px;
}
.textarea:focus {
border-color: var(--color-accent);
}
.input {
width: 100%;
box-sizing: border-box;
height: 36px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.875rem;
font-family: inherit;
outline: none;
color-scheme: dark;
}
.input:focus {
border-color: var(--color-accent);
}
/* ---- Toggle ---- */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
padding-bottom: 12px;
}
.toggle-row .field-label {
margin-bottom: 0;
}
.toggle {
position: relative;
width: 44px;
height: 26px;
border-radius: 13px;
border: none;
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
cursor: pointer;
transition: background-color 0.2s;
flex-shrink: 0;
}
.toggle.on {
background-color: var(--color-accent);
}
.thumb {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #fff;
transition: transform 0.2s;
}
.toggle.on .thumb {
transform: translateX(18px);
}
/* ---- Save button ---- */
.save-btn {
width: 100%;
height: 40px;
border-radius: 8px;
border: none;
background-color: var(--color-accent);
color: var(--color-bg-primary);
font-size: 0.9rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
margin-top: 4px;
margin-bottom: 4px;
transition: background-color 0.15s, opacity 0.15s;
}
.save-btn:hover:not(:disabled) {
background-color: var(--color-accent-hover);
}
.save-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* ---- Tags ---- */
.tags-loading {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
opacity: 0.7;
}
/* ---- EXIF ---- */
.exif {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
font-size: 0.78rem;
margin: 0;
}
dt {
color: var(--color-text-muted);
font-weight: 500;
}
dd {
margin: 0;
color: var(--color-text-primary);
word-break: break-word;
}
/* ---- Misc ---- */
.error {
color: var(--color-danger);
font-size: 0.875rem;
padding: 8px 0;
}
.empty {
color: var(--color-text-muted);
font-size: 0.95rem;
text-align: center;
padding: 40px 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
-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;
}
}
+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;
+27 -661
View File
@@ -2,122 +2,28 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { untrack } from 'svelte';
import { api, ApiError } from '$lib/api/client';
import { authStore } from '$lib/stores/auth';
import { api } from '$lib/api/client';
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';
import FileViewer from '$lib/components/file/FileViewer.svelte';
import type { FileCursorPage } from '$lib/api/types';
// ---- State ----
// This standalone route is the fallback for a deep link / hard reload to a
// file. The normal path (opening from the grid) renders FileViewer as an
// overlay on the still-mounted list via shallow routing — see files/+page.
let fileId = $derived(page.params.id);
let file = $state<File | null>(null);
let fileTags = $state<Tag[]>([]);
let previewSrc = $state<string | null>(null);
let prevFile = $state<File | null>(null);
let nextFile = $state<File | null>(null);
let loading = $state(true);
let saving = $state(false);
let error = $state('');
let prevId = $state<string | null>(null);
let nextId = $state<string | null>(null);
// 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)
let notes = $state('');
let contentDatetime = $state('');
let isPublic = $state(false);
let dirty = $state(false);
let exifEntries = $derived(
file?.exif ? Object.entries(file.exif as Record<string, unknown>) : [],
);
// ---- Load ----
$effect(() => {
if (!fileId) return;
const id = fileId; // snapshot — don't re-run if other state changes
// Revoke old blob URL without tracking previewSrc as a dependency
untrack(() => {
if (previewSrc) URL.revokeObjectURL(previewSrc);
previewSrc = null;
});
void loadPage(id);
const id = fileId;
if (id) void resolveNeighbors(id);
});
async function loadPage(id: string) {
loading = true;
error = '';
// Drop the previous file's tags; they reload lazily when scrolled to.
fileTags = [];
try {
const fileData = await api.get<File>(`/files/${id}`);
file = fileData;
notes = fileData.notes ?? '';
contentDatetime = fileData.content_datetime
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
: '';
isPublic = fileData.is_public ?? false;
dirty = false;
void fetchPreview(id);
resolveNeighbors(id);
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load file';
} finally {
loading = false;
}
}
async function fetchPreview(id: string) {
const token = get(authStore).accessToken;
try {
const res = await fetch(`/api/v1/files/${id}/preview`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.ok) {
const blob = await res.blob();
previewSrc = URL.createObjectURL(blob);
}
} catch {
// non-critical — thumbnail stays as fallback
}
}
// 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) {
// No cached grid here, so derive neighbours from an anchored window. The
// backend anchor window is forward-inclusive, so prev is only available once
// we're past the first item of that window.
async function resolveNeighbors(id: string) {
const sort = get(fileSorting);
const params = new URLSearchParams({
anchor: id,
@@ -127,572 +33,32 @@
});
try {
const result = await api.get<FileCursorPage>(`/files?${params}`);
if (fileId !== id) return;
const items = result.items ?? [];
const idx = items.findIndex((f) => f.id === id);
prevFile = idx > 0 ? items[idx - 1] : null;
nextFile = idx >= 0 && idx < items.length - 1 ? items[idx + 1] : null;
prevId = idx > 0 ? (items[idx - 1].id ?? null) : null;
nextId = idx >= 0 && idx < items.length - 1 ? (items[idx + 1].id ?? null) : null;
} catch {
// non-critical
}
}
// ---- Save ----
async function save() {
if (!file || saving) return;
saving = true;
error = '';
try {
const updated = await api.patch<File>(`/files/${file.id}`, {
notes: notes.trim() || null,
content_datetime: contentDatetime
? new Date(contentDatetime).toISOString()
: undefined,
is_public: isPublic,
});
file = updated;
dirty = false;
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to save';
} finally {
saving = false;
}
function pageTo(id: string) {
goto(`/files/${id}`);
}
// ---- 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(() => {
function closeViewer() {
// No list mounted underneath — go to the grid, carrying the file as an
// anchor so it scrolls into view there.
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) {
const updated = await api.put<Tag[]>(`/files/${fileId}/tags/${tagId}`);
fileTags = updated;
tagsLoadedFor = fileId ?? null;
}
async function removeTag(tagId: string) {
await api.delete(`/files/${fileId}/tags/${tagId}`);
fileTags = fileTags.filter((t) => t.id !== tagId);
}
// ---- Navigation ----
function navigateTo(f: File | null) {
if (!f?.id) return;
// Remember where we paged to, so returning to the grid lands here.
setLastOpened(f.id);
goto(`/files/${f.id}`);
}
// Return to the list the user came from, passing the current file as an
// ?anchor=<id> so the grid scrolls back to it (the position is carried in the
// URL — survives reload and doesn't depend on hidden in-memory state).
// noScroll stops SvelteKit from jumping the list to the top first.
function backToList() {
const snap = peekFilesSnapshot();
const params = new URLSearchParams(snap?.listSearch ?? '');
if (fileId) params.set('anchor', fileId);
const qs = params.toString();
goto('/files' + (qs ? `?${qs}` : ''), { noScroll: true });
}
function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft') navigateTo(prevFile);
if (e.key === 'ArrowRight') navigateTo(nextFile);
if (e.key === 'Escape') backToList();
}
// ---- Helpers ----
function formatDatetime(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleString();
}
// EXIF values may be nested arrays/objects (e.g. rationals, GPS); render those
// as JSON instead of the useless "[object Object]".
function formatExifValue(val: unknown): string {
if (val === null || val === undefined) return '—';
if (typeof val === 'object') return JSON.stringify(val);
return String(val);
goto('/files' + (id ? `?anchor=${id}` : ''), { noScroll: true });
}
</script>
<svelte:head>
<title>
{file?.original_name ?? fileId} | Tanabata
</title>
<title>{fileId} | Tanabata</title>
</svelte:head>
<svelte:window onkeydown={handleKeydown} />
<div class="viewer-page">
<!-- Top bar -->
<div class="top-bar">
<button class="back-btn" onclick={backToList} aria-label="Back to files">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<span class="filename">{file?.original_name ?? ''}</span>
</div>
<!-- Preview -->
<div class="preview-wrap">
{#if previewSrc}
<img src={previewSrc} alt={file?.original_name ?? ''} class="preview-img" />
{:else if loading}
<div class="preview-placeholder shimmer"></div>
{:else}
<div class="preview-placeholder failed"></div>
{/if}
<!-- Prev / Next -->
{#if prevFile}
<button
class="nav-btn nav-prev"
onclick={() => navigateTo(prevFile)}
aria-label="Previous file"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M11 3L5 9L11 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{/if}
{#if nextFile}
<button
class="nav-btn nav-next"
onclick={() => navigateTo(nextFile)}
aria-label="Next file"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M7 3L13 9L7 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{/if}
</div>
<!-- Metadata panel -->
<div class="meta-panel">
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if file}
<!-- File info -->
<div class="info-row">
<span class="mime">{file.mime_type}</span>
<span class="sep">·</span>
<span class="created">Added {formatDatetime(file.created_at)}</span>
</div>
<!-- Edit form -->
<section class="section">
<label class="field-label" for="notes">Notes</label>
<textarea
id="notes"
class="textarea"
rows="3"
bind:value={notes}
oninput={() => (dirty = true)}
placeholder="Add notes…"
></textarea>
</section>
<section class="section">
<label class="field-label" for="datetime">Date taken</label>
<input
id="datetime"
type="datetime-local"
class="input"
bind:value={contentDatetime}
oninput={() => (dirty = true)}
/>
</section>
<section class="section toggle-row">
<span class="field-label">Public</span>
<button
class="toggle"
class:on={isPublic}
onclick={() => { isPublic = !isPublic; dirty = true; }}
role="switch"
aria-checked={isPublic}
aria-label="Public"
>
<span class="thumb"></span>
</button>
</section>
<button
class="save-btn"
onclick={save}
disabled={!dirty || saving}
>
{saving ? 'Saving…' : 'Save changes'}
</button>
<!-- Tags (loaded lazily on scroll) -->
<section class="section" use:tagsSentinel>
<div class="field-label">Tags</div>
{#if tagsLoaded}
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
{:else}
<p class="tags-loading">Loading tags…</p>
{/if}
</section>
<!-- EXIF -->
{#if exifEntries.length > 0}
<section class="section">
<div class="field-label">EXIF</div>
<dl class="exif">
{#each exifEntries as [key, val]}
<dt>{key}</dt>
<dd>{formatExifValue(val)}</dd>
{/each}
</dl>
</section>
{/if}
{:else if !loading}
<p class="empty">File not found.</p>
{/if}
</div>
</div>
<style>
.viewer-page {
display: flex;
flex-direction: column;
min-height: 0;
padding-bottom: 70px; /* clear navbar */
}
/* ---- Top bar ---- */
.top-bar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background-color: var(--color-bg-primary);
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
min-height: 44px;
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: none;
color: var(--color-text-primary);
cursor: pointer;
flex-shrink: 0;
}
.back-btn:hover {
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.filename {
font-size: 0.9rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Preview ---- */
.preview-wrap {
position: relative;
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
/* Fill viewport below the top bar (44px) */
height: calc(100dvh - 44px);
flex-shrink: 0;
overflow: hidden;
}
.preview-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}
.preview-placeholder {
width: 100%;
height: 100%;
}
.preview-placeholder.shimmer {
background: linear-gradient(
90deg,
#111 25%,
#222 50%,
#111 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
.preview-placeholder.failed {
background-color: #1a1010;
}
/* ---- Nav buttons ---- */
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s;
}
.nav-btn:hover {
background-color: rgba(0, 0, 0, 0.8);
}
.nav-prev { left: 10px; }
.nav-next { right: 10px; }
/* ---- Metadata panel ---- */
.meta-panel {
padding: 14px 14px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.info-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: var(--color-text-muted);
padding-bottom: 10px;
}
.sep { opacity: 0.4; }
.section {
padding: 10px 0;
border-top: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.textarea {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
outline: none;
min-height: 70px;
}
.textarea:focus {
border-color: var(--color-accent);
}
.input {
width: 100%;
box-sizing: border-box;
height: 36px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.875rem;
font-family: inherit;
outline: none;
color-scheme: dark;
}
.input:focus {
border-color: var(--color-accent);
}
/* ---- Toggle ---- */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
padding-bottom: 12px;
}
.toggle-row .field-label {
margin-bottom: 0;
}
.toggle {
position: relative;
width: 44px;
height: 26px;
border-radius: 13px;
border: none;
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
cursor: pointer;
transition: background-color 0.2s;
flex-shrink: 0;
}
.toggle.on {
background-color: var(--color-accent);
}
.thumb {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #fff;
transition: transform 0.2s;
}
.toggle.on .thumb {
transform: translateX(18px);
}
/* ---- Save button ---- */
.save-btn {
width: 100%;
height: 40px;
border-radius: 8px;
border: none;
background-color: var(--color-accent);
color: var(--color-bg-primary);
font-size: 0.9rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
margin-top: 4px;
margin-bottom: 4px;
transition: background-color 0.15s, opacity 0.15s;
}
.save-btn:hover:not(:disabled) {
background-color: var(--color-accent-hover);
}
.save-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* ---- Tags ---- */
.tags-loading {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
opacity: 0.7;
}
/* ---- EXIF ---- */
.exif {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
font-size: 0.78rem;
margin: 0;
}
dt {
color: var(--color-text-muted);
font-weight: 500;
}
dd {
margin: 0;
color: var(--color-text-primary);
word-break: break-word;
}
/* ---- Misc ---- */
.error {
color: var(--color-danger);
font-size: 0.875rem;
padding: 8px 0;
}
.empty {
color: var(--color-text-muted);
font-size: 0.95rem;
text-align: center;
padding: 40px 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
{#if fileId}
<FileViewer {fileId} {prevId} {nextId} onNavigate={pageTo} onClose={closeViewer} />
{/if}