From 4f04c1f7b0c8e5d8eaa6a12e9072282c120296d4 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 3 Jul 2026 13:19:47 +0200 Subject: [PATCH] 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 --- web/src/lib/stores/filters.svelte.ts | 82 +++++++++++++- web/src/routes/+page.svelte | 103 +++++++++++++++++- .../tags/[category]/[[value]]/+page.svelte | 4 + 3 files changed, 183 insertions(+), 6 deletions(-) diff --git a/web/src/lib/stores/filters.svelte.ts b/web/src/lib/stores/filters.svelte.ts index 7929df1..36d8a9a 100644 --- a/web/src/lib/stores/filters.svelte.ts +++ b/web/src/lib/stores/filters.svelte.ts @@ -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 = { + 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 = { + 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:`. */ + 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({ 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 { !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; } diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index fde817c..c160bf4 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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} + +
+ + + + + {#if chipsActive(filters)} + + {/if} +
+ {#snippet trailing()} diff --git a/web/src/routes/tags/[category]/[[value]]/+page.svelte b/web/src/routes/tags/[category]/[[value]]/+page.svelte index 592ae8a..1cb9655 100644 --- a/web/src/routes/tags/[category]/[[value]]/+page.svelte +++ b/web/src/routes/tags/[category]/[[value]]/+page.svelte @@ -82,6 +82,10 @@ heapUid: null, folderPath: null, search: '', + sort: 'newest', + mediaType: null, + year: null, + favorite: false, tagCategory: category, tagValue: selectedValue })