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

@@ -16,6 +16,7 @@
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
let { children } = $props();
@@ -108,6 +109,11 @@
</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}
{@render children?.()}
{/if}

View File

@@ -35,6 +35,7 @@
setOrder,
} from "$lib/stores/selection.svelte";
import {
openPreview,
setRightSidebarWidth,
setThumbnailSize,
THUMBNAIL_SIZE_LABELS,
@@ -51,11 +52,9 @@
} from "$lib/actions/visibleRange";
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 PhotoTile from "$lib/components/timeline/PhotoTile.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";
import { type PpPhoto } from "$lib/types/photoprism";
@@ -668,6 +667,7 @@
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
openPreview();
}
// 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.
-->
<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
bind:this={scrollRoot}
class="flex-1 overflow-y-auto outline-none focus:outline-none"
@@ -932,8 +927,6 @@
{/if}
</div>
</main>
{/snippet}
</SplitGrid>
<BulkActionBar />
</div>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
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
// surrounding grid here — this surface is a single-photo viewer.
const uid = $derived((page.params.uid ?? null) as string | null);
@@ -10,5 +10,5 @@
</script>
<div class="flex min-h-0 flex-1">
<InlinePreview {uid} {order} />
<PreviewPane {uid} {order} showChevrons={false} />
</div>

View File

@@ -51,8 +51,6 @@
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.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 Tab = CauseKey | DupTab;
@@ -226,38 +224,31 @@
{:else}
<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 px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
Could not load review queue: {reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
</p>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>The review queue is empty.</p>
<p class="text-xs">
PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here. The Stacks and Cross-folder tabs above stay
available for duplicate cleanup.
</p>
</div>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} />
{/key}
{/if}
</main>
{/snippet}
</SplitGrid>
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
Could not load review queue: {reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
</p>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>The review queue is empty.</p>
<p class="text-xs">
PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here. The Stacks and Cross-folder tabs above stay
available for duplicate cleanup.
</p>
</div>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} />
{/key}
{/if}
</main>
<BulkActionBar />
</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">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query';
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';
import { isTagCategory } from '$lib/stores/filters.svelte';
// Tag-flavoured surfaces, all under one route so the user can swap
// between them without losing place. Four tabs:
// - labels classifier-derived (PhotoPrism's /labels)
// - keywords user-typed (Details.Keywords aggregated)
// - ratings 1-5 star marks from mule-sidecar
// - colors 4-swatch marks from mule-sidecar
// Tab + drill state live in the URL so refresh / share / back work.
type Tab = 'labels' | 'keywords' | 'ratings' | 'colors';
const TABS: { id: Tab; label: string }[] = [
{ id: 'labels', label: 'Labels (auto)' },
{ id: 'keywords', label: 'Keywords' },
{ id: 'ratings', label: 'Ratings' },
{ id: 'colors', label: 'Colors' }
];
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 {
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);
// `/tags` is not a reachable destination from the UI — the sidebar's
// "Tags" row only toggles the submenu. This page exists purely as a
// redirect target so direct navigation to /tags (a stale bookmark, a
// legacy `?tab=`/`?drill=` URL, or someone typing it) bounces somewhere
// sensible instead of rendering a dead-end prompt.
$effect(() => {
const tab = page.url.searchParams.get('tab');
const drill = page.url.searchParams.get('drill');
if (tab && isTagCategory(tab)) {
const path = drill
? `/tags/${tab}/${encodeURIComponent(drill)}`
: `/tags/${tab}`;
void goto(path, { replaceState: true, keepFocus: true, noScroll: true });
return;
}
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;
void goto('/', { replaceState: true, keepFocus: true, noScroll: true });
});
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>
<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 />