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

@@ -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