5 Commits

Author SHA1 Message Date
H1K0 e801eec47d feat(frontend): bidirectional lazy load for anchored grid returns
Returning to the grid at a deep position (deep link / hard reload to a
file, then back → /files?anchor=<id>) used to load only a tiny forward
window at the anchor. Now the grid fills the viewport around the anchor
and pages in both directions as the user scrolls.

- loadAroundAnchor fetches a window centred on the anchor and pre-fills a
  few pages each way sequentially, then centres on the anchor once. Doing
  the initial fill explicitly (rather than via the sentinels) keeps the
  pages contiguous and leaves the sentinels out of range, so there's no
  mount-time load storm.
- loading starts true when the URL carries an ?anchor, so the child
  InfiniteScroll sentinels (whose effects run before this page's reset
  effect on mount) can't fire a stray page-1 loadMore that interleaves
  with loadAroundAnchor.
- loadPrev pages backward (direction=backward) and prepends, then shifts
  the scroller down by the added height via flushSync (no paint between
  prepend and correction) so the viewport stays visually fixed.
- InfiniteScroll gains an `edge` prop; a top instance (shown only when
  hasPrev) drives upward loading. Both loaders share the `loading` guard.
- Mock: honour direction=backward and emit prev_cursor; the Go backend
  already supports backward keyset pagination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:16:32 +03:00
H1K0 dc1af8c585 fix(frontend): make infinite scroll viewport-relative, stop eager-loading
Lazy load fetched the entire list at once: every list's loader had a
"fill the viewport" recursion gated on
scrollContainer.scrollHeight <= clientHeight, but <main> is not the
scroller (the window/body is), so that condition is always true and it
recursed through every page (with a 10-item window, ~all pages fired at
once).

Move the filling logic into InfiniteScroll and base it on the sentinel's
viewport rect instead: load while the sentinel is within 300px of the
viewport bottom, re-checked synchronously after each load. This works
regardless of which element scrolls and loads only enough pages to reach
past the viewport.

Drop the per-page recursion (and now-unused scrollContainer refs / tick
imports) from the files, trash, tags, categories and pools lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 00:14:04 +03:00
H1K0 ffb8848a96 chore(frontend): bump mock files to 500 to exercise lazy load
75 mock files fit in a single 100-item page, so infinite scroll never
fired. 500 yields 5 cursor pages for testing lazy loading and the
overlay viewer paging past the loaded set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 00:05:43 +03:00
H1K0 fa491487b7 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>
2026-06-11 00:02:25 +03:00
H1K0 4f8d6a41f9 feat(frontend): lazy infinite scroll on tags/categories/pools lists
These three lists used a manual "Load more" button while files and trash
already lazy-loaded on scroll. Wire them to the shared InfiniteScroll
component for consistent behaviour: the offset-based load() now also runs
a viewport-fill pass (keep paging until the content overflows so the
sentinel sits below the fold), and the button + its now-unused spinner
CSS are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:46:29 +03:00
11 changed files with 943 additions and 1019 deletions
+5 -1
View File
@@ -5,7 +5,11 @@ declare global {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // 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 {} // interface Platform {}
} }
} }
@@ -3,27 +3,53 @@
loading?: boolean; loading?: boolean;
hasMore?: boolean; hasMore?: boolean;
onLoadMore: () => void; onLoadMore: () => void;
/** Which edge to watch: 'bottom' loads on scroll down, 'top' on scroll up. */
edge?: 'top' | 'bottom';
} }
let { loading = false, hasMore = true, onLoadMore }: Props = $props(); let { loading = false, hasMore = true, onLoadMore, edge = 'bottom' }: Props = $props();
// Lookahead distance past the viewport edge at which we start loading.
const MARGIN = 300;
let sentinel = $state<HTMLDivElement | undefined>(); let sentinel = $state<HTMLDivElement | undefined>();
// True while the sentinel is within MARGIN px of the watched viewport edge.
// Measuring the sentinel's viewport rect (rather than a scroll container's
// scrollHeight/clientHeight) makes this correct whether the page scrolls on
// <main> or on the window, and loads only enough to reach past the viewport.
function nearViewport(): boolean {
if (!sentinel) return false;
const rect = sentinel.getBoundingClientRect();
return edge === 'bottom'
? rect.top <= window.innerHeight + MARGIN
: rect.bottom >= -MARGIN;
}
function maybeLoad() {
if (loading || !hasMore || !sentinel) return;
if (nearViewport()) onLoadMore();
}
// Load on scroll: the observer notifies us when the sentinel nears the viewport.
$effect(() => { $effect(() => {
if (!sentinel) return; if (!sentinel) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && !loading && hasMore) { if (entries[0].isIntersecting) maybeLoad();
onLoadMore();
}
}, },
{ rootMargin: '300px' }, { rootMargin: `${MARGIN}px` },
); );
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); return () => observer.disconnect();
}); });
// After each load settles (loading → false), re-check synchronously: if the
// freshly added content still didn't push the sentinel past the viewport, load
// again. This fills short pages without the throttled observer lagging.
$effect(() => {
if (!loading) maybeLoad();
});
</script> </script>
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div> <div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
@@ -59,4 +85,4 @@
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
</style> </style>
@@ -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;
}
}
+2 -43
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import { categorySorting, type CategorySortField } from '$lib/stores/sorting'; import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import type { Category, CategoryOffsetPage } from '$lib/api/types'; import type { Category, CategoryOffsetPage } from '$lib/api/types';
const LIMIT = 100; const LIMIT = 100;
@@ -142,15 +143,7 @@
{/each} {/each}
</div> </div>
{#if loading} <InfiniteScroll {loading} {hasMore} onLoadMore={load} />
<div class="loading-row">
<span class="spinner" role="status" aria-label="Loading"></span>
</div>
{/if}
{#if hasMore && !loading}
<button class="load-more" onclick={load}>Load more</button>
{/if}
{#if !loading && categories.length === 0} {#if !loading && categories.length === 0}
<div class="empty"> <div class="empty">
@@ -330,40 +323,6 @@
filter: brightness(1.15); filter: brightness(1.15);
} }
.loading-row {
display: flex;
justify-content: center;
padding: 20px;
}
.spinner {
display: block;
width: 28px;
height: 28px;
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.load-more {
display: block;
margin: 16px auto 0;
padding: 8px 24px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
background: none;
color: var(--color-accent);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.load-more:hover {
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.error { .error {
color: var(--color-danger); color: var(--color-danger);
+217 -78
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; 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 { api } from '$lib/api/client';
import { ApiError } from '$lib/api/client'; import { ApiError } from '$lib/api/client';
import FileCard from '$lib/components/file/FileCard.svelte'; 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 FileUpload from '$lib/components/file/FileUpload.svelte';
import FilterBar from '$lib/components/file/FilterBar.svelte'; import FilterBar from '$lib/components/file/FilterBar.svelte';
import Header from '$lib/components/layout/Header.svelte'; import Header from '$lib/components/layout/Header.svelte';
@@ -13,15 +14,10 @@
import { selectionStore, selectionActive } from '$lib/stores/selection'; import { selectionStore, selectionActive } from '$lib/stores/selection';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte'; import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
import { tick } from 'svelte'; import { tick, flushSync } from 'svelte';
import { parseDslFilter } from '$lib/utils/dsl'; import { parseDslFilter } from '$lib/utils/dsl';
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types'; import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
import { appSettings } from '$lib/stores/appSettings'; import { appSettings } from '$lib/stores/appSettings';
import {
saveFilesSnapshot,
peekFilesSnapshot,
queryKey,
} from '$lib/stores/filesCache';
let scrollContainer = $state<HTMLElement | undefined>(); let scrollContainer = $state<HTMLElement | undefined>();
@@ -85,8 +81,15 @@
let files = $state<File[]>([]); let files = $state<File[]>([]);
let nextCursor = $state<string | null>(null); let nextCursor = $state<string | null>(null);
let loading = $state(false); // Start busy when arriving with an ?anchor so the InfiniteScroll sentinels
// can't fire a stray page-1 loadMore before loadAroundAnchor takes over (their
// effects run before this component's reset effect on mount).
let loading = $state(Boolean(page.url.searchParams.get('anchor')));
let hasMore = $state(true); let hasMore = $state(true);
// Backward pagination — only active after an anchored return, where the grid
// starts in the middle of the list and can grow upward as well as downward.
let prevCursor = $state<string | null>(null);
let hasPrev = $state(false);
let error = $state(''); let error = $state('');
let filterOpen = $state(false); let filterOpen = $state(false);
@@ -98,49 +101,38 @@
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`); let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
let prevKey = $state(''); let prevKey = $state('');
// Restore the grid DATA on entry. Scroll restoration is handled separately in // Reset + reload when the query (sort/order/filter) changes or on first mount.
// afterNavigate (below), which runs after SvelteKit's own scroll reset. // 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(() => { $effect(() => {
const key = resetKey; const key = resetKey;
if (key === prevKey) return; if (key === prevKey) return;
const firstRun = prevKey === ''; const firstRun = prevKey === '';
prevKey = key; prevKey = key;
// On entry, restore the grid the user left when opening a file (same files = [];
// sort/order/filter) so back-navigation keeps their place. A later change nextCursor = null;
// means the query itself changed → reset and reload from the top. hasMore = true;
const snap = peekFilesSnapshot(); // A plain list starts at the top, so there is nothing before it.
if (firstRun && snap && queryKey(snap.query) === key) { prevCursor = null;
files = snap.files; hasPrev = false;
nextCursor = snap.nextCursor; error = '';
hasMore = snap.hasMore; // Deep-link return carrying a position anchor but no loaded grid: load a
} else { // window centred on the anchor instead of page 1, so we can scroll to it
files = []; // and grow the grid in both directions.
nextCursor = null; if (firstRun && anchorParam) {
hasMore = true; void loadAroundAnchor(anchorParam);
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);
}
} }
}); });
// Scroll restoration runs here because afterNavigate fires AFTER SvelteKit has // Scroll to an ?anchor= file on a deep-link return. Runs in afterNavigate
// applied its own scroll handling, so our position wins instead of being reset // because it fires AFTER SvelteKit's own scroll handling, so our position wins
// to the top. The anchor (last-viewed file) is read from the URL. // instead of being reset to the top.
afterNavigate((nav) => { afterNavigate(() => {
const anchor = page.url.searchParams.get('anchor'); const anchor = page.url.searchParams.get('anchor');
if (anchor) { if (anchor) {
scrollToFile(anchor); scrollToFile(anchor);
consumeAnchor(); 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);
} }
}); });
@@ -149,7 +141,7 @@
// frames because the cards may not be laid out yet right after a restore. // frames because the cards may not be laid out yet right after a restore.
function scrollToFile(anchorId: string | null) { function scrollToFile(anchorId: string | null) {
if (!anchorId) return; if (!anchorId) return;
const attempt = (tries: number) => { const tryScroll = () => {
const idx = files.findIndex((f) => f.id === anchorId); const idx = files.findIndex((f) => f.id === anchorId);
const card = const card =
idx >= 0 && scrollContainer idx >= 0 && scrollContainer
@@ -157,11 +149,19 @@
: null; : null;
if (card) { if (card) {
card.scrollIntoView({ block: 'center' }); card.scrollIntoView({ block: 'center' });
return; return true;
} }
if (tries > 0) requestAnimationFrame(() => attempt(tries - 1)); return false;
}; };
requestAnimationFrame(() => attempt(10)); // Centre immediately if the card is already laid out (it is, right after the
// anchored load's tick) so it's pinned before any scroll sentinel fires.
if (tryScroll()) return;
let tries = 10;
const loop = () => {
if (tryScroll() || tries-- <= 0) return;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
} }
// Drop the ?anchor= param once consumed so it doesn't linger in the URL or // Drop the ?anchor= param once consumed so it doesn't linger in the URL or
@@ -173,23 +173,60 @@
replaceState(`${url.pathname}${url.search}`, page.state); replaceState(`${url.pathname}${url.search}`, page.state);
} }
// Fallback for a deep link / hard reload that has an anchor but no cached grid: // How many pages to pre-load on each side of the anchor so the viewport is
// fetch a page anchored at that file so we can scroll to it. // covered and the scroll sentinels start out of range (no mount-time storm).
const ANCHOR_PREFILL_PAGES = 3;
function baseListParams(): URLSearchParams {
const p = new URLSearchParams({
limit: String(LIMIT),
sort: sortState.sort,
order: sortState.order,
});
if (filterParam) p.set('filter', filterParam);
return p;
}
// Deep link / hard reload with an anchor but no loaded grid: fetch a window
// centred on that file and pre-fill a few pages each way, all sequentially, so
// the grid is filled around the anchor before we centre on it. The prev/next
// cursors then let it keep growing in both directions as the user scrolls.
async function loadAroundAnchor(anchor: string) { async function loadAroundAnchor(anchor: string) {
loading = true; loading = true;
error = ''; error = '';
try { try {
const params = new URLSearchParams({ const a = baseListParams();
anchor, a.set('anchor', anchor);
limit: String(LIMIT), const res = await api.get<FileCursorPage>(`/files?${a}`);
sort: sortState.sort,
order: sortState.order,
});
if (filterParam) params.set('filter', filterParam);
const res = await api.get<FileCursorPage>(`/files?${params}`);
files = res.items ?? []; files = res.items ?? [];
nextCursor = res.next_cursor ?? null; nextCursor = res.next_cursor ?? null;
hasMore = !!res.next_cursor; hasMore = !!res.next_cursor;
prevCursor = res.prev_cursor ?? null;
hasPrev = !!res.prev_cursor;
for (let i = 0; i < ANCHOR_PREFILL_PAGES && hasMore && nextCursor; i++) {
const p = baseListParams();
p.set('cursor', nextCursor);
const r = await api.get<FileCursorPage>(`/files?${p}`);
files = [...files, ...(r.items ?? [])];
nextCursor = r.next_cursor ?? null;
hasMore = !!r.next_cursor;
}
for (let i = 0; i < ANCHOR_PREFILL_PAGES && hasPrev && prevCursor; i++) {
const p = baseListParams();
p.set('cursor', prevCursor);
p.set('direction', 'backward');
const r = await api.get<FileCursorPage>(`/files?${p}`);
const items = r.items ?? [];
if (items.length === 0) {
hasPrev = false;
break;
}
files = [...items, ...files];
prevCursor = r.prev_cursor ?? null;
hasPrev = !!r.prev_cursor;
}
await tick(); await tick();
scrollToFile(anchor); scrollToFile(anchor);
consumeAnchor(); consumeAnchor();
@@ -200,18 +237,65 @@
} }
} }
// Load the previous page (scrolling up) and prepend it. Content inserted above
// the viewport would push everything down, so we shift the scroll down by
// exactly the added height — applied synchronously (flushSync, no paint in
// between) so there's no visible jump. Shares the `loading` guard with loadMore
// so the two never mutate files concurrently.
async function loadPrev() {
if (loading || !hasPrev || !prevCursor) return;
loading = true;
try {
const params = baseListParams();
params.set('cursor', prevCursor);
params.set('direction', 'backward');
const res = await api.get<FileCursorPage>(`/files?${params}`);
const items = res.items ?? [];
if (items.length === 0) {
hasPrev = false;
return;
}
// Capture scroll state just before mutating (after the request, so the
// user's scrolling during it doesn't skew the offset).
const scroller = getScroller();
const beforeTop = scroller.scrollTop;
const beforeHeight = scroller.scrollHeight;
files = [...items, ...files];
prevCursor = res.prev_cursor ?? null;
hasPrev = !!res.prev_cursor;
flushSync(); // apply the prepend now, before the browser paints
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
} catch {
hasPrev = false;
} finally {
loading = false;
}
}
// The element that actually scrolls the grid: the nearest scrollable ancestor,
// or the document scroller (the grid's <main> doesn't scroll on its own here).
function getScroller(): HTMLElement {
let el: HTMLElement | null = scrollContainer ?? null;
while (el) {
const oy = getComputedStyle(el).overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) {
return el;
}
el = el.parentElement;
}
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
}
async function loadMore() { async function loadMore() {
if (loading || !hasMore) return; if (loading || !hasMore) return;
loading = true; loading = true;
error = ''; error = '';
try { try {
const params = new URLSearchParams({ const params = baseListParams();
limit: String(LIMIT),
sort: sortState.sort,
order: sortState.order,
});
if (nextCursor) params.set('cursor', nextCursor); if (nextCursor) params.set('cursor', nextCursor);
if (filterParam) params.set('filter', filterParam);
const res = await api.get<FileCursorPage>(`/files?${params}`); const res = await api.get<FileCursorPage>(`/files?${params}`);
files = [...files, ...(res.items ?? [])]; files = [...files, ...(res.items ?? [])];
nextCursor = res.next_cursor ?? null; nextCursor = res.next_cursor ?? null;
@@ -222,12 +306,9 @@
} finally { } finally {
loading = false; loading = false;
} }
// If the loaded content doesn't fill the viewport yet (no scrollbar), // Viewport filling is handled by InfiniteScroll, which re-checks after each
// keep loading until it does or there's nothing left. // load — no manual recursion (which over-fetched here because <main> isn't
await tick(); // the scroller, so its scrollHeight never exceeds its clientHeight).
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
void loadMore();
}
} }
function applyFilter(filter: string | null) { function applyFilter(filter: string | null) {
@@ -243,20 +324,50 @@
function openFile(file: File) { function openFile(file: File) {
if (!file.id) return; if (!file.id) return;
// Snapshot the grid so returning from the viewer restores this exact list // Open the viewer as an overlay on top of the still-mounted grid via
// and scroll position instead of reloading page 1 from the top. // shallow routing: the URL becomes /files/<id> and the browser back button
saveFilesSnapshot({ // closes it, but the list is never torn down or reloaded.
query: { sort: sortState.sort, order: sortState.order, filter: filterParam }, pushState(`/files/${file.id}`, { fileId: file.id });
// Only the filter — never the transient ?anchor — defines the list URL }
// to return to.
listSearch: filterParam ? `?filter=${encodeURIComponent(filterParam)}` : '', // ---- Viewer overlay (shallow routing) ----
files, let activeFileId = $derived(page.state.fileId);
nextCursor, let activeIdx = $derived(activeFileId ? files.findIndex((f) => f.id === activeFileId) : -1);
hasMore, let viewerPrevId = $derived(activeIdx > 0 ? (files[activeIdx - 1]?.id ?? null) : null);
scrollTop: scrollContainer?.scrollTop ?? 0, let viewerNextId = $derived(
lastOpenedId: file.id, activeIdx >= 0 && activeIdx < files.length - 1 ? (files[activeIdx + 1]?.id ?? null) : null,
}); );
goto(`/files/${file.id}`);
// 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 ---- // ---- Selection logic ----
@@ -371,6 +482,10 @@
<p class="error" role="alert">{error}</p> <p class="error" role="alert">{error}</p>
{/if} {/if}
{#if hasPrev}
<InfiniteScroll {loading} hasMore={hasPrev} onLoadMore={loadPrev} edge="top" />
{/if}
<div class="grid"> <div class="grid">
{#each files as file, i (file.id)} {#each files as file, i (file.id)}
<FileCard <FileCard
@@ -393,6 +508,20 @@
</FileUpload> </FileUpload>
</div> </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} {#if $selectionActive}
<SelectionBar <SelectionBar
onEditTags={() => (tagEditorOpen = true)} onEditTags={() => (tagEditorOpen = true)}
@@ -489,6 +618,16 @@
flex-direction: column; 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 { main {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
+27 -661
View File
@@ -2,122 +2,28 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { untrack } from 'svelte'; import { api } from '$lib/api/client';
import { api, ApiError } from '$lib/api/client';
import { authStore } from '$lib/stores/auth';
import { fileSorting } from '$lib/stores/sorting'; import { fileSorting } from '$lib/stores/sorting';
import { appSettings } from '$lib/stores/appSettings'; import FileViewer from '$lib/components/file/FileViewer.svelte';
import { peekFilesSnapshot, setLastOpened, loadMoreIntoSnapshot } from '$lib/stores/filesCache'; import type { FileCursorPage } from '$lib/api/types';
import TagPicker from '$lib/components/file/TagPicker.svelte';
import type { File, Tag, 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 fileId = $derived(page.params.id);
let file = $state<File | null>(null); let prevId = $state<string | null>(null);
let fileTags = $state<Tag[]>([]); let nextId = $state<string | null>(null);
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('');
// 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(() => { $effect(() => {
if (!fileId) return; const id = fileId;
const id = fileId; // snapshot — don't re-run if other state changes if (id) void resolveNeighbors(id);
// Revoke old blob URL without tracking previewSrc as a dependency
untrack(() => {
if (previewSrc) URL.revokeObjectURL(previewSrc);
previewSrc = null;
});
void loadPage(id);
}); });
async function loadPage(id: string) { // No cached grid here, so derive neighbours from an anchored window. The
loading = true; // backend anchor window is forward-inclusive, so prev is only available once
error = ''; // we're past the first item of that window.
// Drop the previous file's tags; they reload lazily when scrolled to. async function resolveNeighbors(id: string) {
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) {
const sort = get(fileSorting); const sort = get(fileSorting);
const params = new URLSearchParams({ const params = new URLSearchParams({
anchor: id, anchor: id,
@@ -127,572 +33,32 @@
}); });
try { try {
const result = await api.get<FileCursorPage>(`/files?${params}`); const result = await api.get<FileCursorPage>(`/files?${params}`);
if (fileId !== id) return;
const items = result.items ?? []; const items = result.items ?? [];
const idx = items.findIndex((f) => f.id === id); const idx = items.findIndex((f) => f.id === id);
prevFile = idx > 0 ? items[idx - 1] : null; prevId = idx > 0 ? (items[idx - 1].id ?? null) : null;
nextFile = idx >= 0 && idx < items.length - 1 ? items[idx + 1] : null; nextId = idx >= 0 && idx < items.length - 1 ? (items[idx + 1].id ?? null) : null;
} catch { } catch {
// non-critical // non-critical
} }
} }
// ---- Save ---- function pageTo(id: string) {
async function save() { goto(`/files/${id}`);
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;
}
} }
// ---- Tags (lazy) ---- function closeViewer() {
// Fetch the current file's tags the first time the Tags section is visible. // No list mounted underneath — go to the grid, carrying the file as an
// Re-runs when fileId changes while the section is still on-screen (e.g. // anchor so it scrolls into view there.
// keyboard paging while scrolled down).
$effect(() => {
const id = fileId; const id = fileId;
if (id && tagsVisible && tagsLoadedFor !== id && !tagsLoading) { goto('/files' + (id ? `?anchor=${id}` : ''), { noScroll: true });
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);
} }
</script> </script>
<svelte:head> <svelte:head>
<title> <title>{fileId} | Tanabata</title>
{file?.original_name ?? fileId} | Tanabata
</title>
</svelte:head> </svelte:head>
<svelte:window onkeydown={handleKeydown} /> {#if fileId}
<FileViewer {fileId} {prevId} {nextId} onNavigate={pageTo} onClose={closeViewer} />
<div class="viewer-page"> {/if}
<!-- 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>
+1 -8
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import { tick } from 'svelte';
import FileCard from '$lib/components/file/FileCard.svelte'; import FileCard from '$lib/components/file/FileCard.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@@ -9,8 +8,6 @@
import { appSettings } from '$lib/stores/appSettings'; import { appSettings } from '$lib/stores/appSettings';
import type { File, FileCursorPage } from '$lib/api/types'; import type { File, FileCursorPage } from '$lib/api/types';
let scrollContainer = $state<HTMLElement | undefined>();
let LIMIT = $derived($appSettings.fileLoadLimit); let LIMIT = $derived($appSettings.fileLoadLimit);
let files = $state<File[]>([]); let files = $state<File[]>([]);
@@ -47,10 +44,6 @@
loading = false; loading = false;
initialLoaded = true; initialLoaded = true;
} }
await tick();
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
void loadMore();
}
} }
// ---- Selection ---- // ---- Selection ----
@@ -165,7 +158,7 @@
</button> </button>
</header> </header>
<main bind:this={scrollContainer}> <main>
{#if error} {#if error}
<p class="error" role="alert">{error}</p> <p class="error" role="alert">{error}</p>
{/if} {/if}
+2 -43
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import { poolSorting, type PoolSortField } from '$lib/stores/sorting'; import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import type { Pool, PoolOffsetPage } from '$lib/api/types'; import type { Pool, PoolOffsetPage } from '$lib/api/types';
const LIMIT = 50; const LIMIT = 50;
@@ -159,15 +160,7 @@
{/each} {/each}
</div> </div>
{#if loading} <InfiniteScroll {loading} {hasMore} onLoadMore={load} />
<div class="loading-row">
<span class="spinner" role="status" aria-label="Loading"></span>
</div>
{/if}
{#if hasMore && !loading}
<button class="load-more" onclick={load}>Load more</button>
{/if}
{#if !loading && pools.length === 0} {#if !loading && pools.length === 0}
<div class="empty"> <div class="empty">
@@ -393,40 +386,6 @@
opacity: 0.5; opacity: 0.5;
} }
.loading-row {
display: flex;
justify-content: center;
padding: 20px;
}
.spinner {
display: block;
width: 28px;
height: 28px;
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.load-more {
display: block;
margin: 16px auto 0;
padding: 8px 24px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
background: none;
color: var(--color-accent);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.load-more:hover {
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.error { .error {
color: var(--color-danger); color: var(--color-danger);
+2 -44
View File
@@ -3,6 +3,7 @@
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import { tagSorting, type TagSortField } from '$lib/stores/sorting'; import { tagSorting, type TagSortField } from '$lib/stores/sorting';
import TagBadge from '$lib/components/tag/TagBadge.svelte'; import TagBadge from '$lib/components/tag/TagBadge.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import type { Tag, TagOffsetPage } from '$lib/api/types'; import type { Tag, TagOffsetPage } from '$lib/api/types';
const LIMIT = 100; const LIMIT = 100;
@@ -147,15 +148,7 @@
{/each} {/each}
</div> </div>
{#if loading} <InfiniteScroll {loading} {hasMore} onLoadMore={load} />
<div class="loading-row">
<span class="spinner" role="status" aria-label="Loading"></span>
</div>
{/if}
{#if hasMore && !loading}
<button class="load-more" onclick={load}>Load more</button>
{/if}
{#if !loading && tags.length === 0} {#if !loading && tags.length === 0}
<div class="empty"> <div class="empty">
@@ -315,41 +308,6 @@
align-content: flex-start; align-content: flex-start;
} }
.loading-row {
display: flex;
justify-content: center;
padding: 20px;
}
.spinner {
display: block;
width: 28px;
height: 28px;
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.load-more {
display: block;
margin: 16px auto 0;
padding: 8px 24px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
background: none;
color: var(--color-accent);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.load-more:hover {
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.error { .error {
color: var(--color-danger); color: var(--color-danger);
font-size: 0.875rem; font-size: 0.875rem;
+15 -2
View File
@@ -157,7 +157,7 @@ const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => {
}; };
}); });
const MOCK_FILES: MockFile[] = Array.from({ length: 75 }, (_, i) => { const MOCK_FILES: MockFile[] = Array.from({ length: 500 }, (_, i) => {
const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4']; const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
const exts = ['jpg', 'png', 'webp', 'mp4' ]; const exts = ['jpg', 'png', 'webp', 'mp4' ];
const mi = i % mimes.length; const mi = i % mimes.length;
@@ -606,12 +606,25 @@ export function mockApiPlugin(): Plugin {
return json(res, 200, { items: slice, next_cursor, prev_cursor }); return json(res, 200, { items: slice, next_cursor, prev_cursor });
} }
const direction = qs.get('direction') ?? 'forward';
if (direction === 'backward' && cursor) {
// Cursor marks the current top boundary; return the page before it.
const end = Number(Buffer.from(cursor, 'base64').toString());
const start = Math.max(0, end - limit);
const slice = MOCK_FILES.slice(start, end);
const prev_cursor = start > 0
? Buffer.from(String(start)).toString('base64') : null;
const next_cursor = Buffer.from(String(end)).toString('base64');
return json(res, 200, { items: slice, next_cursor, prev_cursor });
}
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0; const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
const slice = MOCK_FILES.slice(offset, offset + limit); const slice = MOCK_FILES.slice(offset, offset + limit);
const nextOffset = offset + slice.length; const nextOffset = offset + slice.length;
const next_cursor = nextOffset < MOCK_FILES.length const next_cursor = nextOffset < MOCK_FILES.length
? Buffer.from(String(nextOffset)).toString('base64') : null; ? Buffer.from(String(nextOffset)).toString('base64') : null;
return json(res, 200, { items: slice, next_cursor, prev_cursor: null }); const prev_cursor = offset > 0
? Buffer.from(String(offset)).toString('base64') : null;
return json(res, 200, { items: slice, next_cursor, prev_cursor });
} }
// GET /tags/{id}/rules // GET /tags/{id}/rules