Compare commits
9 Commits
| 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 { goto } from '$app/navigation';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { authStore } from '$lib/stores/auth';
|
import { authStore } from '$lib/stores/auth';
|
||||||
|
import { clearSection, type SectionKey } from '$lib/stores/sectionCache';
|
||||||
|
|
||||||
const BASE = '/api/v1';
|
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
|
/** 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
|
* token is missing or rejected, so an expired session doesn't strand the user
|
||||||
* on a page that only shows errors. */
|
* 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;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,51 @@
|
|||||||
busy = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="editor" class:busy>
|
<div class="editor" class:busy>
|
||||||
@@ -131,12 +176,13 @@
|
|||||||
<span class="hint">— partial tags shown with dashed border, click to apply to all</span>
|
<span class="hint">— partial tags shown with dashed border, click to apply to all</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-row">
|
<div class="tag-row">
|
||||||
{#each assignedTags as tag (tag.id)}
|
{#each assignedTags as tag, i (tag.id)}
|
||||||
{@const isPartial = partialIds.has(tag.id ?? '')}
|
{@const isPartial = partialIds.has(tag.id ?? '')}
|
||||||
<div class="tag-wrap">
|
<div class="tag-wrap">
|
||||||
<button
|
<button
|
||||||
class="tag assigned"
|
class="tag assigned"
|
||||||
class:partial={isPartial}
|
class:partial={isPartial}
|
||||||
|
class:kbfocus={assignedFocusIdx === i}
|
||||||
style={tagStyle(tag)}
|
style={tagStyle(tag)}
|
||||||
onclick={() => (isPartial ? promotePartial(tag.id!) : remove(tag.id!))}
|
onclick={() => (isPartial ? promotePartial(tag.id!) : remove(tag.id!))}
|
||||||
title={isPartial
|
title={isPartial
|
||||||
@@ -162,6 +208,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Search tags…"
|
placeholder="Search tags…"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
|
onkeydown={onSearchKeydown}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
{#if search}
|
{#if search}
|
||||||
@@ -182,9 +229,10 @@
|
|||||||
{#if availableTags.length > 0}
|
{#if availableTags.length > 0}
|
||||||
<div class="section-label">Add tag</div>
|
<div class="section-label">Add tag</div>
|
||||||
<div class="tag-row available-row">
|
<div class="tag-row available-row">
|
||||||
{#each availableTags as tag (tag.id)}
|
{#each availableTags as tag, i (tag.id)}
|
||||||
<button
|
<button
|
||||||
class="tag available"
|
class="tag available"
|
||||||
|
class:hl={highlightIdx === i}
|
||||||
style={tagStyle(tag)}
|
style={tagStyle(tag)}
|
||||||
onclick={() => add(tag.id!)}
|
onclick={() => add(tag.id!)}
|
||||||
title="Add to all selected files"
|
title="Add to all selected files"
|
||||||
@@ -310,6 +358,17 @@
|
|||||||
filter: brightness(1.1);
|
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 {
|
.search-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
index: number;
|
index: number;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
selectionMode?: boolean;
|
selectionMode?: boolean;
|
||||||
|
/** Roving keyboard-focus ring (shown only during keyboard navigation). */
|
||||||
|
focused?: boolean;
|
||||||
onTap?: (e: MouseEvent) => void;
|
onTap?: (e: MouseEvent) => void;
|
||||||
/** Called when long-press fires; receives the pointerType of the gesture. */
|
/** Called when long-press fires; receives the pointerType of the gesture. */
|
||||||
onLongPress?: (pointerType: string) => void;
|
onLongPress?: (pointerType: string) => void;
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
index,
|
index,
|
||||||
selected = false,
|
selected = false,
|
||||||
selectionMode = false,
|
selectionMode = false,
|
||||||
|
focused = false,
|
||||||
onTap,
|
onTap,
|
||||||
onLongPress
|
onLongPress
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -108,6 +111,7 @@
|
|||||||
class="card"
|
class="card"
|
||||||
class:loaded={!!imgSrc}
|
class:loaded={!!imgSrc}
|
||||||
class:selected
|
class:selected
|
||||||
|
class:focused
|
||||||
data-file-index={index}
|
data-file-index={index}
|
||||||
onpointerdown={onPointerDown}
|
onpointerdown={onPointerDown}
|
||||||
onpointermove={onPointerMoveInternal}
|
onpointermove={onPointerMoveInternal}
|
||||||
@@ -215,6 +219,12 @@
|
|||||||
background-color: color-mix(in srgb, var(--color-accent) 35%, transparent);
|
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 {
|
.check {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
|
|||||||
@@ -184,17 +184,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Keyboard ----
|
// ---- Keyboard ----
|
||||||
|
let tagsSection = $state<HTMLElement>();
|
||||||
|
let pendingTagFocus = false;
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
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);
|
if (prevId) onNavigate(prevId);
|
||||||
} else if (e.key === 'ArrowRight') {
|
} else if (e.key === 'ArrowRight' || e.key === 'j') {
|
||||||
if (nextId) onNavigate(nextId);
|
if (nextId) onNavigate(nextId);
|
||||||
|
} else if (e.key === 'e') {
|
||||||
|
e.preventDefault();
|
||||||
|
jumpToTags();
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
onClose();
|
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 ----
|
// ---- Helpers ----
|
||||||
function formatDatetime(iso: string | null | undefined): string {
|
function formatDatetime(iso: string | null | undefined): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
@@ -344,7 +371,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Tags (loaded lazily on scroll) -->
|
<!-- Tags (loaded lazily on scroll) -->
|
||||||
<section class="section" use:tagsSentinel>
|
<section class="section" use:tagsSentinel bind:this={tagsSection}>
|
||||||
<div class="field-label">Tags</div>
|
<div class="field-label">Tags</div>
|
||||||
{#if tagsLoaded}
|
{#if tagsLoaded}
|
||||||
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
|
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
|
||||||
|
|||||||
@@ -53,6 +53,71 @@
|
|||||||
onApply(null);
|
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 ---
|
// --- Drag-and-drop reordering ---
|
||||||
let dragIndex = $state<number | null>(null);
|
let dragIndex = $state<number | null>(null);
|
||||||
let dropIndex = $state<number | null>(null);
|
let dropIndex = $state<number | null>(null);
|
||||||
@@ -99,6 +164,7 @@
|
|||||||
class="token active-token"
|
class="token active-token"
|
||||||
class:dragging={dragIndex === i}
|
class:dragging={dragIndex === i}
|
||||||
class:drop-before={dropIndex === i && dragIndex !== null && dragIndex !== i}
|
class:drop-before={dropIndex === i && dragIndex !== null && dragIndex !== i}
|
||||||
|
class:kbfocus={tokenFocusIdx === i}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -129,14 +195,16 @@
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Search tags…"
|
placeholder="Search tags…"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
|
onkeydown={onSearchKeydown}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Tag list -->
|
<!-- Tag list -->
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
{#each filteredTags as tag (tag.id)}
|
{#each filteredTags as tag, i (tag.id)}
|
||||||
<button
|
<button
|
||||||
class="token tag-token"
|
class="token tag-token"
|
||||||
|
class:hl={highlightIdx === i}
|
||||||
style="background-color: {tag.color
|
style="background-color: {tag.color
|
||||||
? '#' + tag.color
|
? '#' + tag.color
|
||||||
: tag.category_color
|
: tag.category_color
|
||||||
@@ -232,6 +300,11 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-token.kbfocus {
|
||||||
|
outline: 2px solid var(--color-danger);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.op-token {
|
.op-token {
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-bg-elevated));
|
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-bg-elevated));
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
@@ -278,6 +351,11 @@
|
|||||||
filter: brightness(1.15);
|
filter: brightness(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-token.hl {
|
||||||
|
outline: 2px solid var(--color-text-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.no-tags {
|
.no-tags {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -64,6 +64,53 @@
|
|||||||
const color = tag.color ?? tag.category_color;
|
const color = tag.color ?? tag.category_color;
|
||||||
return color ? `background-color: #${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>
|
</script>
|
||||||
|
|
||||||
<div class="picker" class:busy>
|
<div class="picker" class:busy>
|
||||||
@@ -71,9 +118,10 @@
|
|||||||
{#if fileTags.length > 0}
|
{#if fileTags.length > 0}
|
||||||
<div class="section-label">Assigned</div>
|
<div class="section-label">Assigned</div>
|
||||||
<div class="tag-row">
|
<div class="tag-row">
|
||||||
{#each filteredAssigned as tag (tag.id)}
|
{#each filteredAssigned as tag, i (tag.id)}
|
||||||
<button
|
<button
|
||||||
class="tag assigned"
|
class="tag assigned"
|
||||||
|
class:kbfocus={assignedFocusIdx === i}
|
||||||
style={tagStyle(tag)}
|
style={tagStyle(tag)}
|
||||||
onclick={() => handleRemove(tag.id!)}
|
onclick={() => handleRemove(tag.id!)}
|
||||||
title="Remove tag"
|
title="Remove tag"
|
||||||
@@ -92,6 +140,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Search tags…"
|
placeholder="Search tags…"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
|
onkeydown={onSearchKeydown}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
{#if search}
|
{#if search}
|
||||||
@@ -112,9 +161,10 @@
|
|||||||
{#if filteredAvailable.length > 0}
|
{#if filteredAvailable.length > 0}
|
||||||
<div class="section-label">Add tag</div>
|
<div class="section-label">Add tag</div>
|
||||||
<div class="tag-row available-row">
|
<div class="tag-row available-row">
|
||||||
{#each filteredAvailable as tag (tag.id)}
|
{#each filteredAvailable as tag, i (tag.id)}
|
||||||
<button
|
<button
|
||||||
class="tag available"
|
class="tag available"
|
||||||
|
class:hl={highlightIdx === i}
|
||||||
style={tagStyle(tag)}
|
style={tagStyle(tag)}
|
||||||
onclick={() => handleAdd(tag.id!)}
|
onclick={() => handleAdd(tag.id!)}
|
||||||
title="Add tag"
|
title="Add tag"
|
||||||
@@ -198,6 +248,17 @@
|
|||||||
filter: brightness(1.1);
|
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 {
|
.search-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
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">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { themeStore, toggleTheme } from '$lib/stores/theme';
|
import { themeStore, toggleTheme } from '$lib/stores/theme';
|
||||||
|
import KeyboardHelp from '$lib/components/layout/KeyboardHelp.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -35,15 +37,121 @@
|
|||||||
|
|
||||||
const isLogin = $derived($page.url.pathname === '/login');
|
const isLogin = $derived($page.url.pathname === '/login');
|
||||||
const isAdmin = $derived($page.url.pathname.startsWith('/admin'));
|
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>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onGlobalKey} />
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
|
{#if helpOpen}
|
||||||
|
<KeyboardHelp onClose={() => (helpOpen = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !isLogin && !isAdmin}
|
{#if !isLogin && !isAdmin}
|
||||||
<footer>
|
<footer>
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
{@const active = $page.url.pathname.startsWith(item.match)}
|
{@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'}
|
{#if item.label === 'Categories'}
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width="24"
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<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 { api, ApiError } from '$lib/api/client';
|
||||||
import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
|
import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
|
||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
import 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';
|
import type { Category, CategoryOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
const LIMIT = 100;
|
const LIMIT = 100;
|
||||||
@@ -26,6 +29,44 @@
|
|||||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||||
let prevKey = $state('');
|
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(() => {
|
$effect(() => {
|
||||||
if (resetKey !== prevKey) {
|
if (resetKey !== prevKey) {
|
||||||
prevKey = resetKey;
|
prevKey = resetKey;
|
||||||
@@ -146,7 +187,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main bind:this={scrollEl}>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
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 { api } from '$lib/api/client';
|
||||||
import { ApiError } from '$lib/api/client';
|
import { ApiError } from '$lib/api/client';
|
||||||
import FileCard from '$lib/components/file/FileCard.svelte';
|
import FileCard from '$lib/components/file/FileCard.svelte';
|
||||||
@@ -19,6 +20,17 @@
|
|||||||
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
||||||
import { appSettings } from '$lib/stores/appSettings';
|
import { appSettings } from '$lib/stores/appSettings';
|
||||||
|
|
||||||
|
// 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 scrollContainer = $state<HTMLElement | undefined>();
|
||||||
|
|
||||||
let uploader = $state<{ open: () => void } | undefined>();
|
let uploader = $state<{ open: () => void } | undefined>();
|
||||||
@@ -27,16 +39,139 @@
|
|||||||
// ---- Bulk tag editor ----
|
// ---- Bulk tag editor ----
|
||||||
let tagEditorOpen = $state(false);
|
let tagEditorOpen = $state(false);
|
||||||
|
|
||||||
// Escape dismisses one layer at a time: an open overlay (tag editor / pool
|
// ---- Keyboard roving focus ----
|
||||||
// picker / delete confirm) first, then the selection. The file viewer owns
|
// The id of the grid's keyboard-focused file, plus a flag that gates the focus
|
||||||
// its own Escape, so we bail out while it's up.
|
// ring so it only shows once the user actually starts navigating by keyboard.
|
||||||
function handleEscape(e: KeyboardEvent) {
|
let focusedId = $state<string | null>(null);
|
||||||
if (e.key !== 'Escape') return;
|
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;
|
if (tagEditorOpen) tagEditorOpen = false;
|
||||||
else if (poolPickerOpen) poolPickerOpen = false;
|
else if (poolPickerOpen) poolPickerOpen = false;
|
||||||
else if (confirmDeleteFiles) confirmDeleteFiles = false;
|
else if (confirmDeleteFiles) confirmDeleteFiles = false;
|
||||||
else if (activeFileId) return;
|
else if (activeFileId) return;
|
||||||
else if ($selectionActive) selectionStore.exit();
|
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 ----
|
// ---- Add to pool picker ----
|
||||||
@@ -113,6 +248,11 @@
|
|||||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
||||||
let prevKey = $state('');
|
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.
|
// 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
|
// 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.
|
// no snapshot to restore — except a deep-link return carrying an anchor.
|
||||||
@@ -122,6 +262,26 @@
|
|||||||
const firstRun = prevKey === '';
|
const firstRun = prevKey === '';
|
||||||
prevKey = key;
|
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 = [];
|
files = [];
|
||||||
nextCursor = null;
|
nextCursor = null;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
@@ -149,9 +309,52 @@
|
|||||||
if (anchor) {
|
if (anchor) {
|
||||||
scrollToFile(anchor);
|
scrollToFile(anchor);
|
||||||
consumeAnchor();
|
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
|
// Scroll the grid so the given file is centred. Uses scrollIntoView (works
|
||||||
// whether the actual scroller is <main> or the window) and retries across
|
// 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.
|
// 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
|
// between) so there's no visible jump. Shares the `loading` guard with loadMore
|
||||||
// so the two never mutate files concurrently.
|
// so the two never mutate files concurrently.
|
||||||
async function loadPrev() {
|
async function loadPrev() {
|
||||||
if (loading || !hasPrev || !prevCursor) return;
|
if (loading || !hasPrev) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
|
let items: File[];
|
||||||
|
let newPrevCursor: string | null;
|
||||||
|
if (prevCursor) {
|
||||||
const params = baseListParams();
|
const params = baseListParams();
|
||||||
params.set('cursor', prevCursor);
|
params.set('cursor', prevCursor);
|
||||||
params.set('direction', 'backward');
|
params.set('direction', 'backward');
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
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) {
|
if (items.length === 0) {
|
||||||
hasPrev = false;
|
hasPrev = false;
|
||||||
return;
|
return;
|
||||||
@@ -279,11 +500,13 @@
|
|||||||
const beforeHeight = scroller.scrollHeight;
|
const beforeHeight = scroller.scrollHeight;
|
||||||
|
|
||||||
files = [...items, ...files];
|
files = [...items, ...files];
|
||||||
prevCursor = res.prev_cursor ?? null;
|
prevCursor = newPrevCursor;
|
||||||
hasPrev = !!res.prev_cursor;
|
hasPrev = !!newPrevCursor;
|
||||||
|
|
||||||
flushSync(); // apply the prepend now, before the browser paints
|
flushSync(); // apply the prepend now, before the browser paints
|
||||||
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
||||||
|
|
||||||
|
trimTail();
|
||||||
} catch {
|
} catch {
|
||||||
hasPrev = false;
|
hasPrev = false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -305,17 +528,85 @@
|
|||||||
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
|
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() {
|
async function loadMore() {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
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();
|
const params = baseListParams();
|
||||||
if (nextCursor) params.set('cursor', nextCursor);
|
if (nextCursor) params.set('cursor', nextCursor);
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
files = [...files, ...(res.items ?? [])];
|
newItems = res.items ?? [];
|
||||||
nextCursor = res.next_cursor ?? null;
|
newNextCursor = res.next_cursor ?? null;
|
||||||
hasMore = !!res.next_cursor;
|
}
|
||||||
|
files = [...files, ...newItems];
|
||||||
|
nextCursor = newNextCursor;
|
||||||
|
hasMore = !!newNextCursor;
|
||||||
|
trimHead();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
@@ -467,7 +758,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleEscape} />
|
<svelte:window onkeydown={handleKey} />
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Files | Tanabata</title>
|
<title>Files | Tanabata</title>
|
||||||
@@ -500,13 +791,15 @@
|
|||||||
<InfiniteScroll {loading} hasMore={hasPrev} onLoadMore={loadPrev} edge="top" />
|
<InfiniteScroll {loading} hasMore={hasPrev} onLoadMore={loadPrev} edge="top" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="grid" onpointerdowncapture={() => (kbActive = false)}>
|
||||||
{#each files as file, i (file.id)}
|
{#each files as file, i (file.id)}
|
||||||
<FileCard
|
<FileCard
|
||||||
{file}
|
{file}
|
||||||
index={i}
|
index={i}
|
||||||
selected={$selectionStore.ids.has(file.id ?? '')}
|
selected={$selectionStore.ids.has(file.id ?? '')}
|
||||||
selectionMode={$selectionActive}
|
selectionMode={$selectionActive}
|
||||||
|
focused={kbActive && file.id === focusedId}
|
||||||
onTap={(e) => handleTap(file, i, e)}
|
onTap={(e) => handleTap(file, i, e)}
|
||||||
onLongPress={(pt) => handleLongPress(file, i, pt)}
|
onLongPress={(pt) => handleLongPress(file, i, pt)}
|
||||||
/>
|
/>
|
||||||
@@ -538,7 +831,7 @@
|
|||||||
|
|
||||||
{#if $selectionActive}
|
{#if $selectionActive}
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
onEditTags={() => (tagEditorOpen = true)}
|
onEditTags={openTagEditor}
|
||||||
onAddToPool={openPoolPicker}
|
onAddToPool={openPoolPicker}
|
||||||
onDelete={() => (confirmDeleteFiles = true)}
|
onDelete={() => (confirmDeleteFiles = true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<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 { api, ApiError } from '$lib/api/client';
|
||||||
import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
|
import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
|
||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
import 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';
|
import type { Pool, PoolOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
@@ -25,6 +28,44 @@
|
|||||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||||
let prevKey = $state('');
|
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(() => {
|
$effect(() => {
|
||||||
if (resetKey !== prevKey) {
|
if (resetKey !== prevKey) {
|
||||||
prevKey = resetKey;
|
prevKey = resetKey;
|
||||||
@@ -147,7 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main bind:this={scrollEl}>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<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 { api, ApiError } from '$lib/api/client';
|
||||||
import { tagSorting, type TagSortField } from '$lib/stores/sorting';
|
import { tagSorting, type TagSortField } from '$lib/stores/sorting';
|
||||||
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
import 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';
|
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
const LIMIT = 100;
|
const LIMIT = 100;
|
||||||
@@ -30,6 +33,45 @@
|
|||||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||||
let prevKey = $state('');
|
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(() => {
|
$effect(() => {
|
||||||
if (resetKey !== prevKey) {
|
if (resetKey !== prevKey) {
|
||||||
prevKey = resetKey;
|
prevKey = resetKey;
|
||||||
@@ -155,7 +197,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main bind:this={scrollEl}>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user