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

@@ -13,6 +13,7 @@ import {
import { queryClient } from '$lib/queryClient'; import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { import {
clearBulkToFirst,
clearSelection, clearSelection,
focusAfter, focusAfter,
indexOf, indexOf,
@@ -23,7 +24,7 @@ import {
toggle toggle
} from '$lib/stores/selection.svelte'; } from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte'; import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
import { toggleLeftSidebar, toggleRightSidebar } from '$lib/stores/view.svelte'; import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
/** /**
@@ -381,6 +382,24 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return; if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
// Modal owns arrow / Escape / Space while it's open — it handles
// its own linear nav, close-on-Esc, and close-on-Space. Action
// keys (X/S/U/A/Z) still pass through because they target the
// shared selection store and work the same in either context.
if (view.previewOpen) {
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'Escape' ||
e.key === ' ' ||
e.code === 'Space'
) {
return;
}
}
// S+digit chord. A digit 19 within the chord window consumes the key // S+digit chord. A digit 19 within the chord window consumes the key
// and fires add-to-heap-N. Any other key cancels the chord without // and fires add-to-heap-N. Any other key cancels the chord without
// firing the default active-heap action — the user switched intent — // firing the default active-heap action — the user switched intent —
@@ -398,6 +417,18 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const meta = e.metaKey || e.ctrlKey; const meta = e.metaKey || e.ctrlKey;
const shift = e.shiftKey; const shift = e.shiftKey;
// Space on a focused tile opens the full-screen preview modal.
// Matches the dblclick gesture so the user has both keyboard and
// mouse paths to the same surface. `e.code === 'Space'` covers
// layouts where `e.key` is the dead-key combining mark.
if ((e.key === ' ' || e.code === 'Space') && !meta && !shift) {
if (selection.focused) {
e.preventDefault();
openPreview();
return;
}
}
// ── Grid nav keys ──────────────────────────────────────────────────── // ── Grid nav keys ────────────────────────────────────────────────────
switch (e.key) { switch (e.key) {
case 'ArrowLeft': case 'ArrowLeft':
@@ -424,6 +455,11 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
} }
return; return;
case 'Escape': case 'Escape':
// First Esc collapses a multi-selection back to single-focus
// on its first member — the user's "starting photo" stays
// visible instead of vanishing. Only when there's no bulk
// does Esc fully dismiss focus.
if (clearBulkToFirst()) return;
clearSelection(); clearSelection();
setFocused(null); setFocused(null);
return; return;

View File

@@ -1,75 +0,0 @@
/**
* Drag-to-resize Svelte action for vertical splits. Sibling of `resizable`
* (which handles left/right edges); kept as its own file so each action's
* surface stays small and the call sites read obviously.
*
* edge: 'bottom' — handle on the bottom edge of the pane; drag down enlarges
* edge: 'top' — handle on the top edge of the pane; drag up enlarges
*
* Usage (handle on the bottom edge of the top preview pane):
* <div use:resizableVertical={{ edge: 'bottom',
* getHeight: () => view.previewPaneHeight,
* setHeight: setPreviewPaneHeight }} />
*/
export interface ResizableVerticalParams {
edge: 'top' | 'bottom';
getHeight: () => number;
setHeight: (px: number) => void;
}
export function resizableVertical(node: HTMLElement, initial: ResizableVerticalParams) {
let params = initial;
let pointerId = -1;
let startY = 0;
let startHeight = 0;
function onDown(e: PointerEvent) {
if (e.button !== 0) return;
pointerId = e.pointerId;
startY = e.clientY;
startHeight = params.getHeight();
node.setPointerCapture(pointerId);
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
node.addEventListener('pointermove', onMove);
node.addEventListener('pointerup', onUp);
node.addEventListener('pointercancel', onUp);
}
function onMove(e: PointerEvent) {
if (e.pointerId !== pointerId) return;
const dy = e.clientY - startY;
// `edge: 'bottom'` — handle on the bottom edge of the controlled pane,
// drag down grows it. `edge: 'top'` — handle on the top edge of the
// controlled pane (i.e. the pane is below the handle), drag up grows
// it, so the delta is inverted. Mirrors the horizontal action.
const delta = params.edge === 'bottom' ? dy : -dy;
params.setHeight(startHeight + delta);
}
function onUp(e: PointerEvent) {
if (pointerId === -1) return;
try {
node.releasePointerCapture(pointerId);
} catch {
// Pointer may already be released; ignore.
}
pointerId = -1;
document.body.style.cursor = '';
document.body.style.userSelect = '';
node.removeEventListener('pointermove', onMove);
node.removeEventListener('pointerup', onUp);
node.removeEventListener('pointercancel', onUp);
}
node.addEventListener('pointerdown', onDown);
return {
update(next: ResizableVerticalParams) {
params = next;
},
destroy() {
node.removeEventListener('pointerdown', onDown);
}
};
}

View File

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

View File

@@ -41,7 +41,9 @@
filters, filters,
setFolderPath, setFolderPath,
setSection, setSection,
type Section TAG_CATEGORIES,
type Section,
type TagCategory
} from '$lib/stores/filters.svelte'; } from '$lib/stores/filters.svelte';
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte'; import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte'; import FolderTree, { buildTree } from './FolderTree.svelte';
@@ -361,6 +363,59 @@
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0'); 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 rootActive = $derived(filters.folderPath === '/');
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0); const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
@@ -510,23 +565,23 @@
// Map's `geoQuery` already returns the GeoJSON the user is // Map's `geoQuery` already returns the GeoJSON the user is
// permitted to see (PhotoPrism's /geo applies the session ACL), // permitted to see (PhotoPrism's /geo applies the session ACL),
// so the badge is per-user-correct without extra scoping. // so the badge is per-user-correct without extra scoping.
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }, { kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }
// Tags rolls up labels + keywords + ratings + colors. Labels // Tags is rendered as a bespoke expandable block below the
// flows through countPhotos (scoped); keywords/ratings/colors are // `views` loop — it has sub-categories (Labels/Keywords/Colors/
// from library-wide marks tables and only contribute when we're // Ratings) and a chevron, neither of which fits the flat
// in admin-without-BasePath mode (their sources don't scope). // section/route ViewItem shape.
{ ];
kind: 'route',
href: '/tags', // Total badge for the "Tags" header row. Rolls up labels + keywords +
label: 'Tags', // ratings + colors. Labels flows through countPhotos (scoped); keywords/
getCount: () => { // 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 (labelsBadge === undefined) return undefined;
if (wantScoped) return labelsBadge; if (wantScoped) return labelsBadge;
const keywords = keywordsQuery.data?.length ?? 0; const keywords = keywordsQuery.data?.length ?? 0;
return labelsBadge + keywords + ratingsCount + colorsCount; return labelsBadge + keywords + ratingsCount + colorsCount;
} });
}
];
const manageViews: ViewItem[] = [ const manageViews: ViewItem[] = [
{ {
@@ -555,7 +610,7 @@
{@const count = v.getCount()} {@const count = v.getCount()}
{#if v.kind === 'section'} {#if v.kind === 'section'}
<button <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:bg-primary={active}
class:text-primary-foreground={active} class:text-primary-foreground={active}
class:hover:bg-primary={active} class:hover:bg-primary={active}
@@ -564,7 +619,7 @@
<span class="truncate">{v.label}</span> <span class="truncate">{v.label}</span>
{#if count !== undefined} {#if count !== undefined}
<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-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}" : 'bg-secondary text-muted-foreground'}"
> >
@@ -575,7 +630,7 @@
{:else} {:else}
<a <a
href={v.href} 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:bg-primary={active}
class:text-primary-foreground={active} class:text-primary-foreground={active}
class:hover:bg-primary={active} class:hover:bg-primary={active}
@@ -583,7 +638,7 @@
<span class="truncate">{v.label}</span> <span class="truncate">{v.label}</span>
{#if count !== undefined} {#if count !== undefined}
<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-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}" : 'bg-secondary text-muted-foreground'}"
> >
@@ -603,7 +658,7 @@
slightly faded even at scroll-bottom. slightly faded even at scroll-bottom.
--> -->
<nav <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);" 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 <!-- 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 hover-revealed actions on the header for library settings and
new-top-level-folder. --> new-top-level-folder. -->
<div> <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"> <span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library Library
</span> </span>
@@ -641,11 +696,11 @@
below — same affordance as nested folder rows. below — same affordance as nested folder rows.
--> -->
<div <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:bg-primary={rootActive}
class:text-primary-foreground={rootActive} class:text-primary-foreground={rootActive}
class:hover:bg-primary={rootActive} class:hover:bg-primary={rootActive}
style="padding-left: 8px;" style="padding-left: 4px;"
> >
{#if hasSubfolders} {#if hasSubfolders}
<button <button
@@ -658,6 +713,10 @@
> >
{rootExpanded ? '▾' : '▸'} {rootExpanded ? '▾' : '▸'}
</button> </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} {/if}
<!-- <!--
Count badge lives INSIDE the button so the entire row (label Count badge lives INSIDE the button so the entire row (label
@@ -666,15 +725,14 @@
--> -->
<button <button
type="button" type="button"
class="flex min-w-0 flex-1 items-center text-left" class="flex min-w-0 flex-1 items-center pl-1 text-left"
class:px-1={hasSubfolders}
onclick={() => pickFolder('/')} onclick={() => pickFolder('/')}
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`} title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
> >
<span class="truncate">{rootLabel}</span> <span class="truncate">{rootLabel}</span>
{#if configQuery.data} {#if configQuery.data}
<span <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-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}" : 'bg-secondary text-muted-foreground'}"
> >
@@ -721,35 +779,8 @@
{/if} {/if}
</div> </div>
<!-- Views — everyday browse entries (section + route mixed) under
a single uppercase eyebrow. Compact rows, no icons. -->
<div> <div>
<div class="px-3 pb-1"> <div class="group/header flex items-center 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}
</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">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Heaps Heaps
</span> </span>
@@ -781,20 +812,20 @@
open), pushing the button slightly left. open), pushing the button slightly left.
--> -->
<li <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:bg-primary={active}
class:text-primary-foreground={active} class:text-primary-foreground={active}
class:hover:bg-primary={active} class:hover:bg-primary={active}
> >
<button <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)} onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)} ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`} title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
> >
<span class="truncate">{heap.Title}</span> <span class="truncate">{heap.Title}</span>
<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-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}" : 'bg-secondary text-muted-foreground'}"
> >
@@ -847,6 +878,89 @@
{/if} {/if}
</div> </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> </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`. Inner preview surface used by the full-screen PreviewModal and the
Renders the focused photo or video inside its host pane so the grid shareable /photo/[uid] deep-link route. Renders the focused photo or
stays visible below. Prev/next move `selection.focused` directly so video, with optional prev/next chevrons that walk `order` via
the right metadata sidebar tracks in lockstep. 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"> <script lang="ts">
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism'; import { getPhoto } from '$lib/services/photoprism';
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte'; import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { selection, setAnchor, setFocused } from '$lib/stores/selection.svelte'; import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import SelectionDeck from '$lib/components/preview/SelectionDeck.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte'; import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism'; import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
interface Props { interface Props {
uid: string | null; 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[]; 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(); let { uid, order, showChevrons = true }: 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);
const photoQuery = createQuery<PpPhoto>(() => ({ const photoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', uid ?? ''], queryKey: ['photo', uid ?? ''],
@@ -38,20 +33,10 @@
const currentIndex = $derived(uid ? order.indexOf(uid) : -1); 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; const VIDEO_LOAD_DELAY_MS = 250;
let armedUid = $state<string | null>(null); let armedUid = $state<string | null>(null);
$effect(() => { $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; const target = uid;
if (!target) { if (!target) {
armedUid = null; armedUid = null;
@@ -71,10 +56,8 @@
} }
</script> </script>
<div class="relative flex h-full w-full items-center justify-center bg-black/30 p-4"> <div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
{#if multiSelect} {#if uid === null}
<SelectionDeck uids={selectedUids} />
{:else if uid === null}
<p class="text-sm text-muted-foreground">Select a photo to preview.</p> <p class="text-sm text-muted-foreground">Select a photo to preview.</p>
{:else if photoQuery.isPending} {:else if photoQuery.isPending}
<p class="text-sm text-muted-foreground">Loading…</p> <p class="text-sm text-muted-foreground">Loading…</p>
@@ -82,7 +65,7 @@
<p class="text-sm text-destructive">Failed to load photo.</p> <p class="text-sm text-destructive">Failed to load photo.</p>
{:else if photoQuery.data} {:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)} {@const pf = primaryFile(photoQuery.data)}
{#if currentIndex > 0} {#if showChevrons && currentIndex > 0}
<button <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" 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)} onclick={() => focusAt(currentIndex - 1)}
@@ -91,7 +74,7 @@
</button> </button>
{/if} {/if}
{#if currentIndex >= 0 && currentIndex < order.length - 1} {#if showChevrons && currentIndex >= 0 && currentIndex < order.length - 1}
<button <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" 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)} onclick={() => focusAt(currentIndex + 1)}
@@ -104,10 +87,6 @@
{#if isVideo(photoQuery.data)} {#if isVideo(photoQuery.data)}
{@const vf = videoFile(photoQuery.data)} {@const vf = videoFile(photoQuery.data)}
{#if armedUid === uid} {#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} {#key vf.Hash}
<VideoPlayer <VideoPlayer
src={videoUrl(vf.Hash)} src={videoUrl(vf.Hash)}
@@ -116,9 +95,6 @@
/> />
{/key} {/key}
{:else} {:else}
<!-- Debounce window: render the poster only. Matches the
still-photo branch's styling so the pane reads identically
until <VideoPlayer> arms in. -->
<img <img
src={thumbUrl(pf.Hash, 'fit_1280')} src={thumbUrl(pf.Hash, 'fit_1280')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'} 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) { :global(.vds-player) {
/* Let the player size to the video's intrinsic aspect ratio, /* Let the player size to the video's intrinsic aspect ratio,
capped by the host pane. Don't set width/height to 100% — that capped by the host pane. Don't set width/height to 100% — that
was stretching widescreen video into the square inline-preview was stretching widescreen video into the square preview pane.
pane. The default vidstack layout reads the loaded media's The default vidstack layout reads the loaded media's aspect
aspect and sizes the box accordingly; we just clamp the upper and sizes the box accordingly; we just clamp the upper bound
bound so it can't escape the SplitGrid top pane. */ so it can't escape its host pane. */
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
--media-brand: #3b82f6; --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'; } from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch'; import { batchEdit } from '$lib/services/batch';
import { import {
clearBulkToFirst,
clearSelection, clearSelection,
focusAfter, focusAfter,
selection, selection,
@@ -59,6 +60,10 @@
const isArchive = $derived(filters.section === 'archive'); const isArchive = $derived(filters.section === 'archive');
function clearAll() { 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(); clearSelection();
setFocused(null); setFocused(null);
} }

View File

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

View File

@@ -7,6 +7,7 @@
* filter shape (archive → `archived:true`, etc.), and the search box on * filter shape (archive → `archived:true`, etc.), and the search box on
* top stacks an additional `q` term. * top stacks an additional `q` term.
*/ */
import { goto } from '$app/navigation';
import { toOriginalsPath, userBasePath } from '$lib/stores/session.svelte'; import { toOriginalsPath, userBasePath } from '$lib/stores/session.svelte';
export type Section = export type Section =
@@ -16,6 +17,22 @@ export type Section =
| 'hidden' | 'hidden'
| 'heap'; | 'heap';
export type TagCategory = 'labels' | 'keywords' | 'colors' | 'ratings';
export const TAG_CATEGORIES: readonly TagCategory[] = [
'labels',
'keywords',
'colors',
'ratings'
] as const;
export function isTagCategory(v: unknown): v is TagCategory {
return (
typeof v === 'string' &&
(TAG_CATEGORIES as readonly string[]).includes(v)
);
}
export interface FilterState { export interface FilterState {
section: Section; section: Section;
/** Heap UID, used when section === 'heap'. */ /** Heap UID, used when section === 'heap'. */
@@ -24,6 +41,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;
/**
* Active tag-browser category and selected value. Set by the
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
* feed `filtersToQ()` (PhotoPrism DSL); colors/ratings are resolved
* client-side from the marks pool and don't contribute to `q`.
*/
tagCategory: TagCategory | null;
tagValue: string | null;
} }
// Default landing = root folder (`/`). The Folders group sits at the top // Default landing = root folder (`/`). The Folders group sits at the top
@@ -34,7 +59,9 @@ export const filters = $state<FilterState>({
section: 'all-photos', section: 'all-photos',
heapUid: null, heapUid: null,
folderPath: '/', folderPath: '/',
search: '' search: '',
tagCategory: null,
tagValue: null
}); });
export function setSection(section: Section, heapUid: string | null = null): void { export function setSection(section: Section, heapUid: string | null = null): void {
@@ -50,6 +77,38 @@ export function setFolderPath(path: string | null): void {
filters.folderPath = path; filters.folderPath = path;
} }
export function setTagFilter(
category: TagCategory | null,
value: string | null
): void {
filters.tagCategory = category;
filters.tagValue = value;
}
/**
* Navigate to a tag-category browse URL. Path-segment shape
* (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's
* dynamic route plumbing typecheck `page.params`.
*
* `replace` is used by the auto-select-first-tag effect so the
* empty-category URL (`/tags/labels`) doesn't end up in history — back
* would otherwise loop straight back into the auto-select redirect.
*/
export async function navigateToTag(
category: TagCategory,
value: string | null,
options: { replace?: boolean } = {}
): Promise<void> {
const path = value
? `/tags/${category}/${encodeURIComponent(value)}`
: `/tags/${category}`;
await goto(path, {
keepFocus: true,
noScroll: true,
replaceState: options.replace ?? false
});
}
/** /**
* Quote a DSL term value when it contains characters that PhotoPrism's * Quote a DSL term value when it contains characters that PhotoPrism's
* parser treats as boundaries (spaces, colons). We surround in double * parser treats as boundaries (spaces, colons). We surround in double
@@ -115,6 +174,16 @@ export function filtersToQ(f: FilterState = filters): string {
parts.push(`path:${quoteIfNeeded(serverPath + '*')}`); parts.push(`path:${quoteIfNeeded(serverPath + '*')}`);
} }
} }
// Tag drill-down clauses for server-resolvable tag categories.
// Colors/ratings live in the mule-sidecar marks store and are
// applied client-side after the photo pool is fetched.
if (f.tagCategory && f.tagValue) {
if (f.tagCategory === 'labels') {
parts.push(`label:${quoteIfNeeded(f.tagValue)}`);
} else if (f.tagCategory === 'keywords') {
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
}
}
if (f.search) parts.push(quoteIfNeeded(f.search)); if (f.search) parts.push(quoteIfNeeded(f.search));
return parts.join(' '); return parts.join(' ');
} }

View File

@@ -51,6 +51,33 @@ export function clearSelection(): void {
selection.anchor = null; selection.anchor = null;
} }
/**
* Collapse a multi-selection (ids.size >= 2) down to single-focus on the
* first selected uid in display order. Returns true when a bulk was
* actually collapsed so callers can branch on it ("did Esc consume the
* bulk, or should it dismiss the surface itself?"). Used by gridKeyNav's
* Esc handler, BulkActionBar's Clear button, and the preview modal's
* Esc handler — all want the same "first Esc drops the multi-select,
* second Esc dismisses" UX.
*/
export function clearBulkToFirst(): boolean {
if (selection.ids.size < 2) return false;
let first: string | null = null;
for (const uid of selection.order) {
if (selection.ids.has(uid)) {
first = uid;
break;
}
}
clearSelection();
if (first) {
selection.ids.add(first);
selection.focused = first;
selection.anchor = first;
}
return true;
}
export function toggle(uid: string): void { export function toggle(uid: string): void {
if (selection.ids.has(uid)) { if (selection.ids.has(uid)) {
selection.ids.delete(uid); selection.ids.delete(uid);

View File

@@ -24,7 +24,8 @@ interface Persisted {
thumbnailSize?: ThumbnailSize; thumbnailSize?: ThumbnailSize;
leftSidebarWidth?: number; leftSidebarWidth?: number;
rightSidebarWidth?: number; rightSidebarWidth?: number;
previewPaneHeight?: number; tagsBrowserWidth?: number;
tagsBrowserCollapsed?: boolean;
/** /**
* Per-section expanded state for the right-sidebar metadata panel * Per-section expanded state for the right-sidebar metadata panel
* (GPS, Credits, File). Keyed by section id; missing entries use a * (GPS, Credits, File). Keyed by section id; missing entries use a
@@ -40,14 +41,9 @@ export const DEFAULT_LEFT_WIDTH = 224;
export const MIN_RIGHT_WIDTH = 220; export const MIN_RIGHT_WIDTH = 220;
export const MAX_RIGHT_WIDTH = 480; export const MAX_RIGHT_WIDTH = 480;
export const DEFAULT_RIGHT_WIDTH = 280; export const DEFAULT_RIGHT_WIDTH = 280;
export const MIN_PREVIEW_HEIGHT = 160; export const MIN_TAGS_BROWSER_WIDTH = 180;
export const DEFAULT_PREVIEW_HEIGHT = 360; export const MAX_TAGS_BROWSER_WIDTH = 480;
/** export const DEFAULT_TAGS_BROWSER_WIDTH = 240;
* Cap the preview pane at 70 % of the viewport so the grid is always
* visible underneath. Resolved against `window.innerHeight` at set-time
* (the localStorage load happens before any viewport size is known).
*/
export const MAX_PREVIEW_HEIGHT_FRAC = 0.7;
function clamp(n: number, lo: number, hi: number): number { function clamp(n: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, n)); return Math.min(hi, Math.max(lo, n));
@@ -78,7 +74,13 @@ export const view = $state<{
thumbnailSize: ThumbnailSize; thumbnailSize: ThumbnailSize;
leftSidebarWidth: number; leftSidebarWidth: number;
rightSidebarWidth: number; rightSidebarWidth: number;
previewPaneHeight: number; tagsBrowserWidth: number;
tagsBrowserCollapsed: boolean;
/**
* Ephemeral: true while the full-screen preview modal is open. Not
* persisted — a refresh always returns to the grid.
*/
previewOpen: boolean;
metadataSections: Record<string, boolean>; metadataSections: Record<string, boolean>;
}>({ }>({
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false, rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
@@ -96,12 +98,15 @@ export const view = $state<{
MIN_RIGHT_WIDTH, MIN_RIGHT_WIDTH,
MAX_RIGHT_WIDTH MAX_RIGHT_WIDTH
), ),
previewPaneHeight: Math.max( tagsBrowserWidth: clamp(
MIN_PREVIEW_HEIGHT, typeof initial.tagsBrowserWidth === 'number'
typeof initial.previewPaneHeight === 'number' ? initial.tagsBrowserWidth
? initial.previewPaneHeight : DEFAULT_TAGS_BROWSER_WIDTH,
: DEFAULT_PREVIEW_HEIGHT MIN_TAGS_BROWSER_WIDTH,
MAX_TAGS_BROWSER_WIDTH
), ),
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
previewOpen: false,
metadataSections: metadataSections:
initial.metadataSections && typeof initial.metadataSections === 'object' initial.metadataSections && typeof initial.metadataSections === 'object'
? { ...initial.metadataSections } ? { ...initial.metadataSections }
@@ -116,7 +121,8 @@ function persist(): void {
thumbnailSize: view.thumbnailSize, thumbnailSize: view.thumbnailSize,
leftSidebarWidth: view.leftSidebarWidth, leftSidebarWidth: view.leftSidebarWidth,
rightSidebarWidth: view.rightSidebarWidth, rightSidebarWidth: view.rightSidebarWidth,
previewPaneHeight: view.previewPaneHeight, tagsBrowserWidth: view.tagsBrowserWidth,
tagsBrowserCollapsed: view.tagsBrowserCollapsed,
metadataSections: view.metadataSections metadataSections: view.metadataSections
}; };
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
@@ -132,14 +138,32 @@ export function setRightSidebarWidth(px: number): void {
persist(); persist();
} }
export function setPreviewPaneHeight(px: number): void { export function setTagsBrowserWidth(px: number): void {
const maxH = browser view.tagsBrowserWidth = clamp(
? Math.max(MIN_PREVIEW_HEIGHT + 1, Math.floor(window.innerHeight * MAX_PREVIEW_HEIGHT_FRAC)) Math.round(px),
: 1024; MIN_TAGS_BROWSER_WIDTH,
view.previewPaneHeight = clamp(Math.round(px), MIN_PREVIEW_HEIGHT, maxH); MAX_TAGS_BROWSER_WIDTH
);
persist(); persist();
} }
export function toggleTagsBrowser(): void {
view.tagsBrowserCollapsed = !view.tagsBrowserCollapsed;
persist();
}
export function openPreview(): void {
view.previewOpen = true;
}
export function closePreview(): void {
view.previewOpen = false;
}
export function togglePreview(): void {
view.previewOpen = !view.previewOpen;
}
export function setThumbnailSize(size: ThumbnailSize): void { export function setThumbnailSize(size: ThumbnailSize): void {
view.thumbnailSize = size; view.thumbnailSize = size;
persist(); persist();

View File

@@ -0,0 +1,98 @@
/**
* Shared helpers for tag-browser surfaces (the LeftSidebar Tags submenu,
* the TagsBrowserSidebar panel, and the /tags/[category]/[[value]] drill
* page). Group builders resolve color/rating buckets out of the
* mule-sidecar marks store joined against a photo pool — both the list
* panel and the drill page consume the same groups so highlighted counts
* and resolved photo sets never drift apart.
*/
import type { PhotoMarksMap } from '$lib/services/photoprism';
import type { PpPhoto } from '$lib/types/photoprism';
/**
* Lightroom culling convention: red rejects, orange reviews, yellow
* picks, green keeps. Order here is the order the TagsBrowser renders
* rows in — fixed so the user can build muscle memory.
*/
export const COLOR_SWATCHES: readonly { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
] as const;
export interface RatingGroup {
rating: number;
photos: PpPhoto[];
}
export function buildRatingGroups(
marks: PhotoMarksMap | undefined,
pool: PpPhoto[] | undefined
): RatingGroup[] {
if (!marks || !pool) return [];
const byUid = new Map(pool.map((p) => [p.UID, p]));
const buckets = new Map<number, PpPhoto[]>();
for (const [uid, mark] of Object.entries(marks)) {
const r = mark.rating ?? 0;
if (r <= 0) continue;
const photo = byUid.get(uid);
if (!photo) continue;
const arr = buckets.get(r) ?? [];
arr.push(photo);
buckets.set(r, arr);
}
const out: RatingGroup[] = [];
for (let r = 5; r >= 1; r--) {
const photos = buckets.get(r);
if (photos && photos.length > 0) out.push({ rating: r, photos });
}
return out;
}
export interface ColorGroup {
key: string;
title: string;
bg: string;
photos: PpPhoto[];
}
export function buildColorGroups(
marks: PhotoMarksMap | undefined,
pool: PpPhoto[] | undefined
): ColorGroup[] {
if (!marks || !pool) return [];
const byUid = new Map(pool.map((p) => [p.UID, p]));
const buckets = new Map<string, PpPhoto[]>();
for (const [uid, mark] of Object.entries(marks)) {
const c = mark.color;
if (!c) continue;
const photo = byUid.get(uid);
if (!photo) continue;
const arr = buckets.get(c) ?? [];
arr.push(photo);
buckets.set(c, arr);
}
const out: ColorGroup[] = [];
for (const swatch of COLOR_SWATCHES) {
const photos = buckets.get(swatch.key);
if (photos && photos.length > 0) out.push({ ...swatch, photos });
}
return out;
}
export function countMarked(
marks: PhotoMarksMap | undefined,
field: 'rating' | 'color'
): number | undefined {
if (!marks) return undefined;
let n = 0;
for (const m of Object.values(marks)) {
if (field === 'rating' ? (m.rating ?? 0) > 0 : Boolean(m.color)) n++;
}
return n;
}
export function starLabel(rating: number): string {
return '★'.repeat(rating);
}

View File

@@ -16,6 +16,7 @@
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte'; import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte'; import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte'; import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
let { children } = $props(); let { children } = $props();
@@ -108,6 +109,11 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Single shared preview modal. Reads view.previewOpen +
selection.focused; any route can pop it via the openPreview
helper (called by the timeline / PhotoGrid dblclick paths and
by gridKeyNav's Space handler). -->
<PreviewModal />
{:else} {:else}
{@render children?.()} {@render children?.()}
{/if} {/if}

View File

@@ -35,6 +35,7 @@
setOrder, setOrder,
} from "$lib/stores/selection.svelte"; } from "$lib/stores/selection.svelte";
import { import {
openPreview,
setRightSidebarWidth, setRightSidebarWidth,
setThumbnailSize, setThumbnailSize,
THUMBNAIL_SIZE_LABELS, THUMBNAIL_SIZE_LABELS,
@@ -51,11 +52,9 @@
} from "$lib/actions/visibleRange"; } from "$lib/actions/visibleRange";
import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte"; import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.svelte"; import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.svelte";
import InlinePreview from "$lib/components/preview/InlinePreview.svelte";
import PhotoTile from "$lib/components/timeline/PhotoTile.svelte"; import PhotoTile from "$lib/components/timeline/PhotoTile.svelte";
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte"; import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte"; import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
import SplitGrid from "$lib/components/preview/SplitGrid.svelte";
import Toolbar from "$lib/components/layout/Toolbar.svelte"; import Toolbar from "$lib/components/layout/Toolbar.svelte";
import { type PpPhoto } from "$lib/types/photoprism"; import { type PpPhoto } from "$lib/types/photoprism";
@@ -668,6 +667,7 @@
selection.ids.add(uid); selection.ids.add(uid);
setFocused(uid); setFocused(uid);
setAnchor(uid); setAnchor(uid);
openPreview();
} }
// Scroll root for the infinite-scroll IntersectionObserver. Bound by // Scroll root for the infinite-scroll IntersectionObserver. Bound by
@@ -815,11 +815,6 @@
sibling at row level and stays full height when the bar appears. sibling at row level and stays full height when the bar appears.
--> -->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden"> <div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<SplitGrid>
{#snippet preview()}
<InlinePreview uid={selection.focused} order={selection.order} />
{/snippet}
{#snippet grid()}
<main <main
bind:this={scrollRoot} bind:this={scrollRoot}
class="flex-1 overflow-y-auto outline-none focus:outline-none" class="flex-1 overflow-y-auto outline-none focus:outline-none"
@@ -932,8 +927,6 @@
{/if} {/if}
</div> </div>
</main> </main>
{/snippet}
</SplitGrid>
<BulkActionBar /> <BulkActionBar />
</div> </div>

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import InlinePreview from '$lib/components/preview/InlinePreview.svelte'; import PreviewPane from '$lib/components/preview/PreviewPane.svelte';
// Deep-link entry. The route renders a full-page InlinePreview keyed // Deep-link entry. The route renders a full-page PreviewPane keyed
// on the URL `uid` so the link stays shareable and reload-safe. No // on the URL `uid` so the link stays shareable and reload-safe. No
// surrounding grid here — this surface is a single-photo viewer. // surrounding grid here — this surface is a single-photo viewer.
const uid = $derived((page.params.uid ?? null) as string | null); const uid = $derived((page.params.uid ?? null) as string | null);
@@ -10,5 +10,5 @@
</script> </script>
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<InlinePreview {uid} {order} /> <PreviewPane {uid} {order} showChevrons={false} />
</div> </div>

View File

@@ -51,8 +51,6 @@
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte'; import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte'; import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
import SplitGrid from '$lib/components/preview/SplitGrid.svelte';
type DupTab = 'stacks' | 'cross-folder'; type DupTab = 'stacks' | 'cross-folder';
type Tab = CauseKey | DupTab; type Tab = CauseKey | DupTab;
@@ -226,11 +224,6 @@
{:else} {:else}
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<SplitGrid>
{#snippet preview()}
<InlinePreview uid={selection.focused} order={selection.order} />
{/snippet}
{#snippet grid()}
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}> <main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending} {#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p> <p class="text-sm text-muted-foreground">Loading review queue…</p>
@@ -256,8 +249,6 @@
{/key} {/key}
{/if} {/if}
</main> </main>
{/snippet}
</SplitGrid>
<BulkActionBar /> <BulkActionBar />
</div> </div>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { page } from '$app/state';
import { resizable } from '$lib/actions/resizable';
import {
isTagCategory,
navigateToTag,
type TagCategory
} from '$lib/stores/filters.svelte';
import { setTagsBrowserWidth, view } from '$lib/stores/view.svelte';
import TagsBrowserSidebar from '$lib/components/sidebar/TagsBrowserSidebar.svelte';
let { children } = $props();
// Active category comes straight from the URL path so deep links
// hydrate the panel without a separate route-load hop. `isTagCategory`
// rejects typos in URLs (e.g. /tags/labl) by treating them as "no
// category active" — the dynamic page itself will 404 or fall through
// to the landing prompt.
const category = $derived<TagCategory | null>(
isTagCategory(page.params.category) ? page.params.category : null
);
const selectedValue = $derived<string | null>(
typeof page.params.value === 'string' && page.params.value.length > 0
? decodeURIComponent(page.params.value)
: null
);
function onSelect(value: string | null, options: { replace?: boolean } = {}) {
if (!category) return;
void navigateToTag(category, value, options);
}
</script>
<div class="flex min-h-0 flex-1">
{#if category && !view.tagsBrowserCollapsed}
<aside
class="relative h-full shrink-0 border-r border-border bg-card/30"
style="width: {view.tagsBrowserWidth}px;"
>
<TagsBrowserSidebar {category} {selectedValue} {onSelect} />
<div
class="group absolute -right-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{
edge: 'right',
getWidth: () => view.tagsBrowserWidth,
setWidth: setTagsBrowserWidth
}}
role="separator"
aria-orientation="vertical"
aria-label="Resize tags panel"
>
<div
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</aside>
{/if}
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
{@render children?.()}
</div>
</div>

View File

@@ -1,647 +1,23 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query'; import { isTagCategory } from '$lib/stores/filters.svelte';
import {
aggregateKeywords,
countPhotos,
getAllMarks,
getPhoto,
listLabels,
listPhotos,
type AggregatedKeyword,
type PhotoMarksMap,
type PpLabel
} from '$lib/services/photoprism';
import { isAuthenticated, session, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { resizable } from '$lib/actions/resizable';
import { selection } from '$lib/stores/selection.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import SplitGrid from '$lib/components/preview/SplitGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
// Tag-flavoured surfaces, all under one route so the user can swap // `/tags` is not a reachable destination from the UI — the sidebar's
// between them without losing place. Four tabs: // "Tags" row only toggles the submenu. This page exists purely as a
// - labels classifier-derived (PhotoPrism's /labels) // redirect target so direct navigation to /tags (a stale bookmark, a
// - keywords user-typed (Details.Keywords aggregated) // legacy `?tab=`/`?drill=` URL, or someone typing it) bounces somewhere
// - ratings 1-5 star marks from mule-sidecar // sensible instead of rendering a dead-end prompt.
// - colors 4-swatch marks from mule-sidecar $effect(() => {
// Tab + drill state live in the URL so refresh / share / back work. const tab = page.url.searchParams.get('tab');
const drill = page.url.searchParams.get('drill');
type Tab = 'labels' | 'keywords' | 'ratings' | 'colors'; if (tab && isTagCategory(tab)) {
const TABS: { id: Tab; label: string }[] = [ const path = drill
{ id: 'labels', label: 'Labels (auto)' }, ? `/tags/${tab}/${encodeURIComponent(drill)}`
{ id: 'keywords', label: 'Keywords' }, : `/tags/${tab}`;
{ id: 'ratings', label: 'Ratings' }, void goto(path, { replaceState: true, keepFocus: true, noScroll: true });
{ id: 'colors', label: 'Colors' } return;
];
const PAGE_SIZE = 60;
const activeTab = $derived<Tab>(parseTab(page.url.searchParams.get('tab')));
const activePage = $derived(parsePage(page.url.searchParams.get('page')));
const drillKey = $derived(page.url.searchParams.get('drill'));
function parseTab(raw: string | null): Tab {
if (raw === 'keywords' || raw === 'ratings' || raw === 'colors') return raw;
return 'labels';
} }
function parsePage(raw: string | null): number { void goto('/', { replaceState: true, keepFocus: true, noScroll: true });
const n = raw ? parseInt(raw, 10) : 0;
return Number.isFinite(n) && n >= 0 ? n : 0;
}
function setTab(tab: Tab) {
const params = new URLSearchParams();
if (tab !== 'labels') params.set('tab', tab);
void goto(`/tags${params.size ? '?' + params : ''}`, { keepFocus: true, noScroll: true });
}
function setPage(p: number) {
const params = new URLSearchParams(page.url.searchParams);
if (p === 0) params.delete('page');
else params.set('page', String(p));
params.delete('drill');
void goto(`/tags${params.size ? '?' + params : ''}`, { keepFocus: true, noScroll: true });
}
function drillInto(key: string) {
const params = new URLSearchParams(page.url.searchParams);
params.set('drill', key);
void goto(`/tags?${params}`, { keepFocus: true, noScroll: true });
}
function clearDrill() {
const params = new URLSearchParams(page.url.searchParams);
params.delete('drill');
void goto(`/tags${params.size ? '?' + params : ''}`, {
keepFocus: true,
noScroll: true
}); });
}
// ── Data sources ─────────────────────────────────────────────────────────
// Labels and marks are always enabled while /tags is mounted so every
// tab pill can render its count badge, not just the active tab. Both
// share queryKeys with the sidebar so the fetch is deduped. Keywords
// stays lazy (it's an O(1000-photo getPhoto fan-out) — heavy).
const labelsQuery = createQuery<PpLabel[]>(() => ({
queryKey: ['labels'],
queryFn: listLabels,
enabled: isAuthenticated()
}));
// Photo-count for the Labels tab pill — counts pictures that carry any
// label, not distinct label categories. Mirrors (and shares cache with)
// the LeftSidebar's labels badge by reusing its scoping rules + queryKey
// so the two reads dedupe through svelte-query.
const isAdminUser = $derived(session.user?.Role === 'admin');
function scopedLabelsFilter(): string {
const bp = userBasePath();
const base = 'all:true label:*';
if (isAdminUser && bp === '') return base;
if (!isAdminUser && bp === '') return 'uid:none';
return `${base} path:"${bp}*"`;
}
const labelsPhotoCountQuery = createQuery<number>(() => ({
queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser],
queryFn: () => countPhotos(scopedLabelsFilter()),
enabled: isAuthenticated(),
staleTime: 60_000
}));
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
queryKey: ['photos', 'keywords'],
queryFn: aggregateKeywords,
enabled: isAuthenticated() && activeTab === 'keywords',
staleTime: 5 * 60_000
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
// Marks → photo resolution. The sidecar's marks map is keyed by UID;
// we need a thumbnail per UID, so pool the most-recent photo list.
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'marks-pool'],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated() && (activeTab === 'ratings' || activeTab === 'colors')
}));
// ── Group builders ───────────────────────────────────────────────────────
interface RatingGroup {
rating: number;
photos: PpPhoto[];
}
const ratingGroups = $derived<RatingGroup[]>(
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
);
function buildRatingGroups(
marks: PhotoMarksMap | undefined,
pool: PpPhoto[] | undefined
): RatingGroup[] {
if (!marks || !pool) return [];
const byUid = new Map(pool.map((p) => [p.UID, p]));
const buckets = new Map<number, PpPhoto[]>();
for (const [uid, mark] of Object.entries(marks)) {
const r = mark.rating ?? 0;
if (r <= 0) continue;
const photo = byUid.get(uid);
if (!photo) continue;
const arr = buckets.get(r) ?? [];
arr.push(photo);
buckets.set(r, arr);
}
const out: RatingGroup[] = [];
for (let r = 5; r >= 1; r--) {
const photos = buckets.get(r);
if (photos && photos.length > 0) out.push({ rating: r, photos });
}
return out;
}
// Titles follow the Lightroom culling convention so users see the
// swatch's *intent* (reject/review/pick/keep), not just its color.
// Used both as tooltip on swatches and as the visible card label in
// the colors-tab picker grid below.
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
interface ColorGroup {
key: string;
title: string;
bg: string;
photos: PpPhoto[];
}
const colorGroups = $derived<ColorGroup[]>(
buildColorGroups(marksQuery.data, marksPoolQuery.data)
);
function buildColorGroups(
marks: PhotoMarksMap | undefined,
pool: PpPhoto[] | undefined
): ColorGroup[] {
if (!marks || !pool) return [];
const byUid = new Map(pool.map((p) => [p.UID, p]));
const buckets = new Map<string, PpPhoto[]>();
for (const [uid, mark] of Object.entries(marks)) {
const c = mark.color;
if (!c) continue;
const photo = byUid.get(uid);
if (!photo) continue;
const arr = buckets.get(c) ?? [];
arr.push(photo);
buckets.set(c, arr);
}
const out: ColorGroup[] = [];
for (const swatch of COLOR_SWATCHES) {
const photos = buckets.get(swatch.key);
if (photos && photos.length > 0) out.push({ ...swatch, photos });
}
return out;
}
// ── Per-tab badge counts ─────────────────────────────────────────────────
// Each pill shows the number of *photos* a tab covers (not categories),
// so all four tabs read on the same scale and match the sidebar's Tags
// badge. Keywords stays as a distinct-keyword count because the keywords
// pool is the only one without a cheap photo-rollup query.
// `undefined` means the underlying query hasn't resolved yet — the badge
// is skipped rather than showing a misleading 0.
const labelsCount = $derived<number | undefined>(labelsPhotoCountQuery.data);
const keywordsCount = $derived<number | undefined>(keywordsQuery.data?.length);
const ratedPhotosCount = $derived<number | undefined>(
marksQuery.data ? countMarked(marksQuery.data, 'rating') : undefined
);
const coloredPhotosCount = $derived<number | undefined>(
marksQuery.data ? countMarked(marksQuery.data, 'color') : undefined
);
function countMarked(marks: PhotoMarksMap, field: 'rating' | 'color'): number {
let n = 0;
for (const m of Object.values(marks)) {
if (field === 'rating' ? (m.rating ?? 0) > 0 : Boolean(m.color)) n++;
}
return n;
}
function tabCount(tab: Tab): number | undefined {
if (tab === 'labels') return labelsCount;
if (tab === 'keywords') return keywordsCount;
if (tab === 'ratings') return ratedPhotosCount;
return coloredPhotosCount;
}
// ── Sorted full lists per tab (most-common first), then page slice ───────
const labelsSorted = $derived(
[...(labelsQuery.data ?? [])].sort((a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0))
);
const keywordsSorted = $derived(keywordsQuery.data ?? []); // already sorted
// Ratings/Colors are small fixed-cardinality buckets — no pagination.
const totalPages = $derived.by(() => {
if (activeTab === 'labels') return Math.ceil(labelsSorted.length / PAGE_SIZE);
if (activeTab === 'keywords') return Math.ceil(keywordsSorted.length / PAGE_SIZE);
return 0;
});
const pageSlice = $derived.by(() => {
const start = activePage * PAGE_SIZE;
const end = start + PAGE_SIZE;
if (activeTab === 'labels') return labelsSorted.slice(start, end);
if (activeTab === 'keywords') return keywordsSorted.slice(start, end);
return [];
});
// ── Drill-down: server-side q-DSL query for the picked group/tile ───────
const drillQ = $derived.by(() => {
if (!drillKey) return '';
if (activeTab === 'labels') return `label:${drillKey}`;
if (activeTab === 'keywords') return `keywords:${drillKey}`;
return ''; // ratings/colors resolve locally from the marks pool
});
const drillPhotosQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'tag-drill', activeTab, drillQ],
queryFn: () =>
listPhotos({
q: drillQ,
count: 1000,
order: 'newest',
merged: true
}),
enabled: isAuthenticated() && Boolean(drillQ)
}));
// Local drill resolution (ratings / colors): the marks pool already
// carries the photos; just pick the bucket the user clicked.
const localDrillPhotos = $derived.by<PpPhoto[]>(() => {
if (!drillKey) return [];
if (activeTab === 'ratings') {
const r = parseInt(drillKey, 10);
return ratingGroups.find((g) => g.rating === r)?.photos ?? [];
}
if (activeTab === 'colors') {
return colorGroups.find((g) => g.key === drillKey)?.photos ?? [];
}
return [];
});
const drillPhotos = $derived<PpPhoto[]>(
activeTab === 'ratings' || activeTab === 'colors'
? localDrillPhotos
: drillPhotosQuery.data ?? []
);
// Metadata for the focused tile in the drill-in PhotoGrid. Mirrors the
// timeline (+page.svelte) and /review wiring so the right sidebar
// reads consistently across views.
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''],
queryFn: () =>
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
enabled: isAuthenticated() && Boolean(selection.focused),
staleTime: 0
}));
function starLabel(rating: number): string {
return '★'.repeat(rating);
}
// Friendly label for the current drill — used in the toolbar pill.
const drillTitle = $derived.by(() => {
if (!drillKey) return '';
if (activeTab === 'labels') {
const hit = (labelsQuery.data ?? []).find(
(l) => (l.CustomSlug ?? l.Slug) === drillKey
);
return hit?.Name ?? drillKey;
}
if (activeTab === 'keywords') return drillKey;
if (activeTab === 'ratings') return starLabel(parseInt(drillKey, 10));
if (activeTab === 'colors') {
return COLOR_SWATCHES.find((c) => c.key === drillKey)?.title ?? drillKey;
}
return drillKey;
});
const drillCount = $derived<number>(drillPhotos.length);
</script> </script>
<Toolbar showRightToggle={Boolean(drillKey)}>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Tags
</span>
{#if drillKey}
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={clearDrill}
>
← Back
</button>
<span class="text-[11px] font-medium">{drillTitle}</span>
<span class="text-[11px] text-muted-foreground">
{drillCount} photo{drillCount === 1 ? '' : 's'}
</span>
{:else}
<!-- Pill row of tabs. The active tab uses the same primary-tinted
treatment as the sidebar's active rows so the active state
reads consistently across the app. -->
<div class="flex items-center gap-1">
{#each TABS as t (t.id)}
{@const count = tabCount(t.id)}
<button
type="button"
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
onclick={() => setTab(t.id)}
>
{t.label}
{#if count !== undefined}
<span
class="tabular-nums text-[10px] {activeTab === t.id
? 'text-primary/70'
: 'text-muted-foreground/70'}"
>
{count}
</span>
{/if}
</button>
{/each}
</div>
{/if}
{#snippet trailing()}
{#if !drillKey && (activeTab === 'labels' || activeTab === 'keywords') && totalPages > 1}
<!-- Page navigator only renders when there's actually a page to
turn — the ratings/colors tabs cap at 5 / 4 buckets which
always fits a single screen. -->
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={activePage === 0}
onclick={() => setPage(activePage - 1)}
>
</button>
<span class="text-[11px] tabular-nums text-muted-foreground">
{activePage + 1} / {totalPages}
</span>
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={activePage >= totalPages - 1}
onclick={() => setPage(activePage + 1)}
>
</button>
{/if}
{/snippet}
</Toolbar>
{#if drillKey}
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<SplitGrid>
{#snippet preview()}
<InlinePreview uid={selection.focused} order={selection.order} />
{/snippet}
{#snippet grid()}
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
<!-- Drill-down photo grid. Reused for all four tabs; the q-DSL
drives labels/keywords while ratings/colors resolve locally
from the marks pool already in cache. -->
{#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}
<SkeletonGrid />
{:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError}
<p class="text-sm text-destructive">Failed to load photos.</p>
{:else if drillPhotos.length === 0}
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
{:else}
<PhotoGrid photos={drillPhotos} />
{/if}
</main>
{/snippet}
</SplitGrid>
</div>
{#if !view.rightSidebarCollapsed}
<aside
class="relative h-full shrink-0 border-l border-border bg-card/30"
style="width: {view.rightSidebarWidth}px;"
>
<div class="h-full overflow-y-auto">
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
{:else}
<div class="space-y-2 p-4 text-center">
<div class="text-xl"></div>
<p class="text-xs text-muted-foreground">
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click
on a thumbnail to view its metadata here.
</p>
</div>
{/if}
</div>
<div
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{
edge: 'left',
getWidth: () => view.rightSidebarWidth,
setWidth: setRightSidebarWidth
}}
role="separator"
aria-orientation="vertical"
aria-label="Resize info panel"
>
<div
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</aside>
{/if}
</div>
{:else}
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if activeTab === 'labels'}
{#if labelsQuery.isPending}
<SkeletonGrid />
{:else if labelsQuery.isError}
<p class="text-sm text-destructive">Failed to load labels.</p>
{:else if labelsSorted.length === 0}
<p class="text-sm text-muted-foreground">
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content;
if the indexer hasn't run on real photos yet, the list will be empty.
</p>
{:else}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each pageSlice as item (item)}
{@const label = item as PpLabel}
<button
type="button"
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
onclick={() => drillInto(label.CustomSlug ?? label.Slug)}
>
{#if label.Thumb}
<img
src={thumbUrl(label.Thumb, 'tile_500')}
alt={label.Name}
loading="lazy"
class="h-full w-full object-cover transition group-hover:scale-105"
/>
{/if}
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
>
<span class="truncate font-medium">{label.Name}</span>
<span class="text-muted-foreground">{label.PhotoCount ?? 0}</span>
</div>
</button>
{/each}
</div>
{/if}
{:else if activeTab === 'keywords'}
{#if keywordsQuery.isPending}
<SkeletonGrid />
{:else if keywordsQuery.isError}
<p class="text-sm text-destructive">Failed to load keywords.</p>
{:else if keywordsSorted.length === 0}
<p class="text-sm text-muted-foreground">
No user-set keywords yet. Add them from a photo's right-sidebar metadata panel.
</p>
{:else}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each pageSlice as item (item)}
{@const kw = item as AggregatedKeyword}
<button
type="button"
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
onclick={() => drillInto(kw.keyword)}
>
<img
src={thumbUrl(kw.sampleHash, 'tile_500')}
alt={kw.keyword}
loading="lazy"
class="h-full w-full object-cover transition group-hover:scale-105"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
>
<span class="truncate font-medium">{kw.keyword}</span>
<span class="text-muted-foreground">{kw.count}</span>
</div>
</button>
{/each}
</div>
{/if}
{:else if activeTab === 'ratings'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<SkeletonGrid count={5} />
{:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load ratings.</p>
{:else if ratingGroups.length === 0}
<p class="text-sm text-muted-foreground">
No rated photos yet. Open a photo and use the star row in the right sidebar (or
15 in bulk mode) to rate it.
</p>
{:else}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each ratingGroups as group (group.rating)}
{@const rep = group.photos[0]}
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
<button
type="button"
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
onclick={() => drillInto(String(group.rating))}
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={starLabel(group.rating)}
loading="lazy"
class="h-full w-full object-cover transition group-hover:scale-105"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
>
<span class="truncate font-medium text-yellow-500">
{starLabel(group.rating)}
</span>
<span class="text-muted-foreground">{group.photos.length}</span>
</div>
</button>
{/each}
</div>
{/if}
{:else if activeTab === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<SkeletonGrid count={4} />
{:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load colors.</p>
{:else if colorGroups.length === 0}
<p class="text-sm text-muted-foreground">
No color labels yet. Open a photo and use the four-swatch row in the right
sidebar to tag it.
</p>
{:else}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each colorGroups as group (group.key)}
{@const rep = group.photos[0]}
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
<button
type="button"
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
onclick={() => drillInto(group.key)}
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={group.title}
loading="lazy"
class="h-full w-full object-cover transition group-hover:scale-105"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
>
<span class="flex items-center gap-1.5 truncate font-medium">
<span class="h-2.5 w-2.5 rounded-full {group.bg}"></span>
{group.title}
</span>
<span class="text-muted-foreground">{group.photos.length}</span>
</div>
</button>
{/each}
</div>
{/if}
{/if}
</main>
{/if}
<BulkActionBar />

View File

@@ -0,0 +1,259 @@
<script lang="ts">
import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query';
import {
getAllMarks,
getPhoto,
listLabels,
listPhotos,
type PhotoMarksMap,
type PpLabel
} from '$lib/services/photoprism';
import {
filtersToQ,
isTagCategory,
setTagFilter,
type TagCategory
} from '$lib/stores/filters.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { resizable } from '$lib/actions/resizable';
import { selection } from '$lib/stores/selection.svelte';
import {
buildColorGroups,
buildRatingGroups,
COLOR_SWATCHES,
starLabel
} from '$lib/utils/tagGroups';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
// URL-driven category + value. `isTagCategory` rejects typos so a stray
// /tags/foo URL falls back to the no-category prompt rather than firing
// PhotoPrism queries with an invalid DSL clause.
const category = $derived<TagCategory | null>(
isTagCategory(page.params.category) ? page.params.category : null
);
const selectedValue = $derived<string | null>(
typeof page.params.value === 'string' && page.params.value.length > 0
? decodeURIComponent(page.params.value)
: null
);
// Mirror URL into the shared filter store so any other consumer of
// `filters` (e.g. cross-route navigation back to `/`) sees the active
// tag filter, and so `filtersToQ()` produces the correct DSL clause
// for the photo grid. Reset on unmount so leaving /tags doesn't leak
// tag filters back to the timeline.
$effect(() => {
setTagFilter(category, selectedValue);
return () => setTagFilter(null, null);
});
// ── Labels / Keywords resolve via PhotoPrism's q DSL ─────────────────────
// Tag browse is library-wide (within the user's ACL): explicitly NOT
// inheriting the timeline's folder/section/search filters, because the
// counts in the panel rows are themselves library-wide and a folder
// filter on top would make drill counts disagree with the badges (a
// label badge of 157 could otherwise drill into 0 photos because the
// session is scoped to a folder that has none of them).
const useServer = $derived(category === 'labels' || category === 'keywords');
const drillQ = $derived(
useServer && selectedValue
? filtersToQ({
section: 'all-photos',
heapUid: null,
folderPath: null,
search: '',
tagCategory: category,
tagValue: selectedValue
})
: ''
);
const serverPhotosQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'tag-drill', category, drillQ],
queryFn: () =>
listPhotos({ q: drillQ, count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated() && useServer && Boolean(selectedValue)
}));
// ── Colors / Ratings resolve locally from the marks pool ─────────────────
const useLocal = $derived(category === 'colors' || category === 'ratings');
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated() && useLocal,
staleTime: 60_000
}));
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'marks-pool'],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated() && useLocal
}));
const ratingGroups = $derived(
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
);
const colorGroups = $derived(
buildColorGroups(marksQuery.data, marksPoolQuery.data)
);
const localPhotos = $derived.by<PpPhoto[]>(() => {
if (!selectedValue) return [];
if (category === 'ratings') {
const r = parseInt(selectedValue, 10);
return ratingGroups.find((g) => g.rating === r)?.photos ?? [];
}
if (category === 'colors') {
return colorGroups.find((g) => g.key === selectedValue)?.photos ?? [];
}
return [];
});
const drillPhotos = $derived<PpPhoto[]>(
useLocal ? localPhotos : (serverPhotosQuery.data ?? [])
);
const drillCount = $derived(drillPhotos.length);
// ── Title pill — what's currently filtered ───────────────────────────────
const labelsQuery = createQuery<PpLabel[]>(() => ({
queryKey: ['labels'],
queryFn: listLabels,
enabled: isAuthenticated() && category === 'labels'
}));
const drillTitle = $derived.by(() => {
if (!selectedValue) return '';
if (category === 'labels') {
const hit = (labelsQuery.data ?? []).find(
(l) => (l.CustomSlug ?? l.Slug) === selectedValue
);
return hit?.Name ?? selectedValue;
}
if (category === 'keywords') return selectedValue;
if (category === 'ratings') return starLabel(parseInt(selectedValue, 10));
if (category === 'colors') {
return (
COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue
);
}
return selectedValue;
});
// Right-sidebar metadata for the focused tile. Mirrors the timeline
// + review wiring so the metadata panel reads consistently here.
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''],
queryFn: () =>
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
enabled: isAuthenticated() && Boolean(selection.focused),
staleTime: 0
}));
const showSkeleton = $derived(
useServer
? serverPhotosQuery.isPending && Boolean(selectedValue)
: useLocal
? (marksQuery.isPending || marksPoolQuery.isPending) &&
Boolean(selectedValue)
: false
);
const showError = $derived(
useServer
? serverPhotosQuery.isError
: useLocal
? marksQuery.isError || marksPoolQuery.isError
: false
);
</script>
<Toolbar showRightToggle={Boolean(selectedValue)}>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Tags
</span>
{#if category}
<span class="text-[11px] capitalize text-muted-foreground">{category}</span>
{/if}
{#if selectedValue}
<span class="text-[11px] font-medium">{drillTitle}</span>
<span class="text-[11px] text-muted-foreground">
{drillCount} photo{drillCount === 1 ? '' : 's'}
</span>
{/if}
</Toolbar>
{#if !selectedValue}
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
<div class="max-w-sm space-y-2 text-center">
<p class="text-sm font-medium">Pick a {category ?? 'tag'} from the sidebar</p>
<p class="text-xs text-muted-foreground">
Click a row in the panel on the left to filter the photo grid by that tag.
</p>
</div>
</main>
{:else}
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if showSkeleton}
<SkeletonGrid />
{:else if showError}
<p class="text-sm text-destructive">Failed to load photos.</p>
{:else if drillPhotos.length === 0}
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
{:else}
<PhotoGrid photos={drillPhotos} />
{/if}
</main>
</div>
{#if !view.rightSidebarCollapsed}
<aside
class="relative h-full shrink-0 border-l border-border bg-card/30"
style="width: {view.rightSidebarWidth}px;"
>
<div class="h-full overflow-y-auto">
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
{:else}
<div class="space-y-2 p-4 text-center">
<div class="text-xl"></div>
<p class="text-xs text-muted-foreground">
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here.
</p>
</div>
{/if}
</div>
<div
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{
edge: 'left',
getWidth: () => view.rightSidebarWidth,
setWidth: setRightSidebarWidth
}}
role="separator"
aria-orientation="vertical"
aria-label="Resize info panel"
>
<div
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
></div>
</div>
</aside>
{/if}
</div>
{/if}
<BulkActionBar />