preview: full-screen modal replaces inline split + tags route reorg

The old SplitGrid + InlinePreview pane is replaced by a full-screen
PreviewModal mounted once at the layout root. Open via Space on the
focused tile or double-click; close on Esc (or X / Space again).
Inside, PreviewPane renders the focused photo, RightSidebar carries
the metadata, BulkActionBar reuses the existing per-photo actions,
and PreviewCarousel windows ±50 thumbs around the focused index.

Selection contract matches the grid: plain click reduces, shift
extends the range, ⌘/Ctrl toggles, plain arrow drops the multi-
selection, shift-arrow extends. New clearBulkToFirst() helper makes
Esc / Clear collapse a bulk back to single-focus on its first member
before the next press fully dismisses (modal closes, grid clears
focus).

Tags route reorganised into /tags/[category]/[[value]] with its own
+layout and TagsBrowserSidebar; the old monolithic /tags/+page is
trimmed to a legacy redirect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 00:13:20 +02:00
parent 680fa90cbe
commit 0d5f380948
24 changed files with 1593 additions and 1068 deletions

View File

@@ -120,11 +120,11 @@
with the px-2 of Views/Heaps rows; +12px per nested level.
-->
<div
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class="group flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: {8 + depth * 12}px;"
style="padding-left: {4 + depth * 12}px;"
>
{#if hasChildren}
<button
@@ -136,10 +136,10 @@
>
{open ? '▾' : '▸'}
</button>
{:else if depth > 0}
{:else}
<!-- Spacer keeps childless siblings aligned with their chevroned
peers at nested depths. Skipped at depth 0 so root folders
left-align with the Views/Heaps rows. -->
peers at every depth, so labels share a common left edge
across the sidebar (folders, heaps, views, manage). -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<!--
@@ -148,8 +148,7 @@
zone right where the user's eye lands.
-->
<button
class="flex min-w-0 flex-1 items-center text-left"
class:px-1={hasChildren || depth > 0}
class="flex min-w-0 flex-1 items-center pl-1 text-left"
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path}
@@ -158,7 +157,7 @@
{#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>

View File

@@ -41,7 +41,9 @@
filters,
setFolderPath,
setSection,
type Section
TAG_CATEGORIES,
type Section,
type TagCategory
} from '$lib/stores/filters.svelte';
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
@@ -361,6 +363,59 @@
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
}
// Tags-submenu collapse state. Same dedicated-key pattern as `rootExpanded`
// above (keeping it out of `view.metadataSections`, which is reserved for
// the right-sidebar metadata panel). Defaults to collapsed so the sidebar
// doesn't grow on first paint.
const TAGS_OPEN_KEY = 'mule_tags_expanded';
let tagsExpanded = $state(loadTagsExpanded());
function loadTagsExpanded(): boolean {
if (!browser) return false;
const raw = localStorage.getItem(TAGS_OPEN_KEY);
return raw === '1';
}
function toggleTags() {
tagsExpanded = !tagsExpanded;
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
}
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
labels: 'Labels',
keywords: 'Keywords',
colors: 'Colors',
ratings: 'Ratings'
};
function tagCategoryCount(cat: TagCategory): number | undefined {
// Labels reads PhotoPrism's pre-computed distinct-label counter
// (`/api/v1/config` → count.labels), not the photo-count from
// `countPhotos('label:*')`. The photo-count returned 0 on libraries
// whose indexer hadn't surfaced labelled photos yet, leaving the
// badge silently empty; the precomputed counter is always present
// and reads as "how many labels you can pick from", matching the
// Keywords sub-row's distinct-count semantics.
if (cat === 'labels') return configQuery.data?.count?.labels;
if (cat === 'keywords') return keywordsQuery.data?.length;
if (cat === 'ratings') return ratingsCount;
return colorsCount;
}
function isTagCategoryActive(cat: TagCategory): boolean {
return page.url.pathname.startsWith(`/tags/${cat}`);
}
// Hover-prefetch for the expensive keywords aggregation. Same idea as
// the cross-folder duplicates pattern: the LeftSidebar's badge query is
// `enabled: false`, but we eagerly populate the cache on intent so the
// click into /tags/keywords lands on warm data.
function prefetchKeywords(): void {
void qc.prefetchQuery({
queryKey: ['photos', 'keywords'],
queryFn: aggregateKeywords,
staleTime: 5 * 60_000
});
}
const rootActive = $derived(filters.folderPath === '/');
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
@@ -510,24 +565,24 @@
// Map's `geoQuery` already returns the GeoJSON the user is
// permitted to see (PhotoPrism's /geo applies the session ACL),
// so the badge is per-user-correct without extra scoping.
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length },
// Tags rolls up labels + keywords + ratings + colors. Labels
// flows through countPhotos (scoped); keywords/ratings/colors are
// from library-wide marks tables and only contribute when we're
// in admin-without-BasePath mode (their sources don't scope).
{
kind: 'route',
href: '/tags',
label: 'Tags',
getCount: () => {
if (labelsBadge === undefined) return undefined;
if (wantScoped) return labelsBadge;
const keywords = keywordsQuery.data?.length ?? 0;
return labelsBadge + keywords + ratingsCount + colorsCount;
}
}
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }
// Tags is rendered as a bespoke expandable block below the
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
// Ratings) and a chevron, neither of which fits the flat
// section/route ViewItem shape.
];
// Total badge for the "Tags" header row. Rolls up labels + keywords +
// ratings + colors. Labels flows through countPhotos (scoped); keywords/
// ratings/colors are library-wide marks tables and only contribute when
// we're in admin-without-BasePath mode (their sources don't scope).
const tagsTotal = $derived.by<number | undefined>(() => {
if (labelsBadge === undefined) return undefined;
if (wantScoped) return labelsBadge;
const keywords = keywordsQuery.data?.length ?? 0;
return labelsBadge + keywords + ratingsCount + colorsCount;
});
const manageViews: ViewItem[] = [
{
kind: 'route',
@@ -555,7 +610,7 @@
{@const count = v.getCount()}
{#if v.kind === 'section'}
<button
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
class="flex h-[22px] w-full items-center rounded pl-6 pr-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
@@ -564,7 +619,7 @@
<span class="truncate">{v.label}</span>
{#if count !== undefined}
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
@@ -575,7 +630,7 @@
{:else}
<a
href={v.href}
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
@@ -583,7 +638,7 @@
<span class="truncate">{v.label}</span>
{#if count !== undefined}
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
@@ -603,7 +658,7 @@
slightly faded even at scroll-bottom.
-->
<nav
class="flex-1 space-y-3 overflow-y-auto p-3"
class="flex-1 space-y-2 overflow-y-auto p-2"
style="mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent); -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);"
>
<!-- Folders — top of the sidebar because the root folder is the
@@ -612,7 +667,7 @@
hover-revealed actions on the header for library settings and
new-top-level-folder. -->
<div>
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
<div class="group/header flex items-center gap-0.5 px-2 pb-0.5">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library
</span>
@@ -641,11 +696,11 @@
below — same affordance as nested folder rows.
-->
<div
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class="group flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={rootActive}
class:text-primary-foreground={rootActive}
class:hover:bg-primary={rootActive}
style="padding-left: 8px;"
style="padding-left: 4px;"
>
{#if hasSubfolders}
<button
@@ -658,6 +713,10 @@
>
{rootExpanded ? '▾' : '▸'}
</button>
{:else}
<!-- Spacer keeps chevronless rows aligned with their chevroned
peers, so labels share a common left edge across the sidebar. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<!--
Count badge lives INSIDE the button so the entire row (label
@@ -666,15 +725,14 @@
-->
<button
type="button"
class="flex min-w-0 flex-1 items-center text-left"
class:px-1={hasSubfolders}
class="flex min-w-0 flex-1 items-center pl-1 text-left"
onclick={() => pickFolder('/')}
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
>
<span class="truncate">{rootLabel}</span>
{#if configQuery.data}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {rootActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
@@ -721,35 +779,8 @@
{/if}
</div>
<!-- Views — everyday browse entries (section + route mixed) under
a single uppercase eyebrow. Compact rows, no icons. -->
<div>
<div class="px-3 pb-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Views
</span>
</div>
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
</div>
<!-- Manage — curation flows that decide a photo's fate. Same
row shape as Views; grouped separately so the binary-decision
destinations (Review/Archive) don't crowd the browse list. -->
<div>
<div class="px-3 pb-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Manage
</span>
</div>
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
</div>
<div>
<div class="group/header flex items-center px-3 pb-1">
<div class="group/header flex items-center px-2 pb-0.5">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Heaps
</span>
@@ -781,20 +812,20 @@
open), pushing the button slightly left.
-->
<li
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class="group flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
>
<button
class="flex min-w-0 flex-1 items-center gap-2 px-2 text-left"
class="flex min-w-0 flex-1 items-center pl-6 text-left"
onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
>
<span class="truncate">{heap.Title}</span>
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
@@ -847,6 +878,89 @@
{/if}
</div>
<!-- Views — everyday browse entries (section + route mixed) under
a single uppercase eyebrow. Compact rows, no icons. -->
<div>
<div class="px-2 pb-0.5">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Views
</span>
</div>
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
<!--
Tags expandable. Whole row is a toggle (chevron + label + badge);
there is no landing page at /tags — selecting a sub-category is the
only way into a real view.
-->
<button
type="button"
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
style="padding-left: 4px;"
onclick={toggleTags}
title={tagsExpanded ? 'Collapse tags' : 'Expand tags'}
aria-expanded={tagsExpanded}
>
<span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
>
{tagsExpanded ? '▾' : '▸'}
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Tags</span>
{#if tagsTotal !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-secondary px-1 text-[10px] tabular-nums text-muted-foreground"
>
{tagsTotal}
</span>
{/if}
</span>
</button>
{#if tagsExpanded}
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
{@const count = tagCategoryCount(cat)}
<a
href={`/tags/${cat}`}
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: 36px;"
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
>
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
{#if count !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{count}
</span>
{/if}
</a>
{/each}
{/if}
</div>
<!-- Manage — curation flows that decide a photo's fate. Same
row shape as Views; grouped separately so the binary-decision
destinations (Review/Archive) don't crowd the browse list. -->
<div>
<div class="px-2 pb-0.5">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Manage
</span>
</div>
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
</div>
</nav>
<!--

View File

@@ -0,0 +1,159 @@
<!--
Bottom filmstrip for the full-screen PreviewModal. Walks the same
`selection.order` list the host view uses, so the user steps through
their current context (timeline, drill-in, etc.) without surprises.
Windowed: only renders a slice of ±WINDOW around the focused index, so
a 10k-photo timeline doesn't paint 10k thumbs. The slice shifts as
focus moves; the strip re-scrolls the focused tile into view on every
change.
Thumbnails are resolved from TanStack's cache so this surface stays
side-effect-free (no extra fetches just to draw a strip).
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { thumbUrl } from '$lib/stores/session.svelte';
import {
selectOnly,
selectRange,
selection,
setAnchor,
setFocused,
toggle
} from '$lib/stores/selection.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
const qc = useQueryClient();
/** Tiles either side of focus to render. ±50 = ~100 thumbs in DOM at
* any time, plenty to cover normal arrow-skim without bloating. */
const WINDOW = 50;
function lookup(uid: string): string | null {
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
if (direct) return primaryFile(direct).Hash ?? null;
const lists = qc.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
if (hit) return primaryFile(hit).Hash ?? null;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const page of pages) {
const hit = page?.find?.((p) => p.UID === uid);
if (hit) return primaryFile(hit).Hash ?? null;
}
}
return null;
}
const order = $derived(selection.order);
const focused = $derived(selection.focused);
const focusedIdx = $derived(focused ? order.indexOf(focused) : -1);
interface Tile {
uid: string;
hash: string | null;
idx: number;
}
const slice = $derived.by<Tile[]>(() => {
if (focusedIdx < 0) return [];
const lo = Math.max(0, focusedIdx - WINDOW);
const hi = Math.min(order.length, focusedIdx + WINDOW + 1);
const out: Tile[] = [];
for (let i = lo; i < hi; i++) {
out.push({ uid: order[i], hash: lookup(order[i]), idx: i });
}
return out;
});
// Scroll the focused tile into the centre of the strip whenever
// `focused` changes. We key the scroll on `data-uid` so the lookup
// survives slice re-renders.
let stripEl: HTMLDivElement | null = $state(null);
$effect(() => {
const uid = focused;
if (!uid || !stripEl) return;
// Defer until after the slice re-renders — without this, the
// querySelector on the freshly added tile element returns null.
queueMicrotask(() => {
const el = stripEl?.querySelector<HTMLElement>(`[data-strip-uid="${uid}"]`);
el?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
});
});
/**
* Modifier-aware click: shift extends the range from the anchor, ⌘/Ctrl
* toggles in/out of the multi-selection, plain click reduces to this
* tile. Same semantics as the grid's PhotoTile + gridKeyNav click
* handler, so the carousel reads as a continuation of the grid rather
* than a separate surface.
*/
function onTileClick(e: MouseEvent, uid: string) {
if (e.shiftKey) {
e.preventDefault();
selectRange(uid);
setFocused(uid);
return;
}
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
toggle(uid);
setFocused(uid);
return;
}
selectOnly(uid);
setFocused(uid);
setAnchor(uid);
}
</script>
<div
bind:this={stripEl}
class="flex h-20 shrink-0 items-center gap-1 overflow-x-auto overflow-y-hidden border-t border-border bg-background/80 px-2 py-1.5 backdrop-blur"
>
{#each slice as tile (tile.uid)}
{@const isFocused = tile.uid === focused}
{@const isSelected = isFocused || selection.ids.has(tile.uid)}
<!--
Mirror PhotoTile's selected styling so the focused tile in the
strip reads identically to a selected tile in the grid: the
springy scale-90, the blue ring with background offset, and
the blue tint overlay. Keeps the visual language consistent
as the user moves between grid and modal.
-->
<button
type="button"
data-strip-uid={tile.uid}
onclick={(e) => onTileClick(e, tile.uid)}
aria-label={`Photo ${tile.idx + 1} of ${order.length}`}
aria-current={isFocused ? 'true' : undefined}
class:scale-90={isSelected}
class:ring-2={isSelected}
class:ring-blue-500={isSelected}
class:ring-offset-2={isSelected}
class:ring-offset-background={isSelected}
class:transition-[transform,box-shadow]={isSelected}
class:duration-300={isSelected}
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={isSelected}
class="relative h-full aspect-square shrink-0 overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
{#if tile.hash}
<img
src={thumbUrl(tile.hash, 'tile_100')}
alt=""
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
/>
{/if}
{#if isSelected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
</button>
{/each}
</div>

View File

@@ -0,0 +1,168 @@
<!--
Full-screen preview modal. Mounted once at the layout root so every
route (timeline, /tags, future grids) can open it via Space or a
double-click on a tile. Reads from the global selection store:
- selection.focused → which photo to display
- selection.order → walked left/right + drives PreviewCarousel
Layout: preview pane (left, fills) + RightSidebar (right) + action
toolbar + thumbnail carousel along the bottom. Keyboard nav (←/→/Space/
Esc) is owned here; gridKeyNav bails out of those keys while the modal
is open so we don't double-handle. Action keys (X/S/U/⌘Z/etc.) continue
to flow through gridKeyNav since they target selection state, which
the modal shares.
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { createQuery } from '@tanstack/svelte-query';
import { X } from 'lucide-svelte';
import { closePreview, view } from '$lib/stores/view.svelte';
import {
clearBulkToFirst,
clearSelection,
selectRange,
selection,
setAnchor,
setFocused
} from '$lib/stores/selection.svelte';
import { getPhoto } from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
import PreviewPane from './PreviewPane.svelte';
import PreviewCarousel from './PreviewCarousel.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
const focusedUid = $derived(selection.focused);
// Fetch the focused photo so the RightSidebar always has full
// metadata even when the user jumped here from a list that only
// hydrated thumbnails.
const focusedPhotoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', focusedUid ?? ''],
queryFn: () => getPhoto(focusedUid as string),
enabled: Boolean(focusedUid) && isAuthenticated() && view.previewOpen
}));
function step(delta: number, extending: boolean) {
const order = selection.order;
const cur = focusedUid ? order.indexOf(focusedUid) : -1;
if (cur < 0) return;
const next = order[cur + delta];
if (!next) return;
// Plain arrow drops any prior multi-selection down to the cursor —
// otherwise the previously selected tiles keep their blue ring and
// the carousel reads as two simultaneously selected images (the old
// ones still in ids, plus the freshly focused one). Mirrors
// gridKeyNav.moveFocus's contract.
if (!extending) clearSelection();
setFocused(next);
if (extending) selectRange(next);
else setAnchor(next);
}
function onKeydown(e: KeyboardEvent) {
// Don't steal keys from inputs inside the sidebar (editable
// metadata fields, keyword chips, etc.).
const t = e.target as HTMLElement | null;
const tag = t?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) {
return;
}
// stopImmediatePropagation prevents gridKeyNav's window-level
// handler from firing afterwards — without it, closing on Space
// would set previewOpen=false and then gridKeyNav's Space branch
// would re-open the modal because selection.focused is still set.
if (e.key === 'Escape') {
e.preventDefault();
e.stopImmediatePropagation();
// First Esc collapses a bulk back to single-focus on its first
// member, keeping the modal open so the user can keep working.
// Second Esc (no bulk left) closes the modal.
if (clearBulkToFirst()) return;
closePreview();
return;
}
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
e.stopImmediatePropagation();
closePreview();
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
e.stopImmediatePropagation();
step(-1, e.shiftKey);
return;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
e.stopImmediatePropagation();
step(1, e.shiftKey);
}
}
// Bind a window-level handler only while the modal is open. The
// `capture` phase lets us pre-empt gridKeyNav's own listener for the
// keys we own (arrows, Esc, Space) without having to coordinate
// listener order.
$effect(() => {
if (!view.previewOpen) return;
const handler = (e: KeyboardEvent) => onKeydown(e);
window.addEventListener('keydown', handler, { capture: true });
return () => window.removeEventListener('keydown', handler, { capture: true } as EventListenerOptions);
});
</script>
<Dialog.Root
open={view.previewOpen}
onOpenChange={(o) => {
if (!o) closePreview();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/95 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed inset-0 z-50 flex flex-col overflow-hidden bg-background outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
>
<Dialog.Title class="sr-only">Photo preview</Dialog.Title>
<Dialog.Description class="sr-only">
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
</Dialog.Description>
<!-- Top row: preview pane (fills) + sidebar (fixed width). -->
<div class="flex min-h-0 flex-1">
<div class="relative flex min-w-0 flex-1 flex-col">
<button
type="button"
class="absolute right-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/80 text-foreground shadow hover:bg-background"
onclick={closePreview}
aria-label="Close preview"
title="Close (Esc)"
>
<X class="h-4 w-4" />
</button>
<div class="flex min-h-0 flex-1">
<PreviewPane uid={focusedUid} order={selection.order} />
</div>
</div>
{#if focusedPhotoQuery.data}
<aside
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
>
<RightSidebar photo={focusedPhotoQuery.data} />
</aside>
{/if}
</div>
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
<BulkActionBar />
<!-- Bottom filmstrip across selection.order. -->
<PreviewCarousel />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -1,34 +1,29 @@
<!--
Inline preview pane: replaces the old fullscreen `PreviewOverlay`.
Renders the focused photo or video inside its host pane so the grid
stays visible below. Prev/next move `selection.focused` directly so
the right metadata sidebar tracks in lockstep.
Inner preview surface used by the full-screen PreviewModal and the
shareable /photo/[uid] deep-link route. Renders the focused photo or
video, with optional prev/next chevrons that walk `order` via
setFocused.
Video mounting is debounced 250 ms so arrow-skim across a stretch of
video tiles doesn't open (and immediately cancel) range requests we'd
throw away. Until the timer fires, the poster image stands in.
-->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism';
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { selection, setAnchor, setFocused } from '$lib/stores/selection.svelte';
import SelectionDeck from '$lib/components/preview/SelectionDeck.svelte';
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
interface Props {
uid: string | null;
/** Ordered uid list for prev/next chevrons. The host page mirrors
* whatever list the user is currently looking at into this prop so
* navigation stays in context (timeline order, drill-in order, etc). */
order: string[];
/** Hide the floating prev/next chevrons (callers that wrap their
* own nav can disable to avoid duplication). */
showChevrons?: boolean;
}
let { uid, order }: Props = $props();
// Multi-select swaps the single-photo pane for a fanned deck of the
// selected thumbnails. Reads `selection.ids` directly (already in
// scope via the import) so the host doesn't need to thread it
// through as a prop. Spreading SvelteSet preserves insertion order,
// so the most recently picked card sits on top of the fan.
const selectedUids = $derived([...selection.ids]);
const multiSelect = $derived(selectedUids.length >= 2);
let { uid, order, showChevrons = true }: Props = $props();
const photoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', uid ?? ''],
@@ -38,20 +33,10 @@
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
// Debounce window before a focused video actually mounts <VideoPlayer>
// and opens an HTTP range request. Short enough that a deliberate
// click feels instant; long enough that arrow-skim across video tiles
// never opens (and immediately cancels) a stream we'd have thrown
// away anyway. The grid's thumbnail traffic is the thing this
// protects.
const VIDEO_LOAD_DELAY_MS = 250;
let armedUid = $state<string | null>(null);
$effect(() => {
// Re-arm on every uid change. Until the timer fires, the template
// renders the poster image instead of <VideoPlayer>, so no video
// fetch is issued. If uid changes again before the 250 ms is up,
// the cleanup clears the pending timer and the new one takes over.
const target = uid;
if (!target) {
armedUid = null;
@@ -71,10 +56,8 @@
}
</script>
<div class="relative flex h-full w-full items-center justify-center bg-black/30 p-4">
{#if multiSelect}
<SelectionDeck uids={selectedUids} />
{:else if uid === null}
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
{#if uid === null}
<p class="text-sm text-muted-foreground">Select a photo to preview.</p>
{:else if photoQuery.isPending}
<p class="text-sm text-muted-foreground">Loading…</p>
@@ -82,7 +65,7 @@
<p class="text-sm text-destructive">Failed to load photo.</p>
{:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)}
{#if currentIndex > 0}
{#if showChevrons && currentIndex > 0}
<button
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
onclick={() => focusAt(currentIndex - 1)}
@@ -91,7 +74,7 @@
</button>
{/if}
{#if currentIndex >= 0 && currentIndex < order.length - 1}
{#if showChevrons && currentIndex >= 0 && currentIndex < order.length - 1}
<button
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
onclick={() => focusAt(currentIndex + 1)}
@@ -104,10 +87,6 @@
{#if isVideo(photoQuery.data)}
{@const vf = videoFile(photoQuery.data)}
{#if armedUid === uid}
<!-- Key on the video hash so navigating to a new video remounts the
player. Without this the <media-player> element keeps the
previous src bound and `autoplay` doesn't re-fire — clicking
a video tile would leave the pane idle on its poster. -->
{#key vf.Hash}
<VideoPlayer
src={videoUrl(vf.Hash)}
@@ -116,9 +95,6 @@
/>
{/key}
{:else}
<!-- Debounce window: render the poster only. Matches the
still-photo branch's styling so the pane reads identically
until <VideoPlayer> arms in. -->
<img
src={thumbUrl(pf.Hash, 'fit_1280')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}

View File

@@ -1,112 +0,0 @@
<!--
Multi-select deck. Renders the selected thumbnails as a fanned spread
of cards in the inline preview pane. Each new selection flies onto
the deck; each removal shrinks out. Layout reflow on add/remove is
driven by CSS transforms transitioning between the recomputed fan
positions.
Hashes come from the per-photo TanStack cache (populated by the
timeline + the right-sidebar's getPhoto query); we walk the photos-
infinite-query cache as a fallback for photos the user selected
before the right sidebar had a chance to fetch them. Anything not
found is skipped silently — the deck just renders the resolvable
subset, which keeps this surface side-effect-free (no fetches just
to draw a thumbnail spread).
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { fly, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { thumbUrl } from '$lib/stores/session.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
interface Props {
uids: string[];
}
let { uids }: Props = $props();
const qc = useQueryClient();
/** Resolve a uid to its primary-file hash by walking TanStack's caches.
* Order: per-photo cache first (cheapest), then any infinite-query
* entry under `['photos', …]` — both shapes (flat list and Infinite-
* Data envelope) are handled because the timeline uses the envelope
* while pools/sidebars use the flat list. Mirrors the cachedPhoto
* helper in gridKeyNav.ts. */
function lookup(uid: string): string | null {
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
if (direct) return primaryFile(direct).Hash ?? null;
const lists = qc.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
if (hit) return primaryFile(hit).Hash ?? null;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const page of pages) {
const hit = page?.find?.((p) => p.UID === uid);
if (hit) return primaryFile(hit).Hash ?? null;
}
}
return null;
}
interface Card {
uid: string;
hash: string;
}
const cards = $derived<Card[]>(
uids
.map((uid) => {
const hash = lookup(uid);
return hash ? { uid, hash } : null;
})
.filter((c): c is Card => c !== null)
);
/** Fan geometry. The spread tightens as the deck grows so 20 cards
* still fit visually; small selections (24) get wide-stance spreads
* so each card reads as its own surface. The `mid`-relative offset
* keeps the deck centred regardless of count. */
function transform(i: number, n: number): string {
if (n <= 1) return 'translateX(0) rotate(0deg)';
const mid = (n - 1) / 2;
const off = i - mid;
// Tighter angle + offset as N grows; cap so very large selections
// don't degenerate to a single overlapping pile or, conversely,
// spread past the pane edges.
const angleStep = Math.min(9, 30 / n);
const transStep = Math.min(60, 220 / n);
const angle = off * angleStep;
const tx = off * transStep;
return `translateX(${tx}px) rotate(${angle}deg)`;
}
</script>
<div class="relative flex h-full w-full items-center justify-center overflow-hidden">
{#if cards.length === 0}
<p class="text-sm text-muted-foreground">Selection has no resolvable thumbnails yet.</p>
{/if}
{#each cards as card, i (card.uid)}
<img
src={thumbUrl(card.hash, 'fit_720')}
alt=""
class="absolute max-h-[80%] max-w-[60%] rounded-lg object-contain shadow-2xl ring-1 ring-black/40 transition-transform duration-300 ease-out"
style="transform: {transform(i, cards.length)}; z-index: {i};"
in:fly={{ y: -180, duration: 320, easing: cubicOut }}
out:scale={{ start: 0.5, duration: 220, easing: cubicOut }}
/>
{/each}
<!-- Count chip so the user can see at a glance how many photos are
in the bulk selection without counting cards. -->
{#if cards.length > 0}
<div
class="pointer-events-none absolute bottom-3 right-3 rounded-full bg-background/85 px-2.5 py-1 text-xs font-medium shadow"
>
{cards.length} selected
</div>
{/if}
</div>

View File

@@ -1,59 +0,0 @@
<!--
Vertical split layout: a top pane (preview) sized by
`view.previewPaneHeight`, a draggable divider, and a flex-1 bottom
pane (the grid). Mirrors the horizontal sidebar resize pattern.
Usage:
<SplitGrid>
{#snippet preview()}
<InlinePreview uid={selection.focused} order={selection.order} />
{/snippet}
{#snippet grid()}
<main>…</main>
{/snippet}
</SplitGrid>
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import { resizableVertical } from '$lib/actions/resizableVertical';
import { setPreviewPaneHeight, view } from '$lib/stores/view.svelte';
interface Props {
preview: Snippet;
grid: Snippet;
}
let { preview, grid }: Props = $props();
</script>
<div class="flex min-h-0 flex-1 flex-col">
<!-- Top: preview pane. Fixed pixel height controlled by the divider;
overflow-hidden so videos / images can't push the divider off-screen. -->
<div
class="relative shrink-0 overflow-hidden border-b border-border"
style="height: {view.previewPaneHeight}px;"
>
{@render preview()}
<!-- Divider sits on the bottom edge of the preview pane.
`edge: 'bottom'` matches the convention: positive dy = pane grows. -->
<div
class="group absolute -bottom-1.5 left-0 z-20 h-3 w-full cursor-row-resize"
use:resizableVertical={{
edge: 'bottom',
getHeight: () => view.previewPaneHeight,
setHeight: setPreviewPaneHeight
}}
role="separator"
aria-orientation="horizontal"
aria-label="Resize preview pane"
>
<div
class="mt-1 h-0.5 w-full bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</div>
<!-- Bottom: grid container; the caller's snippet handles its own scroll. -->
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
{@render grid()}
</div>
</div>

View File

@@ -106,10 +106,10 @@
:global(.vds-player) {
/* Let the player size to the video's intrinsic aspect ratio,
capped by the host pane. Don't set width/height to 100% — that
was stretching widescreen video into the square inline-preview
pane. The default vidstack layout reads the loaded media's
aspect and sizes the box accordingly; we just clamp the upper
bound so it can't escape the SplitGrid top pane. */
was stretching widescreen video into the square preview pane.
The default vidstack layout reads the loaded media's aspect
and sizes the box accordingly; we just clamp the upper bound
so it can't escape its host pane. */
max-width: 100%;
max-height: 100%;
--media-brand: #3b82f6;

View File

@@ -0,0 +1,409 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
aggregateKeywords,
getAllMarks,
listLabels,
listPhotos,
type AggregatedKeyword,
type PhotoMarksMap,
type PpLabel
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom';
import type { TagCategory } from '$lib/stores/filters.svelte';
import {
buildColorGroups,
buildRatingGroups,
COLOR_SWATCHES,
starLabel
} from '$lib/utils/tagGroups';
import type { PpPhoto } from '$lib/types/photoprism';
interface Props {
category: TagCategory;
selectedValue: string | null;
onSelect: (value: string | null, options?: { replace?: boolean }) => void;
}
const { category, selectedValue, onSelect }: Props = $props();
let filterText = $state('');
// Reset the inline filter input whenever the user switches categories so
// a leftover "sun" query from Labels doesn't silently hide every Keyword
// when they jump tabs.
$effect(() => {
category;
filterText = '';
});
// Queries are gated on the active category so opening the panel on
// Labels doesn't also pull the expensive keywords aggregation in the
// background. Cache keys match the LeftSidebar / legacy tags page so
// the fetches dedupe through TanStack.
const labelsQuery = createQuery<PpLabel[]>(() => ({
queryKey: ['labels'],
queryFn: listLabels,
enabled: isAuthenticated() && category === 'labels'
}));
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
queryKey: ['photos', 'keywords'],
queryFn: aggregateKeywords,
enabled: isAuthenticated() && category === 'keywords',
staleTime: 5 * 60_000
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated() && (category === 'ratings' || category === 'colors'),
staleTime: 60_000
}));
// Same marks-pool query the drill page uses — colors/ratings need a
// representative photo per bucket for the count rollup. Cheap once
// cached; the drill page kicks the same key.
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'marks-pool'],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated() && (category === 'ratings' || category === 'colors')
}));
// PhotoPrism returns labels in arbitrary order; sort by photo count
// descending so the most-used labels are surfaced first. Keywords from
// `aggregateKeywords()` already arrive count-sorted.
const labelsSorted = $derived(
[...(labelsQuery.data ?? [])].sort(
(a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0)
)
);
const filteredLabels = $derived.by(() => {
const q = filterText.trim().toLowerCase();
if (!q) return labelsSorted;
return labelsSorted.filter(
(l) =>
l.Name.toLowerCase().includes(q) ||
(l.CustomSlug ?? l.Slug).toLowerCase().includes(q)
);
});
const keywordsSorted = $derived<AggregatedKeyword[]>(keywordsQuery.data ?? []);
const filteredKeywords = $derived.by(() => {
const q = filterText.trim().toLowerCase();
if (!q) return keywordsSorted;
return keywordsSorted.filter((k) => k.keyword.toLowerCase().includes(q));
});
const ratingGroups = $derived(
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
);
const colorGroups = $derived(
buildColorGroups(marksQuery.data, marksPoolQuery.data)
);
// ── Infinite scroll (labels + keywords) ────────────────────────────────
// Chunked rendering instead of virtualization: simpler, robust against
// the panel mounting/unmounting on category change, and totally fine for
// the cardinalities we see (a few hundred to a few thousand). Each
// nearBottom hit grows the window by PAGE_SIZE; reset the window when
// the underlying list changes (new category, filter input change) so a
// 10k-item list doesn't render its full DOM after a long scroll session.
const PAGE_SIZE = 100;
let visibleCount = $state(PAGE_SIZE);
$effect(() => {
category;
filterText;
visibleCount = PAGE_SIZE;
});
let scrollEl: HTMLElement | undefined = $state();
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
function loadMore() {
visibleCount += PAGE_SIZE;
}
// Click handlers — always set the clicked value. Re-clicking the same
// row is a no-op (the auto-select-first effect below makes "clear"
// untenable: it would just re-pick the first row immediately).
function pickLabel(value: string) {
if (selectedValue !== value) onSelect(value);
}
function pickKeyword(value: string) {
if (selectedValue !== value) onSelect(value);
}
function pickColor(key: string) {
if (selectedValue !== key) onSelect(key);
}
function pickRating(r: number) {
const s = String(r);
if (selectedValue !== s) onSelect(s);
}
// First non-empty entry for the active category. Labels/keywords are
// already sorted by count desc, so [0] is the most-used tag; colors
// and ratings walk their fixed display order and pick the first
// bucket with photos in it. Returns null when nothing's loaded yet or
// the category genuinely has no tagged photos.
const firstValue = $derived.by<string | null>(() => {
if (category === 'labels') {
const first = labelsSorted[0];
return first ? (first.CustomSlug ?? first.Slug) : null;
}
if (category === 'keywords') {
return keywordsSorted[0]?.keyword ?? null;
}
if (category === 'colors') {
return colorGroups[0]?.key ?? null;
}
if (category === 'ratings') {
const g = ratingGroups[0];
return g ? String(g.rating) : null;
}
return null;
});
// Auto-select the first tag when the user lands on a category URL
// without an explicit value. `replace: true` keeps `/tags/labels` out
// of the back-button stack so the redirect doesn't trap the user in a
// loop. The guard `selectedValue == null` means this effect only
// fires when there's genuinely no selection — once a value is picked
// (by the user or by this effect), the URL drives selectedValue and
// the effect no-ops.
$effect(() => {
if (selectedValue != null) return;
if (firstValue == null) return;
onSelect(firstValue, { replace: true });
});
const categoryTitle = $derived(
category === 'labels'
? 'Labels'
: category === 'keywords'
? 'Keywords'
: category === 'colors'
? 'Colors'
: 'Ratings'
);
const showFilterInput = $derived(category === 'labels' || category === 'keywords');
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="shrink-0 border-b border-border px-3 py-2">
<span
class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"
>
{categoryTitle}
</span>
{#if showFilterInput}
<input
type="text"
placeholder={`Filter ${categoryTitle.toLowerCase()}…`}
class="mt-2 w-full rounded border border-border bg-background px-2 py-1 text-[12px] outline-none placeholder:text-muted-foreground/60 focus:border-primary/40"
bind:value={filterText}
/>
{/if}
</div>
{#if category === 'labels'}
{#if labelsQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading labels…</p>
{:else if labelsQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load labels.</p>
{:else if filteredLabels.length === 0}
<p class="px-3 py-2 text-[11px] text-muted-foreground">
{filterText ? 'No labels match the filter.' : 'No labels yet.'}
</p>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleLabels as label (label.UID ?? label.Slug)}
{@const slug = label.CustomSlug ?? label.Slug}
{@const active = slug === selectedValue}
<button
type="button"
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => pickLabel(slug)}
title={label.Name}
>
{#if label.Thumb}
<img
src={thumbUrl(label.Thumb, 'tile_50')}
alt=""
loading="lazy"
class="h-5 w-5 shrink-0 rounded object-cover"
/>
{:else}
<span class="h-5 w-5 shrink-0 rounded bg-secondary"></span>
{/if}
<span class="min-w-0 flex-1 truncate">{label.Name}</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{label.PhotoCount ?? 0}
</span>
</button>
{/each}
<!-- Sentinel: trips well before the user reaches the bottom so
the next page is mounted invisibly. -->
<div
use:nearBottom={{
onHit: loadMore,
enabled: hasMoreLabels,
root: scrollEl ?? null,
preloadPx: 400
}}
class="h-px"
aria-hidden="true"
></div>
{#if hasMoreLabels}
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
Loading more… ({visibleCount} / {filteredLabels.length})
</p>
{/if}
</div>
{/if}
{:else if category === 'keywords'}
{#if keywordsQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">
Loading keywords… (aggregates from photo details — first load may take a few seconds)
</p>
{:else if keywordsQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load keywords.</p>
{:else if filteredKeywords.length === 0}
<p class="px-3 py-2 text-[11px] text-muted-foreground">
{filterText
? 'No keywords match the filter.'
: 'No user-set keywords yet. Add them from a photos right-sidebar metadata panel.'}
</p>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleKeywords as kw (kw.keyword)}
{@const active = kw.keyword === selectedValue}
<button
type="button"
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => pickKeyword(kw.keyword)}
title={kw.keyword}
>
<img
src={thumbUrl(kw.sampleHash, 'tile_50')}
alt=""
loading="lazy"
class="h-5 w-5 shrink-0 rounded object-cover"
/>
<span class="min-w-0 flex-1 truncate">{kw.keyword}</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{kw.count}
</span>
</button>
{/each}
<div
use:nearBottom={{
onHit: loadMore,
enabled: hasMoreKeywords,
root: scrollEl ?? null,
preloadPx: 400
}}
class="h-px"
aria-hidden="true"
></div>
{#if hasMoreKeywords}
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
Loading more… ({visibleCount} / {filteredKeywords.length})
</p>
{/if}
</div>
{/if}
{:else if category === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>
{:else if marksQuery.isError || marksPoolQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load colors.</p>
{:else}
<div class="min-h-0 flex-1 overflow-y-auto">
{#each COLOR_SWATCHES as swatch (swatch.key)}
{@const group = colorGroups.find((g) => g.key === swatch.key)}
{@const count = group?.photos.length ?? 0}
{@const active = swatch.key === selectedValue}
{@const disabled = count === 0}
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] leading-tight hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
{disabled}
onclick={() => pickColor(swatch.key)}
title={swatch.title}
>
<span class="h-3 w-3 shrink-0 rounded-full {swatch.bg}"></span>
<span class="min-w-0 flex-1 truncate">{swatch.title}</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{count}
</span>
</button>
{/each}
</div>
{/if}
{:else if category === 'ratings'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading ratings…</p>
{:else if marksQuery.isError || marksPoolQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load ratings.</p>
{:else}
<div class="min-h-0 flex-1 overflow-y-auto">
{#each [5, 4, 3, 2, 1] as r (r)}
{@const group = ratingGroups.find((g) => g.rating === r)}
{@const count = group?.photos.length ?? 0}
{@const active = String(r) === selectedValue}
{@const disabled = count === 0}
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] leading-tight hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
{disabled}
onclick={() => pickRating(r)}
title={`${r} star${r === 1 ? '' : 's'}`}
>
<span class="text-yellow-500">{starLabel(r)}</span>
<span class="min-w-0 flex-1 truncate text-muted-foreground">
{r} star{r === 1 ? '' : 's'}
</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{count}
</span>
</button>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -13,6 +13,7 @@
} from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch';
import {
clearBulkToFirst,
clearSelection,
focusAfter,
selection,
@@ -59,6 +60,10 @@
const isArchive = $derived(filters.section === 'archive');
function clearAll() {
// Mirror gridKeyNav's Esc: a multi-selection collapses back to its
// first member (the user keeps a single-focus reference) before
// the next Clear/Esc fully dismisses focus.
if (clearBulkToFirst()) return;
clearSelection();
setFocused(null);
}

View File

@@ -21,7 +21,7 @@
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { view } from '$lib/stores/view.svelte';
import { openPreview, view } from '$lib/stores/view.svelte';
import { type PpPhoto } from '$lib/types/photoprism';
import PhotoTile from './PhotoTile.svelte';
@@ -80,6 +80,7 @@
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
openPreview();
}
</script>