feat(web): toolbar filter chips (type/year/favorites) + timeline sort control

Chips compile into the existing q-DSL alongside search/folder/section
terms and persist to the URL. Sort (newest/oldest/added/name) threads
through the sidecar timeline's order param; deep-link anchor windows
stay newest-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:19:47 +02:00
parent 9ef1b4c2f9
commit 4f04c1f7b0
3 changed files with 183 additions and 6 deletions

View File

@@ -41,6 +41,25 @@ export function isTagCategory(v: unknown): v is TagCategory {
);
}
export type SortOrder = 'newest' | 'oldest' | 'added' | 'name';
export const SORT_ORDERS: readonly SortOrder[] = ['newest', 'oldest', 'added', 'name'] as const;
export const SORT_LABELS: Record<SortOrder, string> = {
newest: 'Newest first',
oldest: 'Oldest first',
added: 'Recently added',
name: 'File name'
};
/** Media-type chip values → PhotoPrism boolean q-DSL filters. */
export type MediaType = 'photo' | 'video' | 'raw' | 'live';
export const MEDIA_TYPES: readonly MediaType[] = ['photo', 'video', 'raw', 'live'] as const;
export const MEDIA_TYPE_LABELS: Record<MediaType, string> = {
photo: 'Photos',
video: 'Videos',
raw: 'RAW',
live: 'Live'
};
export interface FilterState {
section: Section;
/** Heap UID, used when section === 'heap'. */
@@ -49,6 +68,14 @@ export interface FilterState {
folderPath: string | null;
/** Free-form search text, ANDed with section-derived terms. */
search: string;
/** Timeline sort order. Maps straight onto PhotoPrism's `order` param. */
sort: SortOrder;
/** Media-type chip; null = any. */
mediaType: MediaType | null;
/** Year chip; null = any. Compiles to `year:<n>`. */
year: number | null;
/** Favorites-only chip. Compiles to `favorite:true`. */
favorite: boolean;
/**
* Active tag-browser category and selected value. Set by the
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
@@ -68,10 +95,42 @@ export const filters = $state<FilterState>({
heapUid: null,
folderPath: '/',
search: '',
sort: 'newest',
mediaType: null,
year: null,
favorite: false,
tagCategory: null,
tagValue: null
});
export function setSort(sort: SortOrder): void {
filters.sort = sort;
}
export function setMediaType(t: MediaType | null): void {
filters.mediaType = t;
}
export function setYear(y: number | null): void {
filters.year = y;
}
export function setFavorite(on: boolean): void {
filters.favorite = on;
}
/** True when any toolbar chip narrows the view (excludes sort — a sort
* isn't a filter). Drives the "Clear" affordance. */
export function chipsActive(f: FilterState = filters): boolean {
return f.mediaType !== null || f.year !== null || f.favorite;
}
export function clearChips(): void {
filters.mediaType = null;
filters.year = null;
filters.favorite = false;
}
export function setSection(section: Section, heapUid: string | null = null): void {
filters.section = section;
filters.heapUid = section === 'heap' ? heapUid : null;
@@ -250,6 +309,12 @@ export function filtersToQ(f: FilterState = filters): string {
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
}
}
// Toolbar chips. PhotoPrism's boolean media filters (`video:true`,
// `photo:true`, …) are the documented DSL forms; `year:` and
// `favorite:` are plain filters.
if (f.mediaType) parts.push(`${f.mediaType}:true`);
if (f.year) parts.push(`year:${f.year}`);
if (f.favorite) parts.push('favorite:true');
if (f.search) parts.push(quoteIfNeeded(f.search));
return parts.join(' ');
}
@@ -272,11 +337,22 @@ export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
!params.has('q');
const folderRaw = params.get('folder');
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
const sortRaw = params.get('sort');
const typeRaw = params.get('type');
const yearRaw = params.get('year');
return {
section,
heapUid: params.get('heap'),
folderPath,
search: params.get('q') ?? ''
search: params.get('q') ?? '',
sort: (SORT_ORDERS as readonly string[]).includes(sortRaw ?? '')
? (sortRaw as SortOrder)
: 'newest',
mediaType: (MEDIA_TYPES as readonly string[]).includes(typeRaw ?? '')
? (typeRaw as MediaType)
: null,
year: yearRaw && /^\d{4}$/.test(yearRaw) ? parseInt(yearRaw, 10) : null,
favorite: params.get('fav') === '1'
};
}
@@ -288,5 +364,9 @@ export function filtersToUrlParams(f: FilterState = filters): URLSearchParams {
if (f.heapUid) params.set('heap', f.heapUid);
if (f.folderPath) params.set('folder', f.folderPath);
if (f.search) params.set('q', f.search);
if (f.sort !== 'newest') params.set('sort', f.sort);
if (f.mediaType) params.set('type', f.mediaType);
if (f.year) params.set('year', String(f.year));
if (f.favorite) params.set('fav', '1');
return params;
}

View File

@@ -17,14 +17,26 @@
type PpAlbum,
} from "$lib/services/photoprism";
import {
chipsActive,
clearChips,
consumePendingFocus,
filters,
filtersToQ,
filtersToUrlParams,
MEDIA_TYPE_LABELS,
MEDIA_TYPES,
parseUrlParams,
setFavorite,
setMediaType,
setSearch,
setSection,
setSort,
setYear,
SORT_LABELS,
SORT_ORDERS,
type MediaType,
type PendingFocus,
type SortOrder,
} from "$lib/stores/filters.svelte";
import { isAuthenticated } from "$lib/stores/session.svelte";
import { untrack } from "svelte";
@@ -81,6 +93,10 @@
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
if (next.search !== undefined) filters.search = next.search;
if (next.sort !== undefined) filters.sort = next.sort;
if (next.mediaType !== undefined) filters.mediaType = next.mediaType;
if (next.year !== undefined) filters.year = next.year;
if (next.favorite !== undefined) filters.favorite = next.favorite;
});
// When the store changes from in-app actions (left-sidebar nav, search
@@ -182,15 +198,20 @@
"photos",
"q",
filtersToQ(filters),
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
{
count: PHOTOS_PAGE_SIZE,
anchor: anchor?.takenAt ?? null,
sort: filters.sort,
},
],
queryFn: ({ pageParam }) => {
const offset = pageParam as number;
const baseQ = filtersToQ(filters);
// Page 0 + anchor → load a window around the anchor's date.
// Subsequent pages aren't reachable in anchor mode (see
// getNextPageParam).
if (offset === 0 && anchor?.takenAt) {
// getNextPageParam). Anchor windows assume chronological order,
// so any non-default sort falls back to plain paging.
if (offset === 0 && anchor?.takenAt && filters.sort === "newest") {
return listPhotosAround({
q: baseQ,
takenAt: anchor.takenAt,
@@ -203,7 +224,7 @@
q: baseQ,
count: PHOTOS_PAGE_SIZE,
offset,
order: "newest",
order: filters.sort,
merged: true,
});
},
@@ -222,7 +243,7 @@
// photos exceeding the page size keep the cursor at the same
// value). To "see more," the user clears the anchor by
// navigating fresh.
if (anchor?.takenAt) return undefined;
if (anchor?.takenAt && filters.sort === "newest") return undefined;
return pages.length * PHOTOS_PAGE_SIZE;
},
enabled: isAuthenticated(),
@@ -800,6 +821,16 @@
setSearch(searchDraft.trim());
}
// Toolbar chips: years from the current year back to 1990 — static
// range keeps it dependency-free; PhotoPrism just returns an empty
// page for years with no photos.
const CHIP_YEARS = Array.from(
{ length: new Date().getFullYear() - 1989 },
(_, i) => new Date().getFullYear() - i,
);
const CHIP_SELECT_CLASS =
"rounded border border-input bg-background px-1.5 py-0.5 text-[11px] text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-ring";
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [
@@ -899,6 +930,68 @@
{/if}
</form>
<!-- Filter chips: sort / media type / year / favorites. Compile into
the same q-DSL the search box feeds, so they stack with search,
folders, and sections. URL-persisted for shareable views. -->
<div class="flex shrink-0 items-center gap-1" role="group" aria-label="Filters">
<select
class={CHIP_SELECT_CLASS}
value={filters.sort}
onchange={(e) => setSort(e.currentTarget.value as SortOrder)}
title="Sort order"
aria-label="Sort order"
>
{#each SORT_ORDERS as s (s)}
<option value={s}>{SORT_LABELS[s]}</option>
{/each}
</select>
<select
class={CHIP_SELECT_CLASS}
value={filters.mediaType ?? ""}
onchange={(e) => setMediaType((e.currentTarget.value || null) as MediaType | null)}
title="Media type"
aria-label="Media type"
>
<option value="">Any type</option>
{#each MEDIA_TYPES as t (t)}
<option value={t}>{MEDIA_TYPE_LABELS[t]}</option>
{/each}
</select>
<select
class={CHIP_SELECT_CLASS}
value={filters.year ? String(filters.year) : ""}
onchange={(e) => setYear(e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : null)}
title="Year"
aria-label="Year"
>
<option value="">Any year</option>
{#each CHIP_YEARS as y (y)}
<option value={String(y)}>{y}</option>
{/each}
</select>
<button
type="button"
class="rounded border px-1.5 py-0.5 text-[11px] transition-colors {filters.favorite
? 'border-red-400 bg-red-500/10 text-red-500'
: 'border-input text-muted-foreground hover:bg-accent'}"
aria-pressed={filters.favorite}
onclick={() => setFavorite(!filters.favorite)}
title="Favorites only (f toggles a photo's favorite)"
>
♥ Favorites
</button>
{#if chipsActive(filters)}
<button
type="button"
class="rounded px-1.5 py-0.5 text-[11px] text-muted-foreground underline-offset-2 hover:underline"
onclick={clearChips}
title="Clear type / year / favorites filters"
>
Clear
</button>
{/if}
</div>
{#snippet trailing()}
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
Persisted to localStorage via view.svelte.ts. -->

View File

@@ -82,6 +82,10 @@
heapUid: null,
folderPath: null,
search: '',
sort: 'newest',
mediaType: null,
year: null,
favorite: false,
tagCategory: category,
tagValue: selectedValue
})