9 Commits

Author SHA1 Message Date
H1K0 19ec96c544 feat(frontend): focus the page search box with / on the list sections
deploy / deploy (push) Successful in 18s
Extends the global `/` shortcut to focus the always-on search input on
Tags/Categories/Pools, matching what the help overlay advertises. Files
keeps its own `/` handler since it has no persistent input and instead
opens the filter bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:43:24 +03:00
H1K0 49e68cc263 feat(frontend): keyboard nav for the bulk tag editor
The grid's `e` opens the bulk tag editor, which has its own UI rather than
the shared TagPicker, so it needed the same keyboard handling: from the
search input, ↓/↑ highlight a suggestion and Enter adds it to all
selected files (focus stays for chaining); with the input empty ←/→ walk
the assigned tags and Del removes the focused one. Mirrors the viewer's
tag picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:42:02 +03:00
H1K0 3a0dbc9ba7 feat(frontend): keyboard nav for the viewer, tag editor and filter
Viewer: j/k mirror the arrow keys, and `e` scrolls to the (lazy) Tags
section and drops the cursor into its filter, forcing the load so focus
lands even before the section is reached.

Tag picker & filter bar: from the search input, ↓/↑ highlight a
suggestion and Enter adds it (focus stays for chaining); with the input
empty ←/→ walk the added tags/tokens and Del removes the focused one. The
filter bar also inserts an operator token on & | ! ( ), applies on
Ctrl+Enter, resets on Ctrl+Backspace and closes on Esc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:40:03 +03:00
H1K0 9a20cc1c84 feat(frontend): keyboard roving-focus and bulk-action keys on the file grid
Arrow keys move a focus ring across the grid (column count derived from
the layout, scrolling the focused card into view and pulling the next
page near the end). Enter opens the focused file; Space/x select; e edits
tags (opening the sheet and focusing its tag filter); p adds to a pool;
Del moves to trash — each falling back to the focused card when nothing
is selected. / opens the filter and focuses its search. The ring only
appears once keyboard navigation starts and is dismissed on pointer use,
so it never distracts mouse users. Escape layering is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:30:13 +03:00
H1K0 49de9fe42b feat(frontend): global keyboard navigation and shortcuts help overlay
Adds a single window-level key dispatcher in the root layout: `g` then
c/t/f/p/s (or the digits 1–5) jump between the five sections, honouring
each section's remembered URL so you land back on the same filter and
scroll. `?` toggles a shortcuts cheat-sheet overlay. The handler stays
out of the way while typing in inputs or when a browser/OS modifier is
held. This is the foundation for the per-context keymaps (grid, viewer,
tag/filter pickers) that follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:24:39 +03:00
H1K0 2b39af8c1c feat(frontend): cache Tags/Categories/Pools lists across section switches
The offset-paginated lists lost their loaded items, search text and
scroll position when you left for another section, since search is local
(not in the URL) and the page unmounts on navigation. Each now snapshots
that state on departure and rehydrates it on return when the
sort/order/search still match, restoring scroll after the list paints.
Because these lists are edited on their own detail/new pages, the API
client drops the matching section's snapshot on any successful mutation
so a stale list never restores. Shared scroll-restore helper and an
OffsetListSnapshot type keep the three pages in lockstep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:20:48 +03:00
H1K0 05af819b3e feat(frontend): cap the Files grid at ~4 viewports with edge windowing
The grid grew without bound as you scrolled (and the section cache then
snapshotted the whole thing). It now keeps at most ~4 viewports of rows:
once it grows past the cap on one end, loadMore/loadPrev trim the
off-screen rows on the other end. The trimmed boundary cursor is dropped
and the opposite has-more flag is raised, so scrolling back refills that
side from an anchored window (?anchor=<file>), reusing the existing
prepend scroll-compensation. This bounds both live memory and the cached
snapshot regardless of how deep you scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:03:43 +03:00
H1K0 e93240ff79 feat(frontend): restore the Files grid and scroll on return via section cache
Leaving the Files list for another section unmounted the page and lost
the loaded grid, cursors and scroll position; returning refetched page 1
from the top. A new in-memory section cache snapshots that state on
departure (beforeNavigate) and rehydrates it on the next mount when the
sort/filter still match, reapplying the scroll offset after the grid
paints. Combined with the navbar remembering the section URL, tapping
back into Files lands you exactly where you left off. The snapshot is
session-only, validated by resetKey, and skipped for in-page query
changes and the shallow-routed viewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:56:33 +03:00
H1K0 370dfd95bc feat(frontend): remember each section's last list URL in the navbar
The bottom-nav links pointed at the bare section roots, so leaving a list
and tapping its tab again dropped the active filter/sort in the query
string. The root layout (which never unmounts) now records the last list
root URL — including its query — per section on navigation, and the nav
links target that remembered URL. Only the list root is recorded, so
detail pages, the trash sub-view, and the shallow-routed file viewer
don't hijack the tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:53:00 +03:00
14 changed files with 1080 additions and 43 deletions
+19
View File
@@ -2,9 +2,26 @@ import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth';
import { clearSection, type SectionKey } from '$lib/stores/sectionCache';
const BASE = '/api/v1';
// The tags/categories/pools lists are edited on their own detail/new pages, so a
// cached list snapshot goes stale after a write there. Drop the matching
// section's snapshot on any successful mutation so the list refetches on return.
// (Files isn't included — its grid keeps itself consistent via optimistic
// updates, and over-invalidating would needlessly lose the scroll position.)
function invalidateSectionCache(path: string, method: string): void {
if (method === 'GET') return;
const sections: SectionKey[] = ['tags', 'categories', 'pools'];
for (const s of sections) {
if (path === `/${s}` || path.startsWith(`/${s}/`) || path.startsWith(`/${s}?`)) {
clearSection(s);
return;
}
}
}
/** Clear the session and bounce to the login screen. Called when the refresh
* token is missing or rejected, so an expired session doesn't strand the user
* on a page that only shows errors. */
@@ -104,6 +121,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
);
}
invalidateSectionCache(path, (init?.method ?? 'GET').toUpperCase());
if (res.status === 204) return undefined as T;
return res.json();
}
@@ -116,6 +116,51 @@
busy = false;
}
}
// ---- Keyboard navigation (from the search input) ----
// ↓/↑ highlight a suggestion, Enter adds it (focus stays); with the input empty
// ←/→ walk the assigned tags and Del removes the focused one from all files.
let highlightIdx = $state(0);
let assignedFocusIdx = $state(-1);
$effect(() => {
if (highlightIdx > availableTags.length - 1) {
highlightIdx = Math.max(0, availableTags.length - 1);
}
});
function onSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
assignedFocusIdx = -1;
if (availableTags.length) highlightIdx = Math.min(highlightIdx + 1, availableTags.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
assignedFocusIdx = -1;
highlightIdx = Math.max(highlightIdx - 1, 0);
} else if (e.key === 'Enter') {
const tag = availableTags[highlightIdx];
if (tag?.id) {
e.preventDefault();
void add(tag.id);
}
} else if (e.key === 'ArrowRight' && search === '') {
e.preventDefault();
const n = assignedTags.length;
if (n) assignedFocusIdx = assignedFocusIdx < 0 ? 0 : Math.min(assignedFocusIdx + 1, n - 1);
} else if (e.key === 'ArrowLeft' && search === '') {
e.preventDefault();
const n = assignedTags.length;
if (n) assignedFocusIdx = assignedFocusIdx < 0 ? n - 1 : Math.max(assignedFocusIdx - 1, 0);
} else if (e.key === 'Delete' && assignedFocusIdx >= 0) {
const tag = assignedTags[assignedFocusIdx];
if (tag?.id) {
e.preventDefault();
void remove(tag.id);
assignedFocusIdx = Math.min(assignedFocusIdx, assignedTags.length - 2);
}
}
}
</script>
<div class="editor" class:busy>
@@ -131,12 +176,13 @@
<span class="hint">— partial tags shown with dashed border, click to apply to all</span>
</div>
<div class="tag-row">
{#each assignedTags as tag (tag.id)}
{#each assignedTags as tag, i (tag.id)}
{@const isPartial = partialIds.has(tag.id ?? '')}
<div class="tag-wrap">
<button
class="tag assigned"
class:partial={isPartial}
class:kbfocus={assignedFocusIdx === i}
style={tagStyle(tag)}
onclick={() => (isPartial ? promotePartial(tag.id!) : remove(tag.id!))}
title={isPartial
@@ -162,6 +208,7 @@
type="search"
placeholder="Search tags…"
bind:value={search}
onkeydown={onSearchKeydown}
autocomplete="off"
/>
{#if search}
@@ -182,9 +229,10 @@
{#if availableTags.length > 0}
<div class="section-label">Add tag</div>
<div class="tag-row available-row">
{#each availableTags as tag (tag.id)}
{#each availableTags as tag, i (tag.id)}
<button
class="tag available"
class:hl={highlightIdx === i}
style={tagStyle(tag)}
onclick={() => add(tag.id!)}
title="Add to all selected files"
@@ -310,6 +358,17 @@
filter: brightness(1.1);
}
.tag.available.hl {
opacity: 1;
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.tag.assigned.kbfocus {
outline: 2px solid var(--color-danger);
outline-offset: 1px;
}
.search-wrap {
position: relative;
display: flex;
@@ -11,6 +11,8 @@
index: number;
selected?: boolean;
selectionMode?: boolean;
/** Roving keyboard-focus ring (shown only during keyboard navigation). */
focused?: boolean;
onTap?: (e: MouseEvent) => void;
/** Called when long-press fires; receives the pointerType of the gesture. */
onLongPress?: (pointerType: string) => void;
@@ -21,6 +23,7 @@
index,
selected = false,
selectionMode = false,
focused = false,
onTap,
onLongPress
}: Props = $props();
@@ -108,6 +111,7 @@
class="card"
class:loaded={!!imgSrc}
class:selected
class:focused
data-file-index={index}
onpointerdown={onPointerDown}
onpointermove={onPointerMoveInternal}
@@ -215,6 +219,12 @@
background-color: color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.card.focused {
outline: 3px solid var(--color-accent);
outline-offset: -3px;
z-index: 1;
}
.check {
position: absolute;
top: 6px;
@@ -184,17 +184,44 @@
}
// ---- Keyboard ----
let tagsSection = $state<HTMLElement>();
let pendingTagFocus = false;
function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft') {
if (e.key === 'ArrowLeft' || e.key === 'k') {
if (prevId) onNavigate(prevId);
} else if (e.key === 'ArrowRight') {
} else if (e.key === 'ArrowRight' || e.key === 'j') {
if (nextId) onNavigate(nextId);
} else if (e.key === 'e') {
e.preventDefault();
jumpToTags();
} else if (e.key === 'Escape') {
onClose();
}
}
// Scroll the (lazily loaded) Tags section into view and drop the cursor into
// its filter. Forces the load so the focus lands even before the user reaches
// the section by scrolling.
function jumpToTags() {
tagsVisible = true;
tagsSection?.scrollIntoView({ behavior: 'smooth', block: 'start' });
pendingTagFocus = true;
focusTagInput();
}
function focusTagInput() {
requestAnimationFrame(() => tagsSection?.querySelector<HTMLInputElement>('input')?.focus());
}
$effect(() => {
if (tagsLoaded && pendingTagFocus) {
pendingTagFocus = false;
focusTagInput();
}
});
// ---- Helpers ----
function formatDatetime(iso: string | null | undefined): string {
if (!iso) return '—';
@@ -344,7 +371,7 @@
</button>
<!-- Tags (loaded lazily on scroll) -->
<section class="section" use:tagsSentinel>
<section class="section" use:tagsSentinel bind:this={tagsSection}>
<div class="field-label">Tags</div>
{#if tagsLoaded}
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
@@ -53,6 +53,71 @@
onApply(null);
}
// ---- Keyboard navigation (from the search input) ----
// ↓/↑ highlight a tag, Enter adds it as a token; the operator chars insert an
// operator token; with the input empty ←/→ walk the active tokens and Del
// removes the focused one. Mod+Enter applies, Mod+Backspace resets, Esc closes.
let highlightIdx = $state(0);
let tokenFocusIdx = $state(-1);
const OP_KEYS = ['&', '|', '!', '(', ')'];
$effect(() => {
if (highlightIdx > filteredTags.length - 1) {
highlightIdx = Math.max(0, filteredTags.length - 1);
}
});
function onSearchKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
apply();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'Backspace') {
e.preventDefault();
reset();
return;
}
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (OP_KEYS.includes(e.key)) {
e.preventDefault();
addToken(e.key);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
tokenFocusIdx = -1;
if (filteredTags.length) highlightIdx = Math.min(highlightIdx + 1, filteredTags.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
tokenFocusIdx = -1;
highlightIdx = Math.max(highlightIdx - 1, 0);
} else if (e.key === 'Enter') {
const tag = filteredTags[highlightIdx];
if (tag?.id) {
e.preventDefault();
addToken(`t=${tag.id}`);
}
} else if (e.key === 'ArrowRight' && search === '') {
e.preventDefault();
const n = tokens.length;
if (n) tokenFocusIdx = tokenFocusIdx < 0 ? 0 : Math.min(tokenFocusIdx + 1, n - 1);
} else if (e.key === 'ArrowLeft' && search === '') {
e.preventDefault();
const n = tokens.length;
if (n) tokenFocusIdx = tokenFocusIdx < 0 ? n - 1 : Math.max(tokenFocusIdx - 1, 0);
} else if (e.key === 'Delete' && tokenFocusIdx >= 0) {
e.preventDefault();
removeToken(tokenFocusIdx);
tokenFocusIdx = Math.min(tokenFocusIdx, tokens.length - 2);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
// --- Drag-and-drop reordering ---
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
@@ -99,6 +164,7 @@
class="token active-token"
class:dragging={dragIndex === i}
class:drop-before={dropIndex === i && dragIndex !== null && dragIndex !== i}
class:kbfocus={tokenFocusIdx === i}
draggable="true"
role="button"
tabindex="0"
@@ -129,14 +195,16 @@
type="search"
placeholder="Search tags…"
bind:value={search}
onkeydown={onSearchKeydown}
autocomplete="off"
/>
<!-- Tag list -->
<div class="tag-list">
{#each filteredTags as tag (tag.id)}
{#each filteredTags as tag, i (tag.id)}
<button
class="token tag-token"
class:hl={highlightIdx === i}
style="background-color: {tag.color
? '#' + tag.color
: tag.category_color
@@ -232,6 +300,11 @@
outline-offset: 2px;
}
.active-token.kbfocus {
outline: 2px solid var(--color-danger);
outline-offset: 2px;
}
.op-token {
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-bg-elevated));
color: var(--color-text-primary);
@@ -278,6 +351,11 @@
filter: brightness(1.15);
}
.tag-token.hl {
outline: 2px solid var(--color-text-primary);
outline-offset: 1px;
}
.no-tags {
font-size: 0.8rem;
color: var(--color-text-muted);
@@ -64,6 +64,53 @@
const color = tag.color ?? tag.category_color;
return color ? `background-color: #${color}` : '';
}
// ---- Keyboard navigation (from the search input) ----
// ↓/↑ highlight a suggestion, Enter adds it (focus stays for chaining); with the
// input empty, ←/→ walk the assigned pills and Del removes the focused one.
let highlightIdx = $state(0);
let assignedFocusIdx = $state(-1);
$effect(() => {
if (highlightIdx > filteredAvailable.length - 1) {
highlightIdx = Math.max(0, filteredAvailable.length - 1);
}
});
function onSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
assignedFocusIdx = -1;
if (filteredAvailable.length) {
highlightIdx = Math.min(highlightIdx + 1, filteredAvailable.length - 1);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
assignedFocusIdx = -1;
highlightIdx = Math.max(highlightIdx - 1, 0);
} else if (e.key === 'Enter') {
const tag = filteredAvailable[highlightIdx];
if (tag?.id) {
e.preventDefault();
void handleAdd(tag.id);
}
} else if (e.key === 'ArrowRight' && search === '') {
e.preventDefault();
const n = filteredAssigned.length;
if (n) assignedFocusIdx = assignedFocusIdx < 0 ? 0 : Math.min(assignedFocusIdx + 1, n - 1);
} else if (e.key === 'ArrowLeft' && search === '') {
e.preventDefault();
const n = filteredAssigned.length;
if (n) assignedFocusIdx = assignedFocusIdx < 0 ? n - 1 : Math.max(assignedFocusIdx - 1, 0);
} else if (e.key === 'Delete' && assignedFocusIdx >= 0) {
const tag = filteredAssigned[assignedFocusIdx];
if (tag?.id) {
e.preventDefault();
void handleRemove(tag.id);
assignedFocusIdx = Math.min(assignedFocusIdx, filteredAssigned.length - 2);
}
}
}
</script>
<div class="picker" class:busy>
@@ -71,9 +118,10 @@
{#if fileTags.length > 0}
<div class="section-label">Assigned</div>
<div class="tag-row">
{#each filteredAssigned as tag (tag.id)}
{#each filteredAssigned as tag, i (tag.id)}
<button
class="tag assigned"
class:kbfocus={assignedFocusIdx === i}
style={tagStyle(tag)}
onclick={() => handleRemove(tag.id!)}
title="Remove tag"
@@ -92,6 +140,7 @@
type="search"
placeholder="Search tags…"
bind:value={search}
onkeydown={onSearchKeydown}
autocomplete="off"
/>
{#if search}
@@ -112,9 +161,10 @@
{#if filteredAvailable.length > 0}
<div class="section-label">Add tag</div>
<div class="tag-row available-row">
{#each filteredAvailable as tag (tag.id)}
{#each filteredAvailable as tag, i (tag.id)}
<button
class="tag available"
class:hl={highlightIdx === i}
style={tagStyle(tag)}
onclick={() => handleAdd(tag.id!)}
title="Add tag"
@@ -198,6 +248,17 @@
filter: brightness(1.1);
}
.tag.available.hl {
opacity: 1;
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.tag.assigned.kbfocus {
outline: 2px solid var(--color-danger);
outline-offset: 1px;
}
.search-wrap {
position: relative;
display: flex;
@@ -0,0 +1,192 @@
<script lang="ts">
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
// Static cheat-sheet of the app's shortcuts, grouped by context. Kept in sync
// by hand with the per-context handlers (global nav here, the rest on the
// Files page / viewer / tag pickers).
const groups: { title: string; rows: [string, string][] }[] = [
{
title: 'Anywhere',
rows: [
['g then c / t / f / p / s', 'Go to Categories / Tags / Files / Pools / Settings'],
['1 5', 'Jump to a section'],
['?', 'Toggle this help'],
['/', 'Focus the filter / search']
]
},
{
title: 'File grid',
rows: [
['↑ ↓ ← →', 'Move focus between files'],
['Enter', 'Open the focused file'],
['Space / x', 'Select / deselect'],
['e', 'Edit tags (focus the tag filter)'],
['p', 'Add to pool'],
['Del', 'Move to trash'],
['Esc', 'Clear selection']
]
},
{
title: 'Viewer',
rows: [
['← / → or j / k', 'Previous / next file'],
['e', 'Jump to tags & focus the filter'],
['Esc', 'Close']
]
},
{
title: 'Tag editor / filter',
rows: [
['↓ ↑', 'Highlight a suggestion'],
['Enter', 'Add the highlighted tag'],
['← →', 'Move across added tags / tokens (empty input)'],
['Del', 'Remove the focused tag / token'],
['& | ! ( )', 'Insert an operator (filter only)'],
['Ctrl+Enter', 'Apply the filter'],
['Ctrl+Backspace', 'Reset the filter'],
['Esc', 'Close']
]
}
];
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="backdrop" role="presentation" onclick={onClose}></div>
<div class="sheet" role="dialog" aria-label="Keyboard shortcuts" aria-modal="true">
<div class="head">
<span class="title">Keyboard shortcuts</span>
<button class="close" onclick={onClose} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 3l10 10M13 3L3 13"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div class="body">
{#each groups as group}
<section class="group">
<h3 class="group-title">{group.title}</h3>
{#each group.rows as [keys, desc]}
<div class="row">
<kbd class="keys">{keys}</kbd>
<span class="desc">{desc}</span>
</div>
{/each}
</section>
{/each}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.5);
}
.sheet {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 301;
width: min(560px, calc(100vw - 24px));
max-height: min(80dvh, 640px);
display: flex;
flex-direction: column;
background-color: var(--color-bg-secondary);
border-radius: 14px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
animation: pop 0.16s ease-out;
}
@keyframes pop {
from {
transform: translate(-50%, -48%) scale(0.98);
opacity: 0;
}
to {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
.head {
display: flex;
align-items: center;
padding: 14px 16px 10px;
}
.title {
flex: 1;
font-size: 1rem;
font-weight: 600;
}
.close {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
display: flex;
}
.close:hover {
color: var(--color-text-primary);
}
.body {
padding: 0 16px 18px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 6px 20px;
}
.group {
break-inside: avoid;
padding-top: 8px;
}
.group-title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-accent);
margin: 0 0 6px;
}
.row {
display: flex;
align-items: baseline;
gap: 10px;
padding: 3px 0;
}
.keys {
flex-shrink: 0;
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--color-text-primary);
background-color: var(--color-bg-elevated);
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
border-radius: 5px;
padding: 2px 6px;
white-space: nowrap;
}
.desc {
font-size: 0.82rem;
color: var(--color-text-muted);
}
</style>
+19
View File
@@ -0,0 +1,19 @@
// Reapply a restored scroll offset to a list's scroller, retrying across frames
// because the list may not be laid out yet right after a cache rehydrate (and
// SvelteKit resets scroll to the top on navigation, so this has to win after).
export function restoreListScroll(getEl: () => HTMLElement | undefined, top: number): void {
let tries = 12;
const apply = () => {
const el = getEl();
if (!el) {
if (tries-- > 0) requestAnimationFrame(apply);
return;
}
if (el.scrollHeight > top + el.clientHeight || tries-- <= 0) {
el.scrollTop = top;
return;
}
requestAnimationFrame(apply);
};
requestAnimationFrame(apply);
}
+47
View File
@@ -0,0 +1,47 @@
// In-memory, per-section view cache. When you leave a list (Files, Tags, …) for
// another section and come back, the page restores its loaded items, pagination
// cursors and scroll position from here instead of refetching from scratch.
//
// Kept deliberately simple: a plain module-level Map that lives for the session.
// No TTL — a snapshot is taken from the page's current state on the way out, so
// it already reflects local mutations (deletes, uploads, tag edits). It is
// dropped on a full reload, and each page validates the snapshot's `resetKey`
// (sort/filter/search) before trusting it, so a stale query never restores.
export type SectionKey = 'files' | 'tags' | 'categories' | 'pools';
/** Snapshot shape shared by the offset-paginated lists (tags/categories/pools). */
export interface OffsetListSnapshot<T> {
/** sort|order|search at capture — guards against restoring a different query. */
resetKey: string;
search: string;
items: T[];
total: number;
offset: number;
}
interface Snapshot<T> {
/** Scroll offset of the list's scroller at capture time. */
scrollTop: number;
/** Page-specific state blob; opaque to this module. */
data: T;
savedAt: number;
}
const cache = new Map<SectionKey, Snapshot<unknown>>();
export function saveSection<T>(key: SectionKey, scrollTop: number, data: T): void {
cache.set(key, { scrollTop, data, savedAt: Date.now() });
}
/** Read and remove a section's snapshot (restore consumes it). */
export function takeSection<T>(key: SectionKey): { scrollTop: number; data: T } | null {
const snap = cache.get(key) as Snapshot<T> | undefined;
if (!snap) return null;
cache.delete(key);
return { scrollTop: snap.scrollTop, data: snap.data };
}
export function clearSection(key: SectionKey): void {
cache.delete(key);
}
+109 -1
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
import { afterNavigate, goto } from '$app/navigation';
import { themeStore, toggleTheme } from '$lib/stores/theme';
import KeyboardHelp from '$lib/components/layout/KeyboardHelp.svelte';
let { children } = $props();
@@ -35,15 +37,121 @@
const isLogin = $derived($page.url.pathname === '/login');
const isAdmin = $derived($page.url.pathname.startsWith('/admin'));
// Remember the last list URL (with its query — filter/sort) per section, so
// tapping a nav item returns you to where you left off rather than the bare
// root. The root layout never unmounts, so this map persists across the whole
// session. Only the section's list root is recorded (e.g. /files, not
// /files/<id> or /files/trash) — the tab should reopen the list, not a
// sub-screen or the viewer.
let lastUrl = $state<Record<string, string>>({});
afterNavigate((nav) => {
const url = nav.to?.url;
if (!url) return;
const item = navItems.find((it) => it.match === url.pathname);
if (item) lastUrl[item.match] = url.pathname + url.search;
});
// ---- Global keyboard navigation -----------------------------------------
let helpOpen = $state(false);
// g-then-letter and 15 jump between sections; both honour the remembered
// per-section URL so you land back on the same filter/scroll.
const G_MAP: Record<string, string> = {
c: '/categories',
t: '/tags',
f: '/files',
p: '/pools',
s: '/settings'
};
const NUM_MAP = navItems.map((it) => it.match); // 1→categories … 5→settings
let pendingG = false;
let gTimer: ReturnType<typeof setTimeout> | undefined;
function go(match: string) {
goto(lastUrl[match] ?? match);
}
function isEditable(t: EventTarget | null): boolean {
return (
t instanceof HTMLElement &&
(t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName))
);
}
function onGlobalKey(e: KeyboardEvent) {
if (helpOpen && e.key === 'Escape') {
helpOpen = false;
return;
}
// Stay out of the way while typing or when a browser/OS combo is held.
if (isEditable(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
if (isLogin) return;
if (e.key === '?') {
helpOpen = !helpOpen;
e.preventDefault();
return;
}
// Focus the page's search box. Pages with a persistent one (Tags/Categories/
// Pools) are handled here; Files has no always-on input, so its own handler
// opens the filter instead.
if (e.key === '/') {
const input = document.querySelector<HTMLInputElement>('input[type="search"]');
if (input) {
e.preventDefault();
input.focus();
}
return;
}
if (pendingG) {
pendingG = false;
clearTimeout(gTimer);
const dest = G_MAP[e.key.toLowerCase()];
if (dest) {
e.preventDefault();
go(dest);
}
return;
}
if (e.key === 'g') {
pendingG = true;
clearTimeout(gTimer);
gTimer = setTimeout(() => (pendingG = false), 1000);
return;
}
if (e.key >= '1' && e.key <= '5') {
const dest = NUM_MAP[Number(e.key) - 1];
if (dest) {
e.preventDefault();
go(dest);
}
}
}
</script>
<svelte:window onkeydown={onGlobalKey} />
{@render children()}
{#if helpOpen}
<KeyboardHelp onClose={() => (helpOpen = false)} />
{/if}
{#if !isLogin && !isAdmin}
<footer>
{#each navItems as item}
{@const active = $page.url.pathname.startsWith(item.match)}
<a href={item.href} class="nav" class:curr={active} aria-label={item.label}>
<a
href={lastUrl[item.match] ?? item.href}
class="nav"
class:curr={active}
aria-label={item.label}
>
{#if item.label === 'Categories'}
<svg
width="24"
+43 -2
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
import { get } from 'svelte/store';
import { api, ApiError } from '$lib/api/client';
import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache';
import { restoreListScroll } from '$lib/stores/listScroll';
import type { Category, CategoryOffsetPage } from '$lib/api/types';
const LIMIT = 100;
@@ -26,6 +29,44 @@
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
let prevKey = $state('');
let scrollEl = $state<HTMLElement>();
let pendingScroll: number | null = null;
// Rehydrate the loaded list, search and scroll from the cache on return (same
// sort/order/search), during init so the matching prevKey/initialLoaded
// suppress the reset + initial load below.
const cached = takeSection<OffsetListSnapshot<Category>>('categories');
if (cached) {
const s0 = get(categorySorting);
const wouldKey = `${s0.sort}|${s0.order}|${cached.data.search}`;
if (wouldKey === cached.data.resetKey && cached.data.items.length > 0) {
search = cached.data.search;
categories = cached.data.items;
total = cached.data.total;
offset = cached.data.offset;
initialLoaded = true;
prevKey = wouldKey;
pendingScroll = cached.scrollTop;
}
}
beforeNavigate(() => {
if (categories.length === 0) return;
saveSection<OffsetListSnapshot<Category>>('categories', scrollEl?.scrollTop ?? 0, {
resetKey,
search,
items: categories,
total,
offset
});
});
afterNavigate(() => {
if (pendingScroll == null) return;
restoreListScroll(() => scrollEl, pendingScroll);
pendingScroll = null;
});
$effect(() => {
if (resetKey !== prevKey) {
prevKey = resetKey;
@@ -146,7 +187,7 @@
</div>
</div>
<main>
<main bind:this={scrollEl}>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
+309 -16
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { afterNavigate, goto, pushState, replaceState } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto, pushState, replaceState } from '$app/navigation';
import { saveSection, takeSection } from '$lib/stores/sectionCache';
import { api } from '$lib/api/client';
import { ApiError } from '$lib/api/client';
import FileCard from '$lib/components/file/FileCard.svelte';
@@ -19,6 +20,17 @@
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
import { appSettings } from '$lib/stores/appSettings';
// What the section cache stores for the Files grid. `resetKey` guards against
// restoring under a different sort/filter than was captured.
interface FilesSnapshot {
resetKey: string;
files: File[];
nextCursor: string | null;
hasMore: boolean;
prevCursor: string | null;
hasPrev: boolean;
}
let scrollContainer = $state<HTMLElement | undefined>();
let uploader = $state<{ open: () => void } | undefined>();
@@ -27,16 +39,139 @@
// ---- Bulk tag editor ----
let tagEditorOpen = $state(false);
// Escape dismisses one layer at a time: an open overlay (tag editor / pool
// picker / delete confirm) first, then the selection. The file viewer owns
// its own Escape, so we bail out while it's up.
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
// ---- Keyboard roving focus ----
// The id of the grid's keyboard-focused file, plus a flag that gates the focus
// ring so it only shows once the user actually starts navigating by keyboard.
let focusedId = $state<string | null>(null);
let kbActive = $state(false);
function isFormTarget(t: EventTarget | null): boolean {
return (
t instanceof HTMLElement &&
(t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(t.tagName))
);
}
function gridCols(): number {
const w = scrollContainer?.clientWidth ?? 0;
return Math.max(1, Math.floor((w || 360) / CARD_PITCH));
}
function focusedFile(): File | undefined {
return focusedId ? files.find((f) => f.id === focusedId) : undefined;
}
// Move the roving focus by `delta` positions, clamped to the loaded grid, and
// scroll the new card into view. Pulls the next page when nearing the end.
function moveFocus(delta: number) {
if (files.length === 0) return;
kbActive = true;
const cur = focusedId ? files.findIndex((f) => f.id === focusedId) : -1;
const next = Math.max(0, Math.min(files.length - 1, cur < 0 ? 0 : cur + delta));
focusedId = files[next]?.id ?? null;
if (next >= files.length - gridCols() * 2 && hasMore && !loading) void loadMore();
const id = focusedId;
requestAnimationFrame(() => {
const idx = files.findIndex((f) => f.id === id);
scrollContainer
?.querySelector<HTMLElement>(`[data-file-index="${idx}"]`)
?.scrollIntoView({ block: 'nearest' });
});
}
// Action keys operate on the selection; with nothing selected they fall back to
// the focused card (selecting it first so the bulk sheets have a target).
function ensureSelectedFocused() {
const f = focusedFile();
if (f?.id && !$selectionStore.ids.has(f.id)) selectionStore.select(f.id);
}
function openTagEditor() {
tagEditorOpen = true;
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
}
function openFilterAndFocus() {
filterOpen = true;
void tick().then(() => document.querySelector<HTMLInputElement>('.bar .search')?.focus());
}
// Single window handler for the grid: Escape peels one layer at a time (overlay
// → selection; the viewer owns its own Escape), and the rest drives roving
// focus + bulk actions while the bare list is in front.
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (tagEditorOpen) tagEditorOpen = false;
else if (poolPickerOpen) poolPickerOpen = false;
else if (confirmDeleteFiles) confirmDeleteFiles = false;
else if (activeFileId) return;
else if ($selectionActive) selectionStore.exit();
return;
}
if (activeFileId || tagEditorOpen || poolPickerOpen || confirmDeleteFiles) return;
if (isFormTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
moveFocus(1);
break;
case 'ArrowLeft':
e.preventDefault();
moveFocus(-1);
break;
case 'ArrowDown':
e.preventDefault();
moveFocus(gridCols());
break;
case 'ArrowUp':
e.preventDefault();
moveFocus(-gridCols());
break;
case 'Enter': {
const f = focusedFile();
if (f) {
e.preventDefault();
openFile(f);
}
break;
}
case ' ':
case 'x': {
const f = focusedFile();
if (f?.id) {
e.preventDefault();
selectionStore.toggle(f.id);
}
break;
}
case 'e':
if ($selectionActive || focusedFile()) {
e.preventDefault();
ensureSelectedFocused();
openTagEditor();
}
break;
case 'p':
if ($selectionActive || focusedFile()) {
e.preventDefault();
ensureSelectedFocused();
void openPoolPicker();
}
break;
case 'Delete':
if ($selectionActive || focusedFile()) {
e.preventDefault();
ensureSelectedFocused();
confirmDeleteFiles = true;
}
break;
case '/':
e.preventDefault();
openFilterAndFocus();
break;
}
}
// ---- Add to pool picker ----
@@ -113,6 +248,11 @@
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
let prevKey = $state('');
// Scroll offset to reapply once the restored grid has painted (set when a
// cached snapshot is rehydrated; consumed in afterNavigate so it wins over
// SvelteKit's own scroll-to-top).
let pendingScroll: number | null = null;
// 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.
@@ -122,6 +262,26 @@
const firstRun = prevKey === '';
prevKey = key;
// Returning to this section: rehydrate the loaded grid + cursors + scroll
// from the section cache instead of refetching, as long as the snapshot was
// taken under the same sort/filter. Skip when arriving on an anchor, which
// has its own (deep-link) restore path below.
if (firstRun && !anchorParam) {
const snap = takeSection<FilesSnapshot>('files');
if (snap && snap.data.resetKey === key && snap.data.files.length > 0) {
files = snap.data.files;
nextCursor = snap.data.nextCursor;
hasMore = snap.data.hasMore;
prevCursor = snap.data.prevCursor;
hasPrev = snap.data.hasPrev;
// Hold the load guards shut until the scroll is reapplied, so the
// InfiniteScroll sentinels can't fire a stray page load at the top.
loading = true;
pendingScroll = snap.scrollTop;
return;
}
}
files = [];
nextCursor = null;
hasMore = true;
@@ -149,9 +309,52 @@
if (anchor) {
scrollToFile(anchor);
consumeAnchor();
return;
}
// Reapply a cached scroll position after a section-cache rehydrate.
if (pendingScroll != null) {
restoreScrollTop(pendingScroll);
pendingScroll = null;
}
});
// Snapshot the loaded grid, cursors and scroll position on the way out, so
// returning to this section restores them instead of refetching. Skipped for
// the shallow-routed viewer (pushState doesn't trigger a navigation) — only
// real departures to another route reach here.
beforeNavigate((nav) => {
// Staying on the list (a sort/filter query change via goto) isn't a
// departure — nothing to snapshot.
if (nav.to?.url.pathname === '/files') return;
if (files.length === 0) return;
const scroller = getScroller();
saveSection<FilesSnapshot>('files', scroller.scrollTop, {
resetKey,
files,
nextCursor,
hasMore,
prevCursor,
hasPrev
});
});
// Reapply a restored scroll offset, retrying across frames because the grid
// may not be laid out yet right after rehydrate. Releases the load guard once
// applied so InfiniteScroll can resume.
function restoreScrollTop(top: number) {
let tries = 10;
const apply = () => {
const scroller = getScroller();
if (scroller.scrollHeight > top + scroller.clientHeight || tries-- <= 0) {
scroller.scrollTop = top;
loading = false;
return;
}
requestAnimationFrame(apply);
};
requestAnimationFrame(apply);
}
// Scroll the grid so the given file is centred. Uses scrollIntoView (works
// whether the actual scroller is <main> or the window) and retries across
// frames because the cards may not be laid out yet right after a restore.
@@ -259,14 +462,32 @@
// 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;
if (loading || !hasPrev) return;
loading = true;
try {
let items: File[];
let newPrevCursor: string | null;
if (prevCursor) {
const params = baseListParams();
params.set('cursor', prevCursor);
params.set('direction', 'backward');
const res = await api.get<FileCursorPage>(`/files?${params}`);
const items = res.items ?? [];
items = res.items ?? [];
newPrevCursor = res.prev_cursor ?? null;
} else {
// The head cursor was dropped when the window trimmed its top. Refetch
// the rows just before the current first file from an anchored window.
const firstId = files[0]?.id;
if (!firstId) {
hasPrev = false;
return;
}
const res = await fetchAnchorWindow(firstId);
const all = res.items ?? [];
const idx = all.findIndex((f) => f.id === firstId);
items = idx > 0 ? all.slice(0, idx) : [];
newPrevCursor = res.prev_cursor ?? null;
}
if (items.length === 0) {
hasPrev = false;
return;
@@ -279,11 +500,13 @@
const beforeHeight = scroller.scrollHeight;
files = [...items, ...files];
prevCursor = res.prev_cursor ?? null;
hasPrev = !!res.prev_cursor;
prevCursor = newPrevCursor;
hasPrev = !!newPrevCursor;
flushSync(); // apply the prepend now, before the browser paints
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
trimTail();
} catch {
hasPrev = false;
} finally {
@@ -305,17 +528,85 @@
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
}
// ---- Windowing -----------------------------------------------------------
// The grid keeps at most ~4 viewports of rows in memory. As it grows past the
// cap on one end, the off-screen rows on the other end are trimmed; the cursor
// for the trimmed boundary is dropped (set null) and the opposite `has*` flag
// is raised, so scrolling back refills that side from an anchored window.
const CARD_PITCH = 162; // 160px thumbnail + 2px grid gap
function windowCap(): number {
const scroller = getScroller();
const w = scroller.clientWidth || 390;
const h = scroller.clientHeight || 700;
const cols = Math.max(1, Math.floor(w / CARD_PITCH));
const rows = Math.max(1, Math.ceil(h / CARD_PITCH));
return Math.max(4 * cols * rows, 2 * LIMIT);
}
// Fetch a window centred on a file (with its boundary cursors), used to refill
// a trimmed edge where the original cursor is no longer held.
function fetchAnchorWindow(anchorId: string): Promise<FileCursorPage> {
const a = baseListParams();
a.set('anchor', anchorId);
return api.get<FileCursorPage>(`/files?${a}`);
}
// Drop the off-screen rows above the viewport once the grid grew past the cap.
// Run after appended rows have painted so the height delta measures only the
// removed top; scroll is compensated so the visible rows don't jump.
function trimHead() {
const cap = windowCap();
if (files.length <= cap) return;
flushSync(); // paint the just-appended (below-fold) rows before measuring
const scroller = getScroller();
const beforeTop = scroller.scrollTop;
const beforeHeight = scroller.scrollHeight;
files = files.slice(files.length - cap);
prevCursor = null;
hasPrev = true;
flushSync();
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
}
// Symmetric to trimHead for upward growth: drop the off-screen rows below the
// viewport. No scroll compensation — the removed rows are past the fold.
function trimTail() {
const cap = windowCap();
if (files.length <= cap) return;
files = files.slice(0, cap);
nextCursor = null;
hasMore = true;
}
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
error = '';
try {
let newItems: File[];
let newNextCursor: string | null;
if (nextCursor == null && files.length > 0) {
// The tail cursor was dropped when the window trimmed its bottom.
// Refetch the rows after the current last file from an anchored window.
const lastId = files[files.length - 1]?.id;
const res = await fetchAnchorWindow(lastId!);
const all = res.items ?? [];
const idx = all.findIndex((f) => f.id === lastId);
newItems = idx >= 0 ? all.slice(idx + 1) : [];
newNextCursor = res.next_cursor ?? null;
} else {
const params = baseListParams();
if (nextCursor) params.set('cursor', nextCursor);
const res = await api.get<FileCursorPage>(`/files?${params}`);
files = [...files, ...(res.items ?? [])];
nextCursor = res.next_cursor ?? null;
hasMore = !!res.next_cursor;
newItems = res.items ?? [];
newNextCursor = res.next_cursor ?? null;
}
files = [...files, ...newItems];
nextCursor = newNextCursor;
hasMore = !!newNextCursor;
trimHead();
} catch (err) {
error = err instanceof ApiError ? err.message : 'Failed to load files';
hasMore = false;
@@ -467,7 +758,7 @@
});
</script>
<svelte:window onkeydown={handleEscape} />
<svelte:window onkeydown={handleKey} />
<svelte:head>
<title>Files | Tanabata</title>
@@ -500,13 +791,15 @@
<InfiniteScroll {loading} hasMore={hasPrev} onLoadMore={loadPrev} edge="top" />
{/if}
<div class="grid">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="grid" onpointerdowncapture={() => (kbActive = false)}>
{#each files as file, i (file.id)}
<FileCard
{file}
index={i}
selected={$selectionStore.ids.has(file.id ?? '')}
selectionMode={$selectionActive}
focused={kbActive && file.id === focusedId}
onTap={(e) => handleTap(file, i, e)}
onLongPress={(pt) => handleLongPress(file, i, pt)}
/>
@@ -538,7 +831,7 @@
{#if $selectionActive}
<SelectionBar
onEditTags={() => (tagEditorOpen = true)}
onEditTags={openTagEditor}
onAddToPool={openPoolPicker}
onDelete={() => (confirmDeleteFiles = true)}
/>
+43 -2
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
import { get } from 'svelte/store';
import { api, ApiError } from '$lib/api/client';
import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache';
import { restoreListScroll } from '$lib/stores/listScroll';
import type { Pool, PoolOffsetPage } from '$lib/api/types';
const LIMIT = 50;
@@ -25,6 +28,44 @@
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
let prevKey = $state('');
let scrollEl = $state<HTMLElement>();
let pendingScroll: number | null = null;
// Rehydrate the loaded list, search and scroll from the cache on return (same
// sort/order/search), during init so the matching prevKey/initialLoaded
// suppress the reset + initial load below.
const cached = takeSection<OffsetListSnapshot<Pool>>('pools');
if (cached) {
const s0 = get(poolSorting);
const wouldKey = `${s0.sort}|${s0.order}|${cached.data.search}`;
if (wouldKey === cached.data.resetKey && cached.data.items.length > 0) {
search = cached.data.search;
pools = cached.data.items;
total = cached.data.total;
offset = cached.data.offset;
initialLoaded = true;
prevKey = wouldKey;
pendingScroll = cached.scrollTop;
}
}
beforeNavigate(() => {
if (pools.length === 0) return;
saveSection<OffsetListSnapshot<Pool>>('pools', scrollEl?.scrollTop ?? 0, {
resetKey,
search,
items: pools,
total,
offset
});
});
afterNavigate(() => {
if (pendingScroll == null) return;
restoreListScroll(() => scrollEl, pendingScroll);
pendingScroll = null;
});
$effect(() => {
if (resetKey !== prevKey) {
prevKey = resetKey;
@@ -147,7 +188,7 @@
</div>
</div>
<main>
<main bind:this={scrollEl}>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
+44 -2
View File
@@ -1,9 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
import { get } from 'svelte/store';
import { api, ApiError } from '$lib/api/client';
import { tagSorting, type TagSortField } from '$lib/stores/sorting';
import TagBadge from '$lib/components/tag/TagBadge.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache';
import { restoreListScroll } from '$lib/stores/listScroll';
import type { Tag, TagOffsetPage } from '$lib/api/types';
const LIMIT = 100;
@@ -30,6 +33,45 @@
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
let prevKey = $state('');
let scrollEl = $state<HTMLElement>();
let pendingScroll: number | null = null;
// Returning from another section: rehydrate the loaded list, search and scroll
// from the cache instead of refetching, as long as the snapshot was taken under
// the same sort/order/search. Done during init (before the effects below run)
// so the matching prevKey/initialLoaded suppress the reset + initial load.
const cached = takeSection<OffsetListSnapshot<Tag>>('tags');
if (cached) {
const s0 = get(tagSorting);
const wouldKey = `${s0.sort}|${s0.order}|${cached.data.search}`;
if (wouldKey === cached.data.resetKey && cached.data.items.length > 0) {
search = cached.data.search;
tags = cached.data.items;
total = cached.data.total;
offset = cached.data.offset;
initialLoaded = true;
prevKey = wouldKey;
pendingScroll = cached.scrollTop;
}
}
beforeNavigate(() => {
if (tags.length === 0) return;
saveSection<OffsetListSnapshot<Tag>>('tags', scrollEl?.scrollTop ?? 0, {
resetKey,
search,
items: tags,
total,
offset
});
});
afterNavigate(() => {
if (pendingScroll == null) return;
restoreListScroll(() => scrollEl, pendingScroll);
pendingScroll = null;
});
$effect(() => {
if (resetKey !== prevKey) {
prevKey = resetKey;
@@ -155,7 +197,7 @@
</div>
</div>
<main>
<main bind:this={scrollEl}>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}