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:
parent
e72d4822e9
commit
27d8215a0a
258
frontend/src/lib/components/file/FilterBar.svelte
Normal file
258
frontend/src/lib/components/file/FilterBar.svelte
Normal 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>
|
||||||
120
frontend/src/lib/components/layout/Header.svelte
Normal file
120
frontend/src/lib/components/layout/Header.svelte
Normal 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>
|
||||||
44
frontend/src/lib/stores/sorting.ts
Normal file
44
frontend/src/lib/stores/sorting.ts
Normal 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',
|
||||||
|
});
|
||||||
39
frontend/src/lib/utils/dsl.ts
Normal file
39
frontend/src/lib/utils/dsl.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,17 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
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';
|
||||||
|
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 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';
|
import type { File, FileCursorPage } from '$lib/api/types';
|
||||||
|
|
||||||
const LIMIT = 50;
|
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 files = $state<File[]>([]);
|
||||||
let nextCursor = $state<string | null>(null);
|
let nextCursor = $state<string | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let hasMore = $state(true);
|
let hasMore = $state(true);
|
||||||
let error = $state('');
|
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() {
|
async function loadMore() {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore) return;
|
||||||
@ -19,13 +54,18 @@
|
|||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
try {
|
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 (nextCursor) params.set('cursor', nextCursor);
|
||||||
|
if (filterParam) params.set('filter', filterParam);
|
||||||
|
|
||||||
const page = await api.get<FileCursorPage>(`/files?${params}`);
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
files = [...files, ...(page.items ?? [])];
|
files = [...files, ...(res.items ?? [])];
|
||||||
nextCursor = page.next_cursor ?? null;
|
nextCursor = res.next_cursor ?? null;
|
||||||
hasMore = !!page.next_cursor;
|
hasMore = !!res.next_cursor;
|
||||||
} 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;
|
||||||
@ -33,6 +73,17 @@
|
|||||||
loading = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@ -40,6 +91,24 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<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>
|
<main>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
|
|||||||
@ -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 {
|
export function mockApiPlugin(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'mock-api',
|
name: 'mock-api',
|
||||||
@ -166,7 +174,7 @@ export function mockApiPlugin(): Plugin {
|
|||||||
|
|
||||||
// GET /tags
|
// GET /tags
|
||||||
if (method === 'GET' && path === '/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
|
// GET /categories
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user