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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
heapUid: null,
|
||||
folderPath: null,
|
||||
search: '',
|
||||
sort: 'newest',
|
||||
mediaType: null,
|
||||
year: null,
|
||||
favorite: false,
|
||||
tagCategory: category,
|
||||
tagValue: selectedValue
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user