Compare commits
9 Commits
e97b7282ff
...
19ec96c544
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ec96c544 | |||
| 49e68cc263 | |||
| 3a0dbc9ba7 | |||
| 9a20cc1c84 | |||
| 49de9fe42b | |||
| 2b39af8c1c | |||
| 05af819b3e | |||
| e93240ff79 | |||
| 370dfd95bc |
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 1–5 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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
if (tagEditorOpen) tagEditorOpen = false;
|
||||
else if (poolPickerOpen) poolPickerOpen = false;
|
||||
else if (confirmDeleteFiles) confirmDeleteFiles = false;
|
||||
else if (activeFileId) return;
|
||||
else if ($selectionActive) selectionStore.exit();
|
||||
// ---- 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 {
|
||||
const params = baseListParams();
|
||||
params.set('cursor', prevCursor);
|
||||
params.set('direction', 'backward');
|
||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||
const items = res.items ?? [];
|
||||
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}`);
|
||||
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 {
|
||||
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;
|
||||
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}`);
|
||||
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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user