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 {
|
export interface FilterState {
|
||||||
section: Section;
|
section: Section;
|
||||||
/** Heap UID, used when section === 'heap'. */
|
/** Heap UID, used when section === 'heap'. */
|
||||||
@@ -49,6 +68,14 @@ export interface FilterState {
|
|||||||
folderPath: string | null;
|
folderPath: string | null;
|
||||||
/** Free-form search text, ANDed with section-derived terms. */
|
/** Free-form search text, ANDed with section-derived terms. */
|
||||||
search: string;
|
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
|
* Active tag-browser category and selected value. Set by the
|
||||||
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
|
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
|
||||||
@@ -68,10 +95,42 @@ export const filters = $state<FilterState>({
|
|||||||
heapUid: null,
|
heapUid: null,
|
||||||
folderPath: '/',
|
folderPath: '/',
|
||||||
search: '',
|
search: '',
|
||||||
|
sort: 'newest',
|
||||||
|
mediaType: null,
|
||||||
|
year: null,
|
||||||
|
favorite: false,
|
||||||
tagCategory: null,
|
tagCategory: null,
|
||||||
tagValue: 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 {
|
export function setSection(section: Section, heapUid: string | null = null): void {
|
||||||
filters.section = section;
|
filters.section = section;
|
||||||
filters.heapUid = section === 'heap' ? heapUid : null;
|
filters.heapUid = section === 'heap' ? heapUid : null;
|
||||||
@@ -250,6 +309,12 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
|
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));
|
if (f.search) parts.push(quoteIfNeeded(f.search));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
@@ -272,11 +337,22 @@ export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
|||||||
!params.has('q');
|
!params.has('q');
|
||||||
const folderRaw = params.get('folder');
|
const folderRaw = params.get('folder');
|
||||||
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
|
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
|
||||||
|
const sortRaw = params.get('sort');
|
||||||
|
const typeRaw = params.get('type');
|
||||||
|
const yearRaw = params.get('year');
|
||||||
return {
|
return {
|
||||||
section,
|
section,
|
||||||
heapUid: params.get('heap'),
|
heapUid: params.get('heap'),
|
||||||
folderPath,
|
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.heapUid) params.set('heap', f.heapUid);
|
||||||
if (f.folderPath) params.set('folder', f.folderPath);
|
if (f.folderPath) params.set('folder', f.folderPath);
|
||||||
if (f.search) params.set('q', f.search);
|
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;
|
return params;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,26 @@
|
|||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
} from "$lib/services/photoprism";
|
} from "$lib/services/photoprism";
|
||||||
import {
|
import {
|
||||||
|
chipsActive,
|
||||||
|
clearChips,
|
||||||
consumePendingFocus,
|
consumePendingFocus,
|
||||||
filters,
|
filters,
|
||||||
filtersToQ,
|
filtersToQ,
|
||||||
filtersToUrlParams,
|
filtersToUrlParams,
|
||||||
|
MEDIA_TYPE_LABELS,
|
||||||
|
MEDIA_TYPES,
|
||||||
parseUrlParams,
|
parseUrlParams,
|
||||||
|
setFavorite,
|
||||||
|
setMediaType,
|
||||||
setSearch,
|
setSearch,
|
||||||
setSection,
|
setSection,
|
||||||
|
setSort,
|
||||||
|
setYear,
|
||||||
|
SORT_LABELS,
|
||||||
|
SORT_ORDERS,
|
||||||
|
type MediaType,
|
||||||
type PendingFocus,
|
type PendingFocus,
|
||||||
|
type SortOrder,
|
||||||
} from "$lib/stores/filters.svelte";
|
} from "$lib/stores/filters.svelte";
|
||||||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
@@ -81,6 +93,10 @@
|
|||||||
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
||||||
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
||||||
if (next.search !== undefined) filters.search = next.search;
|
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
|
// When the store changes from in-app actions (left-sidebar nav, search
|
||||||
@@ -182,15 +198,20 @@
|
|||||||
"photos",
|
"photos",
|
||||||
"q",
|
"q",
|
||||||
filtersToQ(filters),
|
filtersToQ(filters),
|
||||||
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
|
{
|
||||||
|
count: PHOTOS_PAGE_SIZE,
|
||||||
|
anchor: anchor?.takenAt ?? null,
|
||||||
|
sort: filters.sort,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
queryFn: ({ pageParam }) => {
|
queryFn: ({ pageParam }) => {
|
||||||
const offset = pageParam as number;
|
const offset = pageParam as number;
|
||||||
const baseQ = filtersToQ(filters);
|
const baseQ = filtersToQ(filters);
|
||||||
// Page 0 + anchor → load a window around the anchor's date.
|
// Page 0 + anchor → load a window around the anchor's date.
|
||||||
// Subsequent pages aren't reachable in anchor mode (see
|
// Subsequent pages aren't reachable in anchor mode (see
|
||||||
// getNextPageParam).
|
// getNextPageParam). Anchor windows assume chronological order,
|
||||||
if (offset === 0 && anchor?.takenAt) {
|
// so any non-default sort falls back to plain paging.
|
||||||
|
if (offset === 0 && anchor?.takenAt && filters.sort === "newest") {
|
||||||
return listPhotosAround({
|
return listPhotosAround({
|
||||||
q: baseQ,
|
q: baseQ,
|
||||||
takenAt: anchor.takenAt,
|
takenAt: anchor.takenAt,
|
||||||
@@ -203,7 +224,7 @@
|
|||||||
q: baseQ,
|
q: baseQ,
|
||||||
count: PHOTOS_PAGE_SIZE,
|
count: PHOTOS_PAGE_SIZE,
|
||||||
offset,
|
offset,
|
||||||
order: "newest",
|
order: filters.sort,
|
||||||
merged: true,
|
merged: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -222,7 +243,7 @@
|
|||||||
// photos exceeding the page size keep the cursor at the same
|
// photos exceeding the page size keep the cursor at the same
|
||||||
// value). To "see more," the user clears the anchor by
|
// value). To "see more," the user clears the anchor by
|
||||||
// navigating fresh.
|
// navigating fresh.
|
||||||
if (anchor?.takenAt) return undefined;
|
if (anchor?.takenAt && filters.sort === "newest") return undefined;
|
||||||
return pages.length * PHOTOS_PAGE_SIZE;
|
return pages.length * PHOTOS_PAGE_SIZE;
|
||||||
},
|
},
|
||||||
enabled: isAuthenticated(),
|
enabled: isAuthenticated(),
|
||||||
@@ -800,6 +821,16 @@
|
|||||||
setSearch(searchDraft.trim());
|
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
|
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
|
||||||
// focus turns the placeholder hint into a clickable cheat-sheet.
|
// focus turns the placeholder hint into a clickable cheat-sheet.
|
||||||
const SEARCH_EXAMPLES = [
|
const SEARCH_EXAMPLES = [
|
||||||
@@ -899,6 +930,68 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</form>
|
</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()}
|
{#snippet trailing()}
|
||||||
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
|
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
|
||||||
Persisted to localStorage via view.svelte.ts. -->
|
Persisted to localStorage via view.svelte.ts. -->
|
||||||
|
|||||||
@@ -82,6 +82,10 @@
|
|||||||
heapUid: null,
|
heapUid: null,
|
||||||
folderPath: null,
|
folderPath: null,
|
||||||
search: '',
|
search: '',
|
||||||
|
sort: 'newest',
|
||||||
|
mediaType: null,
|
||||||
|
year: null,
|
||||||
|
favorite: false,
|
||||||
tagCategory: category,
|
tagCategory: category,
|
||||||
tagValue: selectedValue
|
tagValue: selectedValue
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user