feat(frontend): add header, filter bar, and sorting store for files page

- sorting.ts: per-section sort store (sort field + order) persisted to localStorage
- dsl.ts: build/parse DSL filter strings ({t=uuid,&,|,!,...})
- Header.svelte: sort dropdown, asc/desc toggle, filter toggle button
- FilterBar.svelte: tag token picker with operator buttons, search, apply/reset
- files/+page.svelte: wired header + filter bar, resets pagination on sort/filter change
- vite-mock-plugin.ts: added 5 mock tags for filter bar development

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 12:47:18 +03:00
parent e72d4822e9
commit 27d8215a0a
6 changed files with 544 additions and 6 deletions
+74 -5
View File
@@ -1,17 +1,52 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { ApiError } from '$lib/api/client';
import FileCard from '$lib/components/file/FileCard.svelte';
import FilterBar from '$lib/components/file/FilterBar.svelte';
import Header from '$lib/components/layout/Header.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
import { parseDslFilter } from '$lib/utils/dsl';
import type { File, FileCursorPage } from '$lib/api/types';
const LIMIT = 50;
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);
let loading = $state(false);
let hasMore = $state(true);
let error = $state('');
let filterOpen = $state(false);
// Derive current filter from URL ?filter= param
let filterParam = $derived($page.url.searchParams.get('filter'));
let activeTokens = $derived(parseDslFilter(filterParam));
// Track sort/order from store
let sortState = $derived($fileSorting);
// Reset + reload whenever sort or filter changes
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
let prevKey = $state('');
$effect(() => {
if (resetKey !== prevKey) {
prevKey = resetKey;
files = [];
nextCursor = null;
hasMore = true;
error = '';
}
});
async function loadMore() {
if (loading || !hasMore) return;
@@ -19,13 +54,18 @@
error = '';
try {
const params = new URLSearchParams({ limit: String(LIMIT) });
const params = new URLSearchParams({
limit: String(LIMIT),
sort: sortState.sort,
order: sortState.order,
});
if (nextCursor) params.set('cursor', nextCursor);
if (filterParam) params.set('filter', filterParam);
const page = await api.get<FileCursorPage>(`/files?${params}`);
files = [...files, ...(page.items ?? [])];
nextCursor = page.next_cursor ?? null;
hasMore = !!page.next_cursor;
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;
@@ -33,6 +73,17 @@
loading = false;
}
}
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;
}
</script>
<svelte:head>
@@ -40,6 +91,24 @@
</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)}
/>
{#if filterOpen}
<FilterBar
value={filterParam}
onApply={applyFilter}
onClose={() => (filterOpen = false)}
/>
{/if}
<main>
{#if error}
<p class="error" role="alert">{error}</p>