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:
Masahiko AMANO 2026-04-05 12:47:18 +03:00
parent e72d4822e9
commit 27d8215a0a
6 changed files with 544 additions and 6 deletions

View File

@ -0,0 +1,258 @@
<script lang="ts">
import { api } from '$lib/api/client';
import type { Tag, TagOffsetPage } from '$lib/api/types';
import { buildDslFilter, parseDslFilter, tokenLabel } from '$lib/utils/dsl';
interface Props {
/** Current DSL filter string (e.g. "{t=uuid1,&,t=uuid2}"). */
value?: string | null;
onApply: (filter: string | null) => void;
onClose: () => void;
}
let { value = null, onApply, onClose }: Props = $props();
const OPERATORS = ['(', ')', '&', '|', '!'] as const;
let tags = $state<Tag[]>([]);
let search = $state('');
let tokens = $state<string[]>(parseDslFilter(value));
let tagNames = $derived(new Map(tags.filter((t) => t.id && t.name).map((t) => [t.id as string, t.name as string])));
$effect(() => {
tokens = parseDslFilter(value ?? null);
});
$effect(() => {
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((page) => {
tags = page.items ?? [];
});
});
let filteredTags = $derived(
search.trim()
? tags.filter((t) => t.name?.toLowerCase().includes(search.toLowerCase()))
: tags,
);
function addToken(t: string) {
tokens = [...tokens, t];
}
function removeToken(i: number) {
tokens = tokens.filter((_, idx) => idx !== i);
}
function apply() {
onApply(buildDslFilter(tokens));
}
function reset() {
tokens = [];
search = '';
onApply(null);
}
</script>
<div class="bar">
<!-- Active tokens -->
<div class="active" class:empty={tokens.length === 0}>
{#if tokens.length === 0}
<span class="hint">No filter — tap a tag or operator below to build one</span>
{:else}
{#each tokens as token, i (i)}
<button class="token active-token" onclick={() => removeToken(i)} title="Remove">
{tokenLabel(token, tagNames)}
</button>
{/each}
{/if}
</div>
<!-- Operator buttons -->
<div class="ops">
{#each OPERATORS as op}
<button class="token op-token" onclick={() => addToken(op)}>{op}</button>
{/each}
</div>
<!-- Tag search -->
<input
class="search"
type="search"
placeholder="Search tags…"
bind:value={search}
autocomplete="off"
/>
<!-- Tag list -->
<div class="tag-list">
{#each filteredTags as tag (tag.id)}
<button
class="token tag-token"
style="background-color: {tag.color ? '#' + tag.color : tag.category_color ? '#' + tag.category_color : 'var(--color-tag-default)'}"
onclick={() => addToken(`t=${tag.id}`)}
>
{tag.name}
</button>
{:else}
<span class="no-tags">{search ? 'No matching tags' : 'No tags yet'}</span>
{/each}
</div>
<!-- Actions -->
<div class="actions">
<button class="btn btn-reset" onclick={reset}>Reset</button>
<button class="btn btn-apply" onclick={apply}>Apply</button>
<button class="btn btn-close" onclick={onClose}>Close</button>
</div>
</div>
<style>
.bar {
background-color: var(--color-bg-elevated);
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.active {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 32px;
align-items: center;
}
.active.empty {
opacity: 0.5;
}
.hint {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.ops {
display: flex;
gap: 4px;
}
.token {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 8px;
border-radius: 5px;
font-size: 0.8rem;
cursor: pointer;
border: none;
font-family: inherit;
}
.active-token {
background-color: var(--color-accent);
color: var(--color-bg-primary);
font-weight: 600;
}
.active-token:hover {
background-color: var(--color-accent-hover);
}
.op-token {
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-bg-elevated));
color: var(--color-text-primary);
font-weight: 700;
min-width: 30px;
justify-content: center;
}
.op-token:hover {
background-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-bg-elevated));
}
.search {
width: 100%;
box-sizing: border-box;
height: 30px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.85rem;
font-family: inherit;
outline: none;
}
.search:focus {
border-color: var(--color-accent);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-height: 120px;
overflow-y: auto;
}
.tag-token {
color: rgba(255, 255, 255, 0.9);
}
.tag-token:hover {
filter: brightness(1.15);
}
.no-tags {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.btn {
height: 30px;
padding: 0 14px;
border-radius: 6px;
border: none;
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
}
.btn-apply {
background-color: var(--color-accent);
color: var(--color-bg-primary);
font-weight: 600;
}
.btn-apply:hover {
background-color: var(--color-accent-hover);
}
.btn-reset {
background-color: color-mix(in srgb, var(--color-danger) 20%, var(--color-bg-elevated));
color: var(--color-text-primary);
}
.btn-reset:hover {
background-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-bg-elevated));
}
.btn-close {
background-color: color-mix(in srgb, var(--color-accent) 15%, var(--color-bg-elevated));
color: var(--color-text-muted);
}
.btn-close:hover {
color: var(--color-text-primary);
}
</style>

View File

@ -0,0 +1,120 @@
<script lang="ts">
import type { FileSortField, SortOrder } from '$lib/stores/sorting';
interface Props {
sortOptions: { value: string; label: string }[];
sort: string;
order: SortOrder;
filterActive?: boolean;
onSortChange: (sort: string) => void;
onOrderToggle: () => void;
onFilterToggle: () => void;
}
let {
sortOptions,
sort,
order,
filterActive = false,
onSortChange,
onOrderToggle,
onFilterToggle,
}: Props = $props();
</script>
<header>
<div class="controls">
<select
class="sort-select"
value={sort}
onchange={(e) => onSortChange((e.currentTarget as HTMLSelectElement).value)}
>
{#each sortOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<button class="icon-btn order-btn" onclick={onOrderToggle} title={order === 'asc' ? 'Ascending' : 'Descending'}>
{#if order === 'asc'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 10L8 6L12 10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{/if}
</button>
<button
class="icon-btn filter-btn"
class:active={filterActive}
onclick={onFilterToggle}
title="Filter"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 4h12M4 8h8M6 12h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
</div>
</header>
<style>
header {
display: flex;
align-items: center;
padding: 6px 10px;
background-color: var(--color-bg-primary);
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
gap: 6px;
flex-shrink: 0;
}
.controls {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.sort-select {
height: 30px;
padding: 0 8px;
border-radius: 6px;
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.85rem;
font-family: inherit;
cursor: pointer;
outline: none;
}
.sort-select:focus {
border-color: var(--color-accent);
}
.icon-btn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-muted);
cursor: pointer;
}
.icon-btn:hover {
color: var(--color-text-primary);
border-color: var(--color-accent);
}
.icon-btn.active {
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
color: var(--color-accent);
border-color: var(--color-accent);
}
</style>

View File

@ -0,0 +1,44 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export type FileSortField = 'content_datetime' | 'created' | 'original_name' | 'mime';
export type TagSortField = 'name' | 'color' | 'category_name' | 'created';
export type SortOrder = 'asc' | 'desc';
export interface SortState<F extends string> {
sort: F;
order: SortOrder;
}
function makeSortStore<F extends string>(key: string, defaults: SortState<F>) {
const stored = browser ? localStorage.getItem(key) : null;
const initial: SortState<F> = stored ? (JSON.parse(stored) as SortState<F>) : defaults;
const store = writable<SortState<F>>(initial);
store.subscribe((v) => {
if (browser) localStorage.setItem(key, JSON.stringify(v));
});
return {
subscribe: store.subscribe,
setSort(sort: F) {
store.update((s) => ({ ...s, sort }));
},
setOrder(order: SortOrder) {
store.update((s) => ({ ...s, order }));
},
toggleOrder() {
store.update((s) => ({ ...s, order: s.order === 'asc' ? 'desc' : 'asc' }));
},
};
}
export const fileSorting = makeSortStore<FileSortField>('sort:files', {
sort: 'created',
order: 'desc',
});
export const tagSorting = makeSortStore<TagSortField>('sort:tags', {
sort: 'created',
order: 'desc',
});

View File

@ -0,0 +1,39 @@
/**
* Filter DSL utilities.
*
* Token format (comma-separated inside braces):
* t=<uuid> has tag
* m=<mime> exact MIME
* m~<pattern> MIME LIKE pattern
* ( ) & | ! grouping / boolean operators
*
* Example: {t=uuid1,&,!,t=uuid2} has tag1 AND NOT tag2
*/
/** Build the filter query string value from an ordered token list. */
export function buildDslFilter(tokens: string[]): string | null {
if (tokens.length === 0) return null;
return '{' + tokens.join(',') + '}';
}
/** Parse the filter query string value back into a token list. */
export function parseDslFilter(value: string | null): string[] {
if (!value) return [];
const inner = value.replace(/^\{/, '').replace(/\}$/, '').trim();
if (!inner) return [];
return inner.split(',');
}
/** Return a human-readable label for a single DSL token (for display). */
export function tokenLabel(token: string, tagNames: Map<string, string>): string {
if (token === '&') return 'AND';
if (token === '|') return 'OR';
if (token === '!') return 'NOT';
if (token === '(') return '(';
if (token === ')') return ')';
if (token.startsWith('t=')) {
const id = token.slice(2);
return tagNames.get(id) ?? token;
}
return token;
}

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>

View File

@ -87,6 +87,14 @@ const MOCK_FILES = Array.from({ length: 75 }, (_, i) => {
};
});
const MOCK_TAGS = [
{ id: '00000000-0000-7000-8001-000000000001', name: 'nature', color: '7ECBA1', category_id: null, category_name: null, category_color: null, created_at: new Date().toISOString() },
{ id: '00000000-0000-7000-8001-000000000002', name: 'portrait', color: '9592B5', category_id: null, category_name: null, category_color: null, created_at: new Date().toISOString() },
{ id: '00000000-0000-7000-8001-000000000003', name: 'travel', color: '4DC7ED', category_id: null, category_name: null, category_color: null, created_at: new Date().toISOString() },
{ id: '00000000-0000-7000-8001-000000000004', name: 'architecture', color: 'E08C5A', category_id: null, category_name: null, category_color: null, created_at: new Date().toISOString() },
{ id: '00000000-0000-7000-8001-000000000005', name: 'food', color: 'DB6060', category_id: null, category_name: null, category_color: null, created_at: new Date().toISOString() },
];
export function mockApiPlugin(): Plugin {
return {
name: 'mock-api',
@ -166,7 +174,7 @@ export function mockApiPlugin(): Plugin {
// GET /tags
if (method === 'GET' && path === '/tags') {
return json(res, 200, { items: [], total: 0, offset: 0, limit: 50 });
return json(res, 200, { items: MOCK_TAGS, total: MOCK_TAGS.length, offset: 0, limit: 200 });
}
// GET /categories