feat(web): /tags tabs + keyword surfacing + dup tab restyle

- /tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors),
  URL-driven with pagination on the label + keyword grids; ratings and
  colors stay as fixed buckets.
- /duplicates tabs (Stacks / Cross-folder) restyled to pill row in the
  Toolbar to match /tags; tab state moved into the route and bound to
  ?tab=...
- New aggregateKeywords() service fans out per-photo getPhoto calls so
  user-typed Details.Keywords surface on /tags (PhotoPrism's /labels
  only returns classifier output).
- RightSidebar renders photo.Labels[] as dashed-border chips after the
  Keywords section, each linking to /?q=label:slug.
- /colors and /ratings routes redirect to /tags?tab=colors|ratings so
  old bookmarks still land somewhere useful; LeftSidebar drops their
  entries and the Tags badge now sums labels + ratings + colors.
- listFolderCounts dedupes by UID (merged=false returns one row per
  FILE, so HEIC+JPG / Live Photo / RAW+JPG pairs were inflating folder
  counts ~2x).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:19:44 +02:00
parent d5e4f23c0f
commit d35de8a2a9
8 changed files with 695 additions and 554 deletions

View File

@@ -1,165 +1,13 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
getAllMarks,
listPhotos,
type PhotoMarksMap
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
// PhotoPrism's Color is auto-derived from image content — the user-set
// label lives in mule-sidecar's marks map alongside ratings. Pool the
// recent photo list so we can resolve thumbnail hashes for each labelled
// UID. Matches the four-swatch palette used by RightSidebar.
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const photosQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'colors-pool'],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated()
}));
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
];
interface ColorGroup {
key: string;
title: string;
bg: string;
photos: PpPhoto[];
}
const groups = $derived<ColorGroup[]>(buildGroups(marksQuery.data, photosQuery.data));
function buildGroups(
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;
}
let selected = $state<string | null>(null);
const selectedGroup = $derived(
selected !== null ? groups.find((g) => g.key === selected) ?? null : null
);
function pickGroup(key: string) {
selected = key;
}
function clearSelection() {
selected = null;
}
// Colors moved into /tags as a tab. Redirect on mount so old bookmarks
// and any in-app links still land somewhere meaningful. Uses
// replaceState so the browser back button skips the redirect hop.
onMount(() => {
void goto('/tags?tab=colors', { replaceState: true });
});
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Colors
</span>
{#if selectedGroup}
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={clearSelection}
>
← Back
</button>
<span class="flex items-center gap-1.5 text-[11px] font-medium">
<span class="h-2.5 w-2.5 rounded-full {selectedGroup.bg}"></span>
{selectedGroup.title}
</span>
<span class="text-[11px] text-muted-foreground">
{selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'}
</span>
{/if}
{#snippet trailing()}
<span class="text-[11px] text-muted-foreground">
{groups.length} color{groups.length === 1 ? '' : 's'}
</span>
{/snippet}
</Toolbar>
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if marksQuery.isPending || photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading colors…</p>
{:else if marksQuery.isError || photosQuery.isError}
<p class="text-sm text-destructive">Failed to load colors.</p>
{:else if groups.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 if selectedGroup}
<PhotoGrid photos={selectedGroup.photos} />
{:else}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each groups 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={() => pickGroup(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}
</main>
<BulkActionBar />
<p class="p-6 text-sm text-muted-foreground">Redirecting to Tags · Colors…</p>

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query';
import {
listDuplicateGroups,
@@ -14,21 +16,56 @@
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
// Same pill-tab pattern as /tags: tab state is URL-driven so the user
// can share / refresh / hit Back and land on the right panel.
type Tab = 'stacks' | 'cross-folder';
const TABS: { id: Tab; label: string }[] = [
{ id: 'stacks', label: 'Stacks' },
{ id: 'cross-folder', label: 'Cross-folder' }
];
const activeTab = $derived<Tab>(parseTab(page.url.searchParams.get('tab')));
function parseTab(raw: string | null): Tab {
return raw === 'cross-folder' ? 'cross-folder' : 'stacks';
}
function setTab(tab: Tab) {
const params = new URLSearchParams();
if (tab !== 'stacks') params.set('tab', tab);
void goto(`/duplicates${params.size ? '?' + params : ''}`, {
keepFocus: true,
noScroll: true
});
}
// Stale-time matches mule-image's DuplicatesView (30 s) so quick
// toolbar bounces don't refetch the (potentially expensive) stack
// listing. Invalidation by mutations is explicit, not time-driven.
const dupesQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
enabled: isAuthenticated(),
enabled: isAuthenticated() && activeTab === 'stacks',
staleTime: 30_000
}));
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Duplicates · stacks
Duplicates
</span>
<!-- Tabs mirror /tags' pill row visually: same height, same active
treatment, same hover affordance. -->
<div class="flex items-center gap-1">
{#each TABS as t (t.id)}
<button
type="button"
class="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}
</button>
{/each}
</div>
{#snippet trailing()}
<!-- Thumbnail-size control mirrors the timeline Toolbar. The view
store is global, so the picked size persists across routes —
@@ -52,14 +89,12 @@
</button>
{/each}
</div>
<span class="text-[11px] text-muted-foreground">
{dupesQuery.data?.length ?? 0} group{dupesQuery.data?.length === 1 ? '' : 's'}
</span>
{/snippet}
</Toolbar>
<main class="min-h-0 flex-1 overflow-y-auto">
<DuplicatesView
{activeTab}
groups={dupesQuery.data ?? []}
pending={dupesQuery.isPending}
error={dupesQuery.error}

View File

@@ -1,156 +1,13 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import {
getAllMarks,
listPhotos,
type PhotoMarksMap
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
// they live in mule-sidecar's marks map. We fan in two queries: marks
// (UID → {rating, color}) and a recent slice of photos (UID → photo)
// so we can resolve the thumbnail hash for each rated UID.
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const photosQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'ratings-pool'],
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
enabled: isAuthenticated()
}));
interface RatingGroup {
rating: number;
photos: PpPhoto[];
}
const groups = $derived<RatingGroup[]>(buildGroups(marksQuery.data, photosQuery.data));
function buildGroups(
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;
}
let selected = $state<number | null>(null);
const selectedGroup = $derived(
selected !== null ? groups.find((g) => g.rating === selected) ?? null : null
);
function pickGroup(rating: number) {
selected = rating;
}
function clearSelection() {
selected = null;
}
function starLabel(rating: number): string {
return '★'.repeat(rating);
}
// Ratings moved into /tags as a tab. Redirect on mount so old bookmarks
// and any in-app links still land somewhere meaningful. Uses
// replaceState so the browser back button skips the redirect hop.
onMount(() => {
void goto('/tags?tab=ratings', { replaceState: true });
});
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Ratings
</span>
{#if selectedGroup}
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={clearSelection}
>
← Back
</button>
<span class="text-[11px] font-medium text-yellow-500">
{starLabel(selectedGroup.rating)}
</span>
<span class="text-[11px] text-muted-foreground">
{selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'}
</span>
{/if}
{#snippet trailing()}
<span class="text-[11px] text-muted-foreground">
{groups.length} rating{groups.length === 1 ? '' : 's'}
</span>
{/snippet}
</Toolbar>
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if marksQuery.isPending || photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading ratings…</p>
{:else if marksQuery.isError || photosQuery.isError}
<p class="text-sm text-destructive">Failed to load ratings.</p>
{:else if groups.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 if selectedGroup}
<PhotoGrid photos={selectedGroup.photos} />
{:else}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each groups 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={() => pickGroup(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}
</main>
<BulkActionBar />
<p class="p-6 text-sm text-muted-foreground">Redirecting to Tags · Ratings…</p>

View File

@@ -1,81 +1,322 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createQuery } from '@tanstack/svelte-query';
import { listLabels, listPhotos, type PpLabel } from '$lib/services/photoprism';
import {
aggregateKeywords,
getAllMarks,
listLabels,
listPhotos,
type AggregatedKeyword,
type PhotoMarksMap,
type PpLabel
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { type PpPhoto } from '$lib/types/photoprism';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
// Mirror /colors: a label grid that drills into a photo grid in place,
// instead of navigating away to the timeline. Stays inside /tags so
// the user keeps their place when they back out.
// 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 ─────────────────────────────────────────────────────────
const labelsQuery = createQuery<PpLabel[]>(() => ({
queryKey: ['labels'],
queryFn: listLabels,
enabled: isAuthenticated()
enabled: isAuthenticated() && activeTab === 'labels'
}));
let selectedSlug = $state<string | null>(null);
const selectedLabel = $derived(
selectedSlug !== null
? (labelsQuery.data ?? []).find(
(l) => (l.CustomSlug ?? l.Slug) === selectedSlug
) ?? null
: null
);
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
queryKey: ['photos', 'keywords'],
queryFn: aggregateKeywords,
enabled: isAuthenticated() && activeTab === 'keywords',
staleTime: 5 * 60_000
}));
// Photo pool for the selected label. PhotoPrism's q-DSL filters
// server-side; we cap at 1000 (the server's hard ceiling) to keep the
// page reactive without paginating in place.
const labelPhotosQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'label', selectedSlug ?? ''],
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated() && (activeTab === 'ratings' || activeTab === 'colors'),
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;
}
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
];
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;
}
// ── 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: `label:${selectedSlug}`,
q: drillQ,
count: 1000,
order: 'newest',
merged: true
}),
enabled: isAuthenticated() && Boolean(selectedSlug)
enabled: isAuthenticated() && Boolean(drillQ)
}));
function pickLabel(slug: string) {
selectedSlug = slug;
}
function clearSelection() {
selectedSlug = null;
// 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 ?? []
);
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>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Tags
</span>
{#if selectedLabel}
{#if drillKey}
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={clearSelection}
onclick={clearDrill}
>
← Back
</button>
<span class="flex items-center gap-1.5 text-[11px] font-medium">
{selectedLabel.Name}
</span>
<span class="text-[11px] font-medium">{drillTitle}</span>
<span class="text-[11px] text-muted-foreground">
{labelPhotosQuery.data?.length ?? selectedLabel.PhotoCount ?? 0} photo{(labelPhotosQuery
.data?.length ?? 0) === 1
? ''
: 's'}
{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)}
<button
type="button"
class="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}
</button>
{/each}
</div>
{/if}
{#snippet trailing()}
<span class="text-[11px] text-muted-foreground">
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
</span>
{#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>
@@ -83,53 +324,185 @@
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if labelsQuery.isPending}
<p class="text-sm text-muted-foreground">Loading labels…</p>
{:else if labelsQuery.isError}
<p class="text-sm text-destructive">Failed to load labels.</p>
{:else if (labelsQuery.data ?? []).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 if selectedLabel}
{#if labelPhotosQuery.isPending}
{#if drillKey}
<!-- 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}
<p class="text-sm text-muted-foreground">Loading photos…</p>
{:else if labelPhotosQuery.isError}
<p class="text-sm text-destructive">Failed to load photos for this label.</p>
{:else if (labelPhotosQuery.data ?? []).length === 0}
<p class="text-sm text-muted-foreground">No photos tagged with this label.</p>
{: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={labelPhotosQuery.data ?? []} />
<PhotoGrid photos={drillPhotos} />
{/if}
{:else}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each labelsQuery.data ?? [] as label (label.UID)}
<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={() => pickLabel(label.CustomSlug ?? label.Slug)}
>
{#if label.Thumb}
{:else if activeTab === 'labels'}
{#if labelsQuery.isPending}
<p class="text-sm text-muted-foreground">Loading labels…</p>
{: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}
<p class="text-sm text-muted-foreground">
Loading keywords…
<br />
<span class="text-[11px]">
This walks every photo's metadata once — the result is cached after the first load.
</span>
</p>
{: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(label.Thumb, 'tile_500')}
alt={label.Name}
src={thumbUrl(kw.sampleHash, 'tile_500')}
alt={kw.keyword}
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"
<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}
<p class="text-sm text-muted-foreground">Loading ratings…</p>
{: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))}
>
<span class="truncate font-medium">{label.Name}</span>
<span class="text-muted-foreground">{label.PhotoCount ?? 0}</span>
</div>
</button>
{/each}
</div>
<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}
<p class="text-sm text-muted-foreground">Loading colors…</p>
{: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>