Files
tanabata/frontend/src/routes/files/+page.svelte
T
H1K0 0e7890a465 style(project): format Go with gofmt, set up Prettier for the frontend
Run gofmt -w across the backend, normalising the manually-aligned := blocks
to the gofmt standard. No code behaviour changes.

Add Prettier (+ prettier-plugin-svelte) to the frontend with the SvelteKit
default config (tabs, single quotes) so formatting is reproducible, then run
it over the whole tree. Add format / format:check npm scripts and a
.prettierignore (build output, generated schema.ts, static assets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:01:29 +03:00

820 lines
23 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { afterNavigate, goto, pushState, replaceState } from '$app/navigation';
import { api } from '$lib/api/client';
import { ApiError } from '$lib/api/client';
import FileCard from '$lib/components/file/FileCard.svelte';
import FileViewer from '$lib/components/file/FileViewer.svelte';
import FileUpload from '$lib/components/file/FileUpload.svelte';
import FilterBar from '$lib/components/file/FilterBar.svelte';
import Header from '$lib/components/layout/Header.svelte';
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
import { selectionStore, selectionActive } from '$lib/stores/selection';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
import { tick, flushSync } from 'svelte';
import { parseDslFilter } from '$lib/utils/dsl';
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
import { appSettings } from '$lib/stores/appSettings';
let scrollContainer = $state<HTMLElement | undefined>();
let uploader = $state<{ open: () => void } | undefined>();
let confirmDeleteFiles = $state(false);
// ---- Bulk tag editor ----
let tagEditorOpen = $state(false);
// ---- Add to pool picker ----
let poolPickerOpen = $state(false);
let pools = $state<Pool[]>([]);
let poolsLoading = $state(false);
let poolPickerSearch = $state('');
let poolPickerError = $state('');
async function openPoolPicker() {
poolPickerOpen = true;
poolPickerError = '';
poolsLoading = true;
poolPickerSearch = '';
try {
const res = await api.get<PoolOffsetPage>('/pools?limit=200&sort=name&order=asc');
pools = res.items ?? [];
} catch {
poolPickerError = 'Failed to load pools';
} finally {
poolsLoading = false;
}
}
async function addToPool(poolId: string) {
const ids = [...$selectionStore.ids];
poolPickerOpen = false;
selectionStore.exit();
try {
await api.post(`/pools/${poolId}/files`, { file_ids: ids });
} catch {
// silently ignore
}
}
let filteredPools = $derived(
poolPickerSearch.trim()
? pools.filter((p) => p.name?.toLowerCase().includes(poolPickerSearch.toLowerCase()))
: pools
);
function handleUploaded(file: File) {
files = [file, ...files];
}
let LIMIT = $derived($appSettings.fileLoadLimit);
const FILE_SORT_OPTIONS = [
{ value: 'created', label: 'Created' },
{ value: 'content_datetime', label: 'Date taken' },
{ value: 'original_name', label: 'Name' },
{ value: 'mime', label: 'Type' }
];
let files = $state<File[]>([]);
let nextCursor = $state<string | null>(null);
// Start busy when arriving with an ?anchor so the InfiniteScroll sentinels
// can't fire a stray page-1 loadMore before loadAroundAnchor takes over (their
// effects run before this component's reset effect on mount).
let loading = $state(Boolean(page.url.searchParams.get('anchor')));
let hasMore = $state(true);
// Backward pagination — only active after an anchored return, where the grid
// starts in the middle of the list and can grow upward as well as downward.
let prevCursor = $state<string | null>(null);
let hasPrev = $state(false);
let error = $state('');
let filterOpen = $state(false);
let filterParam = $derived(page.url.searchParams.get('filter'));
let anchorParam = $derived(page.url.searchParams.get('anchor'));
let activeTokens = $derived(parseDslFilter(filterParam));
let sortState = $derived($fileSorting);
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
let prevKey = $state('');
// Reset + reload when the query (sort/order/filter) changes or on first mount.
// The viewer opens as an overlay now (the list is never unmounted), so there's
// no snapshot to restore — except a deep-link return carrying an anchor.
$effect(() => {
const key = resetKey;
if (key === prevKey) return;
const firstRun = prevKey === '';
prevKey = key;
files = [];
nextCursor = null;
hasMore = true;
// A plain list starts at the top, so there is nothing before it.
prevCursor = null;
hasPrev = false;
error = '';
// Deep-link return carrying a position anchor but no loaded grid: load a
// window centred on the anchor instead of page 1, so we can scroll to it
// and grow the grid in both directions.
if (firstRun && anchorParam) {
void loadAroundAnchor(anchorParam);
}
});
// Scroll to an ?anchor= file on a deep-link return. Runs in afterNavigate
// because it fires AFTER SvelteKit's own scroll handling, so our position wins
// instead of being reset to the top.
afterNavigate(() => {
const anchor = page.url.searchParams.get('anchor');
if (anchor) {
scrollToFile(anchor);
consumeAnchor();
}
});
// 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.
function scrollToFile(anchorId: string | null) {
if (!anchorId) return;
const tryScroll = () => {
const idx = files.findIndex((f) => f.id === anchorId);
const card =
idx >= 0 && scrollContainer
? scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`)
: null;
if (card) {
card.scrollIntoView({ block: 'center' });
return true;
}
return false;
};
// Centre immediately if the card is already laid out (it is, right after the
// anchored load's tick) so it's pinned before any scroll sentinel fires.
if (tryScroll()) return;
let tries = 10;
const loop = () => {
if (tryScroll() || tries-- <= 0) return;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
// Drop the ?anchor= param once consumed so it doesn't linger in the URL or
// re-fire on later interactions. Shallow update — no navigation, no scroll.
function consumeAnchor() {
const url = new URL(page.url);
if (!url.searchParams.has('anchor')) return;
url.searchParams.delete('anchor');
replaceState(`${url.pathname}${url.search}`, page.state);
}
// How many pages to pre-load on each side of the anchor so the viewport is
// covered and the scroll sentinels start out of range (no mount-time storm).
const ANCHOR_PREFILL_PAGES = 3;
function baseListParams(): URLSearchParams {
const p = new URLSearchParams({
limit: String(LIMIT),
sort: sortState.sort,
order: sortState.order
});
if (filterParam) p.set('filter', filterParam);
return p;
}
// Deep link / hard reload with an anchor but no loaded grid: fetch a window
// centred on that file and pre-fill a few pages each way, all sequentially, so
// the grid is filled around the anchor before we centre on it. The prev/next
// cursors then let it keep growing in both directions as the user scrolls.
async function loadAroundAnchor(anchor: string) {
loading = true;
error = '';
try {
const a = baseListParams();
a.set('anchor', anchor);
const res = await api.get<FileCursorPage>(`/files?${a}`);
files = res.items ?? [];
nextCursor = res.next_cursor ?? null;
hasMore = !!res.next_cursor;
prevCursor = res.prev_cursor ?? null;
hasPrev = !!res.prev_cursor;
for (let i = 0; i < ANCHOR_PREFILL_PAGES && hasMore && nextCursor; i++) {
const p = baseListParams();
p.set('cursor', nextCursor);
const r = await api.get<FileCursorPage>(`/files?${p}`);
files = [...files, ...(r.items ?? [])];
nextCursor = r.next_cursor ?? null;
hasMore = !!r.next_cursor;
}
for (let i = 0; i < ANCHOR_PREFILL_PAGES && hasPrev && prevCursor; i++) {
const p = baseListParams();
p.set('cursor', prevCursor);
p.set('direction', 'backward');
const r = await api.get<FileCursorPage>(`/files?${p}`);
const items = r.items ?? [];
if (items.length === 0) {
hasPrev = false;
break;
}
files = [...items, ...files];
prevCursor = r.prev_cursor ?? null;
hasPrev = !!r.prev_cursor;
}
await tick();
scrollToFile(anchor);
consumeAnchor();
} catch (err) {
error = err instanceof ApiError ? err.message : 'Failed to load files';
} finally {
loading = false;
}
}
// Load the previous page (scrolling up) and prepend it. Content inserted above
// the viewport would push everything down, so we shift the scroll down by
// exactly the added height — applied synchronously (flushSync, no paint in
// between) so there's no visible jump. Shares the `loading` guard with loadMore
// so the two never mutate files concurrently.
async function loadPrev() {
if (loading || !hasPrev || !prevCursor) return;
loading = true;
try {
const params = baseListParams();
params.set('cursor', prevCursor);
params.set('direction', 'backward');
const res = await api.get<FileCursorPage>(`/files?${params}`);
const items = res.items ?? [];
if (items.length === 0) {
hasPrev = false;
return;
}
// Capture scroll state just before mutating (after the request, so the
// user's scrolling during it doesn't skew the offset).
const scroller = getScroller();
const beforeTop = scroller.scrollTop;
const beforeHeight = scroller.scrollHeight;
files = [...items, ...files];
prevCursor = res.prev_cursor ?? null;
hasPrev = !!res.prev_cursor;
flushSync(); // apply the prepend now, before the browser paints
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
} catch {
hasPrev = false;
} finally {
loading = false;
}
}
// The element that actually scrolls the grid: the nearest scrollable ancestor,
// or the document scroller (the grid's <main> doesn't scroll on its own here).
function getScroller(): HTMLElement {
let el: HTMLElement | null = scrollContainer ?? null;
while (el) {
const oy = getComputedStyle(el).overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) {
return el;
}
el = el.parentElement;
}
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
}
async function loadMore() {
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;
} catch (err) {
error = err instanceof ApiError ? err.message : 'Failed to load files';
hasMore = false;
} finally {
loading = false;
}
// Viewport filling is handled by InfiniteScroll, which re-checks after each
// load — no manual recursion (which over-fetched here because <main> isn't
// the scroller, so its scrollHeight never exceeds its clientHeight).
}
function applyFilter(filter: string | null) {
const url = new URL(page.url);
if (filter) {
url.searchParams.set('filter', filter);
} else {
url.searchParams.delete('filter');
}
goto(url.toString(), { replaceState: true });
filterOpen = false;
}
function openFile(file: File) {
if (!file.id) return;
// Open the viewer as an overlay on top of the still-mounted grid via
// shallow routing: the URL becomes /files/<id> and the browser back button
// closes it, but the list is never torn down or reloaded.
pushState(`/files/${file.id}`, { fileId: file.id });
}
// ---- Viewer overlay (shallow routing) ----
let activeFileId = $derived(page.state.fileId);
let activeIdx = $derived(activeFileId ? files.findIndex((f) => f.id === activeFileId) : -1);
let viewerPrevId = $derived(activeIdx > 0 ? (files[activeIdx - 1]?.id ?? null) : null);
let viewerNextId = $derived(
activeIdx >= 0 && activeIdx < files.length - 1 ? (files[activeIdx + 1]?.id ?? null) : null
);
// Paging near the end of the loaded grid: pull the next page by cursor so the
// viewer keeps advancing past what was loaded.
$effect(() => {
if (activeIdx >= 0 && activeIdx >= files.length - 3 && hasMore && !loading) {
void loadMore();
}
});
// When the overlay closes (back / Escape / close button), bring the grid to
// the last-viewed file. The list was never unmounted, so this is instant.
let lastOverlayId: string | null = null;
$effect(() => {
const id = activeFileId;
if (id) {
lastOverlayId = id;
} else if (lastOverlayId) {
const target = lastOverlayId;
lastOverlayId = null;
scrollToFile(target);
}
});
function pageTo(id: string) {
// Replace (not push) so a single back press returns to the grid rather than
// stepping back through every file paged.
replaceState(`/files/${id}`, { fileId: id });
}
function closeViewer() {
history.back();
}
// ---- Selection logic ----
let lastSelectedIdx = $state<number | null>(null);
function handleTap(file: File, idx: number, e: MouseEvent) {
if (!$selectionActive) {
openFile(file);
return;
}
if (e.shiftKey && lastSelectedIdx !== null) {
// Range-select between lastSelectedIdx and idx (desktop)
const from = Math.min(lastSelectedIdx, idx);
const to = Math.max(lastSelectedIdx, idx);
for (let i = from; i <= to; i++) {
if (files[i]?.id) selectionStore.select(files[i].id!);
}
lastSelectedIdx = idx;
} else {
if (file.id) selectionStore.toggle(file.id);
lastSelectedIdx = idx;
}
}
function handleLongPress(file: File, idx: number, pointerType: string) {
// Determine drag mode from whether this card is already selected
const alreadySelected = $selectionStore.ids.has(file.id!);
if (alreadySelected) {
selectionStore.deselect(file.id!);
dragMode = 'deselect';
} else {
selectionStore.select(file.id!);
dragMode = 'select';
}
lastSelectedIdx = idx;
// Only enter drag-select for touch — shift+click covers desktop range selection
if (pointerType === 'touch') dragSelecting = true;
}
// ---- Drag-to-select / deselect (touch only) ----
// Entered only after a long-press (400ms stillness), so by the time we
// add the touchmove listener the scroll gesture hasn't started yet.
// A non-passive touchmove listener lets us call preventDefault() to block
// scroll while the user slides their finger across cards.
let dragSelecting = $state(false);
let dragMode = $state<'select' | 'deselect'>('select');
$effect(() => {
if (!dragSelecting) return;
function onTouchMove(e: TouchEvent) {
e.preventDefault(); // block scroll while drag-selecting
const touch = e.touches[0];
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const card = el?.closest<HTMLElement>('[data-file-index]');
if (!card) return;
const idx = parseInt(card.dataset.fileIndex ?? '');
if (isNaN(idx) || !files[idx]?.id) return;
if (dragMode === 'select') {
selectionStore.select(files[idx].id!);
} else {
selectionStore.deselect(files[idx].id!);
}
lastSelectedIdx = idx;
}
function onTouchEnd() {
dragSelecting = false;
}
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
document.addEventListener('touchcancel', onTouchEnd);
return () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
};
});
</script>
<svelte:head>
<title>Files | Tanabata</title>
</svelte:head>
<div class="page">
<Header
sortOptions={FILE_SORT_OPTIONS}
sort={sortState.sort}
order={sortState.order}
filterActive={activeTokens.length > 0 || filterOpen}
onSortChange={(s) => fileSorting.setSort(s as FileSortField)}
onOrderToggle={() => fileSorting.toggleOrder()}
onFilterToggle={() => (filterOpen = !filterOpen)}
onUpload={() => uploader?.open()}
onTrash={() => goto('/files/trash')}
/>
{#if filterOpen}
<FilterBar value={filterParam} onApply={applyFilter} onClose={() => (filterOpen = false)} />
{/if}
<FileUpload bind:this={uploader} onUploaded={handleUploaded}>
<main bind:this={scrollContainer}>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if hasPrev}
<InfiniteScroll {loading} hasMore={hasPrev} onLoadMore={loadPrev} edge="top" />
{/if}
<div class="grid">
{#each files as file, i (file.id)}
<FileCard
{file}
index={i}
selected={$selectionStore.ids.has(file.id ?? '')}
selectionMode={$selectionActive}
onTap={(e) => handleTap(file, i, e)}
onLongPress={(pt) => handleLongPress(file, i, pt)}
/>
{/each}
</div>
<InfiniteScroll {loading} {hasMore} onLoadMore={loadMore} />
{#if !loading && !hasMore && files.length === 0}
<div class="empty">No files yet.</div>
{/if}
</main>
</FileUpload>
</div>
<!-- File viewer overlay (shallow routing): renders on top of the still-mounted
grid, so closing it reveals the list untouched. -->
{#if activeFileId}
<div class="viewer-overlay">
<FileViewer
fileId={activeFileId}
prevId={viewerPrevId}
nextId={viewerNextId}
onNavigate={pageTo}
onClose={closeViewer}
/>
</div>
{/if}
{#if $selectionActive}
<SelectionBar
onEditTags={() => (tagEditorOpen = true)}
onAddToPool={openPoolPicker}
onDelete={() => (confirmDeleteFiles = true)}
/>
{/if}
{#if tagEditorOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="picker-backdrop" role="presentation" onclick={() => (tagEditorOpen = false)}></div>
<div class="picker-sheet tag-sheet" role="dialog" aria-label="Edit tags">
<div class="picker-header">
<span class="picker-title"
>Edit tags — {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1
? 's'
: ''}</span
>
<button class="picker-close" onclick={() => (tagEditorOpen = false)} 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="tag-sheet-body">
<BulkTagEditor fileIds={[...$selectionStore.ids]} onDone={() => (tagEditorOpen = false)} />
</div>
</div>
{/if}
{#if poolPickerOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="picker-backdrop" role="presentation" onclick={() => (poolPickerOpen = false)}></div>
<div class="picker-sheet" role="dialog" aria-label="Add to pool">
<div class="picker-header">
<span class="picker-title"
>Add {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1 ? 's' : ''} to pool</span
>
<button class="picker-close" onclick={() => (poolPickerOpen = false)} 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="picker-search-wrap">
<input
class="picker-search"
type="search"
placeholder="Search pools…"
bind:value={poolPickerSearch}
autocomplete="off"
/>
</div>
{#if poolPickerError}
<p class="picker-error">{poolPickerError}</p>
{:else if poolsLoading}
<p class="picker-empty">Loading…</p>
{:else if filteredPools.length === 0}
<p class="picker-empty">No pools found.</p>
{:else}
<ul class="picker-list">
{#each filteredPools as pool (pool.id)}
<li>
<button class="picker-item" onclick={() => pool.id && addToPool(pool.id)}>
<span class="picker-item-name">{pool.name}</span>
<span class="picker-item-count">{pool.file_count ?? 0} files</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
{#if confirmDeleteFiles}
<ConfirmDialog
message={`Move ${$selectionStore.ids.size} file(s) to trash?`}
confirmLabel="Move to trash"
danger
onConfirm={async () => {
const ids = [...$selectionStore.ids];
confirmDeleteFiles = false;
selectionStore.exit();
try {
await api.post('/files/bulk/delete', { file_ids: ids });
files = files.filter((f) => !ids.includes(f.id ?? ''));
} catch {
// silently ignore — file list already updated optimistically
}
}}
onCancel={() => (confirmDeleteFiles = false)}
/>
{/if}
<style>
.page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Full-screen overlay covering the grid and the bottom navbar (z 100). */
.viewer-overlay {
position: fixed;
inset: 0;
z-index: 200;
background-color: var(--color-bg-primary);
overflow-y: auto;
overscroll-behavior: contain;
}
main {
flex: 1;
overflow-y: auto;
padding: 10px 10px calc(60px + 10px); /* clear fixed navbar */
}
.grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-content: flex-start;
align-items: flex-start;
gap: 2px;
}
/* phantom last item so justify-content doesn't stretch final row */
.grid::after {
content: '';
flex: auto;
}
.error {
color: var(--color-danger);
padding: 12px;
font-size: 0.875rem;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 60px 20px;
font-size: 0.95rem;
}
/* ---- Tag editor sheet ---- */
.tag-sheet {
max-height: 80dvh;
}
.tag-sheet-body {
padding: 0 14px 16px;
overflow-y: auto;
flex: 1;
}
/* ---- Pool picker ---- */
.picker-backdrop {
position: fixed;
inset: 0;
z-index: 110;
background: rgba(0, 0, 0, 0.5);
}
.picker-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 111;
background-color: var(--color-bg-secondary);
border-radius: 14px 14px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
max-height: 70dvh;
display: flex;
flex-direction: column;
animation: slide-up 0.18s ease-out;
}
@keyframes slide-up {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.picker-header {
display: flex;
align-items: center;
padding: 14px 16px 10px;
gap: 8px;
}
.picker-title {
flex: 1;
font-size: 0.95rem;
font-weight: 600;
}
.picker-close {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
display: flex;
align-items: center;
}
.picker-close:hover {
color: var(--color-text-primary);
}
.picker-search-wrap {
padding: 0 14px 10px;
}
.picker-search {
width: 100%;
box-sizing: border-box;
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
.picker-search:focus {
border-color: var(--color-accent);
}
.picker-list {
list-style: none;
margin: 0;
padding: 0 8px 12px;
overflow-y: auto;
flex: 1;
}
.picker-item {
display: flex;
align-items: center;
width: 100%;
text-align: left;
padding: 11px 10px;
border-radius: 8px;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
gap: 8px;
}
.picker-item:hover {
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.picker-item-name {
flex: 1;
font-size: 0.95rem;
color: var(--color-text-primary);
}
.picker-item-count {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.picker-empty,
.picker-error {
text-align: center;
padding: 20px;
font-size: 0.9rem;
color: var(--color-text-muted);
}
.picker-error {
color: var(--color-danger);
}
</style>