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

@@ -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
})