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:
@@ -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