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,5 +1,6 @@
<!--
Duplicate-resolution page body. Two tabs:
Duplicate-resolution page body. Two panels driven by the parent
route's `activeTab` prop (URL-bound):
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via
@@ -14,6 +15,9 @@
The cross-folder scan is opt-in (button-triggered) rather than
auto-run because it's an O(disk) operation. With size pre-filtering
the scan stays fast (~250ms for 400 files in practice).
Tabs themselves render in the parent route's Toolbar so they line up
visually with the `/tags` pill row.
-->
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -26,18 +30,18 @@
import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
type Tab = 'stacks' | 'cross-folder';
interface Props {
activeTab: Tab;
groups: DuplicateGroup[];
pending: boolean;
error: unknown;
}
let { groups, pending, error }: Props = $props();
let { activeTab, groups, pending, error }: Props = $props();
const qc = useQueryClient();
type Tab = 'stacks' | 'cross-folder';
let activeTab = $state<Tab>('stacks');
// Cross-folder scan is a manually-triggered query: `enabled` stays
// false until the user clicks "Scan filesystem". Subsequent clicks
// invalidate the cache so each press kicks a fresh scan.
@@ -45,7 +49,7 @@
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryFn: scanCrossFolderDuplicates,
enabled: scanRequested,
enabled: scanRequested && activeTab === 'cross-folder',
staleTime: 5 * 60_000
}));
@@ -67,159 +71,89 @@
}
});
const stackCount = $derived(groups.length);
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
// Tabs: only the visible card under the active tab should auto-focus.
// We pass `autoFocus={i === 0}` into the FIRST card of the active tab
// (and only when that tab is selected) so keyboard navigation lands
// on the right place when the user switches tabs.
function tabBtnClass(tab: Tab) {
const base =
'inline-flex items-center gap-2 border-b-2 px-3 py-1.5 text-sm transition-colors';
return tab === activeTab
? `${base} border-foreground text-foreground`
: `${base} border-transparent text-muted-foreground hover:text-foreground`;
}
</script>
<div class="space-y-4">
<!-- Tab bar — sticky so it stays visible while the panel scrolls.
Same horizontal padding as the panels below so labels line up. -->
<div
role="tablist"
class="sticky top-0 z-10 flex items-center gap-1 border-b border-border bg-background px-6"
>
<button
type="button"
role="tab"
aria-selected={activeTab === 'stacks'}
class={tabBtnClass('stacks')}
onclick={() => (activeTab = 'stacks')}
>
Stacks
<span
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{pending ? '…' : stackCount}
</span>
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'cross-folder'}
class={tabBtnClass('cross-folder')}
onclick={() => (activeTab = 'cross-folder')}
>
Cross-folder
<span
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{#if !scanRequested}
·
{:else if crossQuery.isFetching && !crossQuery.data}
{:else}
{crossCount}
{/if}
</span>
</button>
<!-- Stacks tab ----------------------------------------------------- -->
{#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
{#if pending}
<p class="text-sm text-muted-foreground">Loading stacks…</p>
{:else if error}
<p class="text-sm text-destructive">
Could not load stacks: {error instanceof Error ? error.message : 'unknown error'}
</p>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>No stacks.</p>
<p class="text-xs">
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies PhotoPrism rejected at index
time live under the Cross-folder tab.
</p>
</div>
{:else}
<div class="space-y-3">
{#each groups as group, i (group.photo.UID)}
<StackGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}
<!-- Stacks tab ----------------------------------------------------- -->
{#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 pb-6">
{#if pending}
<p class="text-sm text-muted-foreground">Loading stacks…</p>
{:else if error}
<p class="text-sm text-destructive">
Could not load stacks: {error instanceof Error
? error.message
: 'unknown error'}
</p>
{:else if stackCount === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>No stacks.</p>
<p class="text-xs">
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you
don't have any, this tab stays empty. Cross-folder copies that
PhotoPrism rejected at index time live under the
<button
type="button"
class="underline hover:text-foreground"
onclick={() => (activeTab = 'cross-folder')}
>
Cross-folder
</button>
tab.
</p>
</div>
{:else}
<div class="space-y-3">
{#each groups as group, i (group.photo.UID)}
<StackGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}
<!-- Cross-folder tab ----------------------------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6">
<header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground">
Byte-identical files PhotoPrism dropped at index time. Found by scanning the
originals tree directly.
</p>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={triggerScan}
>
{#if crossQuery.isFetching}
Scanning…
{:else if scanRequested}
Rescan filesystem
{:else}
Scan filesystem
{/if}
</button>
</header>
<!-- Cross-folder tab ----------------------------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 pb-6">
<header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground">
Byte-identical files PhotoPrism dropped at index time. Found by
scanning the originals tree directly.
</p>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={triggerScan}
>
{#if crossQuery.isFetching}
Scanning…
{:else if scanRequested}
Rescan filesystem
{:else}
Scan filesystem
{/if}
</button>
</header>
{#if !scanRequested}
<p class="text-sm text-muted-foreground">
Click <em>Scan filesystem</em> to look for byte-identical files spread
across folders. Pre-filtered by size, so even large libraries finish
in a few seconds.
</p>
{:else if crossQuery.isFetching && !crossQuery.data}
<p class="text-sm text-muted-foreground">
Hashing files under originals…
</p>
{:else if crossQuery.isError}
<p class="text-sm text-destructive">
Scan failed: {crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
</p>
{:else if crossCount === 0}
<p class="text-sm text-muted-foreground">
No cross-folder duplicates found.
{#if crossQuery.data}
<span class="ml-1 text-[10px] text-muted-foreground/70">
(scanned in {crossQuery.data.scannedMs} ms)
</span>
{/if}
</p>
{:else}
<div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
<CrossFolderGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}
</div>
{#if !scanRequested}
<p class="text-sm text-muted-foreground">
Click <em>Scan filesystem</em> to look for byte-identical files spread across
folders. Pre-filtered by size, so even large libraries finish in a few seconds.
</p>
{:else if crossQuery.isFetching && !crossQuery.data}
<p class="text-sm text-muted-foreground">Hashing files under originals…</p>
{:else if crossQuery.isError}
<p class="text-sm text-destructive">
Scan failed: {crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
</p>
{:else if crossCount === 0}
<p class="text-sm text-muted-foreground">
No cross-folder duplicates found.
{#if crossQuery.data}
<span class="ml-1 text-[10px] text-muted-foreground/70">
(scanned in {crossQuery.data.scannedMs} ms)
</span>
{/if}
</p>
{:else}
<div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
<CrossFolderGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}

View File

@@ -352,9 +352,19 @@
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
{ kind: 'route', href: '/ratings', label: 'Ratings', getCount: () => ratingsCount },
{ kind: 'route', href: '/colors', label: 'Colors', getCount: () => colorsCount },
{ kind: 'route', href: '/tags', label: 'Tags', getCount: () => configQuery.data?.count?.labels }
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
// the badge shows total labels + keywords + ratings + colors so the
// number reflects the combined "things you can filter by" surface.
{
kind: 'route',
href: '/tags',
label: 'Tags',
getCount: () => {
const labels = configQuery.data?.count?.labels;
if (labels === undefined) return undefined;
return labels + ratingsCount + colorsCount;
}
}
];
const manageViews: ViewItem[] = [

View File

@@ -486,6 +486,33 @@
</div>
</div>
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-only:
editing labels requires re-indexing on PhotoPrism's side. The
dashed border + lower contrast distinguishes them from the user-
editable Keywords chips above. -->
{#if (photo.Labels ?? []).length > 0}
<div class="space-y-1">
<div
class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<Tag class="h-3 w-3" /> Labels (auto)
</div>
<div class="flex flex-wrap gap-1">
{#each photo.Labels ?? [] as lbl (lbl.UID ?? lbl.Label?.Slug)}
{@const slug = lbl.Label?.Slug}
{@const name = lbl.Label?.Name ?? slug ?? '(unknown)'}
<a
href={slug ? `/?q=${encodeURIComponent(`label:${slug}`)}` : '#'}
class="inline-flex items-center rounded-full border border-dashed border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground hover:text-foreground"
title={`Source: ${lbl.Source ?? 'classifier'}`}
>
{name}
</a>
{/each}
</div>
</div>
{/if}
<!-- GPS detail (collapsed by default) -->
<details class="rounded border border-border" open={Boolean(photo.Lat || photo.Lng)}>
<summary

View File

@@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from 'axios';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { adoptSession, clearSession, session } from '$lib/stores/session.svelte';
import { primaryFile } from '$lib/types/photoprism';
import type {
PpClientConfig,
PpPhoto,
@@ -385,6 +386,62 @@ export interface PpLabel {
Thumb?: string;
}
/**
* User-set keyword aggregation. PhotoPrism's `/labels` endpoint only
* surfaces classifier-derived labels; user-typed keywords live in
* `Details.Keywords` (a flat comma-separated string) which is *not*
* included in the `/photos` list response. The only path to it is the
* single-photo endpoint, so we fetch the library top-end (capped at
* PhotoPrism's 1000-row ceiling), fan out `getPhoto` calls in batches,
* and aggregate.
*
* Slow but cached upstream via TanStack — keys under the `['photos', …]`
* prefix so the existing photo-mutation invalidations cascade to it.
*/
export interface AggregatedKeyword {
keyword: string;
count: number;
/** UID of an arbitrary photo carrying this keyword — used as the
* thumbnail source so the tile is visually consistent with
* classifier-label tiles. */
sampleUid: string;
sampleHash: string;
}
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
const list = await listPhotos({ count: 1000, merged: true });
const buckets = new Map<string, AggregatedKeyword>();
const concurrency = 8;
for (let i = 0; i < list.length; i += concurrency) {
const slice = list.slice(i, i + concurrency);
const fulls = await Promise.all(
slice.map((p) => getPhoto(p.UID).catch(() => null))
);
for (let j = 0; j < slice.length; j++) {
const photo = slice[j];
const full = fulls[j];
if (!full) continue;
const raw = full.Details?.Keywords ?? '';
if (!raw) continue;
for (const kw of raw.split(',').map((k) => k.trim()).filter(Boolean)) {
const bucket = buckets.get(kw);
if (bucket) {
bucket.count++;
continue;
}
const hash = photo.Hash ?? primaryFile(photo).Hash;
buckets.set(kw, {
keyword: kw,
count: 1,
sampleUid: photo.UID,
sampleHash: hash
});
}
}
}
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
}
export async function listLabels(): Promise<PpLabel[]> {
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
// low-confidence classifier hits, manually-removed labels). They're

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>