From 27d8215a0a4a8fcef93b56966a15a922b2b8d1b5 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sun, 5 Apr 2026 12:47:18 +0300 Subject: [PATCH] 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 --- .../src/lib/components/file/FilterBar.svelte | 258 ++++++++++++++++++ .../src/lib/components/layout/Header.svelte | 120 ++++++++ frontend/src/lib/stores/sorting.ts | 44 +++ frontend/src/lib/utils/dsl.ts | 39 +++ frontend/src/routes/files/+page.svelte | 79 +++++- frontend/vite-mock-plugin.ts | 10 +- 6 files changed, 544 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/components/file/FilterBar.svelte create mode 100644 frontend/src/lib/components/layout/Header.svelte create mode 100644 frontend/src/lib/stores/sorting.ts create mode 100644 frontend/src/lib/utils/dsl.ts diff --git a/frontend/src/lib/components/file/FilterBar.svelte b/frontend/src/lib/components/file/FilterBar.svelte new file mode 100644 index 0000000..77c9adc --- /dev/null +++ b/frontend/src/lib/components/file/FilterBar.svelte @@ -0,0 +1,258 @@ + + +
+ +
+ {#if tokens.length === 0} + No filter — tap a tag or operator below to build one + {:else} + {#each tokens as token, i (i)} + + {/each} + {/if} +
+ + +
+ {#each OPERATORS as op} + + {/each} +
+ + + + + +
+ {#each filteredTags as tag (tag.id)} + + {:else} + {search ? 'No matching tags' : 'No tags yet'} + {/each} +
+ + +
+ + + +
+
+ + \ No newline at end of file diff --git a/frontend/src/lib/components/layout/Header.svelte b/frontend/src/lib/components/layout/Header.svelte new file mode 100644 index 0000000..9635480 --- /dev/null +++ b/frontend/src/lib/components/layout/Header.svelte @@ -0,0 +1,120 @@ + + +
+
+ + + + + +
+
+ + \ No newline at end of file diff --git a/frontend/src/lib/stores/sorting.ts b/frontend/src/lib/stores/sorting.ts new file mode 100644 index 0000000..41d2d55 --- /dev/null +++ b/frontend/src/lib/stores/sorting.ts @@ -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 { + sort: F; + order: SortOrder; +} + +function makeSortStore(key: string, defaults: SortState) { + const stored = browser ? localStorage.getItem(key) : null; + const initial: SortState = stored ? (JSON.parse(stored) as SortState) : defaults; + const store = writable>(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('sort:files', { + sort: 'created', + order: 'desc', +}); + +export const tagSorting = makeSortStore('sort:tags', { + sort: 'created', + order: 'desc', +}); \ No newline at end of file diff --git a/frontend/src/lib/utils/dsl.ts b/frontend/src/lib/utils/dsl.ts new file mode 100644 index 0000000..b4f9c49 --- /dev/null +++ b/frontend/src/lib/utils/dsl.ts @@ -0,0 +1,39 @@ +/** + * Filter DSL utilities. + * + * Token format (comma-separated inside braces): + * t= — has tag + * m= — exact MIME + * m~ — 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 { + 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; +} \ No newline at end of file diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index 3ff67ce..4f7f6cc 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -1,17 +1,52 @@ @@ -40,6 +91,24 @@
+
0 || filterOpen} + onSortChange={(s) => fileSorting.setSort(s as FileSortField)} + onOrderToggle={() => fileSorting.toggleOrder()} + onFilterToggle={() => (filterOpen = !filterOpen)} + /> + + {#if filterOpen} + (filterOpen = false)} + /> + {/if} +
{#if error} diff --git a/frontend/vite-mock-plugin.ts b/frontend/vite-mock-plugin.ts index 92cfede..14d5c0c 100644 --- a/frontend/vite-mock-plugin.ts +++ b/frontend/vite-mock-plugin.ts @@ -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