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

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