From d35de8a2a944767ffbe73750a540152674051ab0 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 17 May 2026 21:19:44 +0200 Subject: [PATCH] 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) --- .../duplicates/DuplicatesView.svelte | 246 +++----- .../lib/components/layout/LeftSidebar.svelte | 16 +- .../components/sidebar/RightSidebar.svelte | 27 + web/src/lib/services/photoprism.ts | 57 ++ web/src/routes/colors/+page.svelte | 170 +----- web/src/routes/duplicates/+page.svelte | 45 +- web/src/routes/ratings/+page.svelte | 161 +----- web/src/routes/tags/+page.svelte | 527 +++++++++++++++--- 8 files changed, 695 insertions(+), 554 deletions(-) diff --git a/web/src/lib/components/duplicates/DuplicatesView.svelte b/web/src/lib/components/duplicates/DuplicatesView.svelte index 8b085f5..fdd3118 100644 --- a/web/src/lib/components/duplicates/DuplicatesView.svelte +++ b/web/src/lib/components/duplicates/DuplicatesView.svelte @@ -1,5 +1,6 @@ -
- -
- - + +{#if activeTab === 'stacks'} +
+ {#if pending} +

Loading stacks…

+ {:else if error} +

+ Could not load stacks: {error instanceof Error ? error.message : 'unknown error'} +

+ {:else if groups.length === 0} +
+

No stacks.

+

+ 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. +

+
+ {:else} +
+ {#each groups as group, i (group.photo.UID)} + + {/each} +
+ {/if}
+{/if} - - {#if activeTab === 'stacks'} -
- {#if pending} -

Loading stacks…

- {:else if error} -

- Could not load stacks: {error instanceof Error - ? error.message - : 'unknown error'} -

- {:else if stackCount === 0} -
-

No stacks.

-

- 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 - - tab. -

-
- {:else} -
- {#each groups as group, i (group.photo.UID)} - - {/each} -
- {/if} -
- {/if} + +{#if activeTab === 'cross-folder'} +
+
+

+ Byte-identical files PhotoPrism dropped at index time. Found by scanning the + originals tree directly. +

+ +
- - {#if activeTab === 'cross-folder'} -
-
-

- Byte-identical files PhotoPrism dropped at index time. Found by - scanning the originals tree directly. -

- -
- - {#if !scanRequested} -

- Click Scan filesystem to look for byte-identical files spread - across folders. Pre-filtered by size, so even large libraries finish - in a few seconds. -

- {:else if crossQuery.isFetching && !crossQuery.data} -

- Hashing files under originals… -

- {:else if crossQuery.isError} -

- Scan failed: {crossQuery.error instanceof Error - ? crossQuery.error.message - : 'unknown error'} -

- {:else if crossCount === 0} -

- No cross-folder duplicates found. - {#if crossQuery.data} - - (scanned in {crossQuery.data.scannedMs} ms) - - {/if} -

- {:else} -
- {#each crossQuery.data?.groups ?? [] as group, i (group.hash)} - - {/each} -
- {/if} -
- {/if} -
+ {#if !scanRequested} +

+ Click Scan filesystem to look for byte-identical files spread across + folders. Pre-filtered by size, so even large libraries finish in a few seconds. +

+ {:else if crossQuery.isFetching && !crossQuery.data} +

Hashing files under originals…

+ {:else if crossQuery.isError} +

+ Scan failed: {crossQuery.error instanceof Error + ? crossQuery.error.message + : 'unknown error'} +

+ {:else if crossCount === 0} +

+ No cross-folder duplicates found. + {#if crossQuery.data} + + (scanned in {crossQuery.data.scannedMs} ms) + + {/if} +

+ {:else} +
+ {#each crossQuery.data?.groups ?? [] as group, i (group.hash)} + + {/each} +
+ {/if} +
+{/if} diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index 99cf84c..c5f2878 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -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[] = [ diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte index f1272f7..2e6b0e1 100644 --- a/web/src/lib/components/sidebar/RightSidebar.svelte +++ b/web/src/lib/components/sidebar/RightSidebar.svelte @@ -486,6 +486,33 @@
+ + {#if (photo.Labels ?? []).length > 0} +
+
+ Labels (auto) +
+
+ {#each photo.Labels ?? [] as lbl (lbl.UID ?? lbl.Label?.Slug)} + {@const slug = lbl.Label?.Slug} + {@const name = lbl.Label?.Name ?? slug ?? '(unknown)'} + + {name} + + {/each} +
+
+ {/if} +
{ + const list = await listPhotos({ count: 1000, merged: true }); + const buckets = new Map(); + 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 { // `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden // low-confidence classifier hits, manually-removed labels). They're diff --git a/web/src/routes/colors/+page.svelte b/web/src/routes/colors/+page.svelte index 1c036c1..d9553c2 100644 --- a/web/src/routes/colors/+page.svelte +++ b/web/src/routes/colors/+page.svelte @@ -1,165 +1,13 @@ - - - Colors - - {#if selectedGroup} - - - - {selectedGroup.title} - - - {selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'} - - {/if} - {#snippet trailing()} - - {groups.length} color{groups.length === 1 ? '' : 's'} - - {/snippet} - - -
- {#if marksQuery.isPending || photosQuery.isPending} -

Loading colors…

- {:else if marksQuery.isError || photosQuery.isError} -

Failed to load colors.

- {:else if groups.length === 0} -

- No color labels yet. Open a photo and use the four-swatch row in the right - sidebar to tag it. -

- {:else if selectedGroup} - - {:else} -
- {#each groups as group (group.key)} - {@const rep = group.photos[0]} - {@const hash = rep.Hash ?? primaryFile(rep).Hash} - - {/each} -
- {/if} -
- - +

Redirecting to Tags · Colors…

diff --git a/web/src/routes/duplicates/+page.svelte b/web/src/routes/duplicates/+page.svelte index c756593..bb9303f 100644 --- a/web/src/routes/duplicates/+page.svelte +++ b/web/src/routes/duplicates/+page.svelte @@ -1,4 +1,6 @@ - Duplicates · stacks + Duplicates + +
+ {#each TABS as t (t.id)} + + {/each} +
{#snippet trailing()} +
+ {#each TABS as t (t.id)} + + {/each} +
{/if} {#snippet trailing()} - - {labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'} - + {#if !drillKey && (activeTab === 'labels' || activeTab === 'keywords') && totalPages > 1} + + + + {activePage + 1} / {totalPages} + + + {/if} {/snippet}
@@ -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} -

Loading labels…

- {:else if labelsQuery.isError} -

Failed to load labels.

- {:else if (labelsQuery.data ?? []).length === 0} -

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

- {:else if selectedLabel} - {#if labelPhotosQuery.isPending} + {#if drillKey} + + {#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}

Loading photos…

- {:else if labelPhotosQuery.isError} -

Failed to load photos for this label.

- {:else if (labelPhotosQuery.data ?? []).length === 0} -

No photos tagged with this label.

+ {:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError} +

Failed to load photos.

+ {:else if drillPhotos.length === 0} +

No photos under this tag.

{:else} - + {/if} - {:else} -
- {#each labelsQuery.data ?? [] as label (label.UID)} - + {/each} +
+ {/if} + {:else if activeTab === 'keywords'} + {#if keywordsQuery.isPending} +

+ Loading keywords… +
+ + This walks every photo's metadata once — the result is cached after the first load. + +

+ {:else if keywordsQuery.isError} +

Failed to load keywords.

+ {:else if keywordsSorted.length === 0} +

+ No user-set keywords yet. Add them from a photo's right-sidebar metadata panel. +

+ {:else} +
+ {#each pageSlice as item (item)} + {@const kw = item as AggregatedKeyword} + + {/each} +
+ {/if} + {:else if activeTab === 'ratings'} + {#if marksQuery.isPending || marksPoolQuery.isPending} +

Loading ratings…

+ {:else if marksQuery.isError || marksPoolQuery.isError} +

Failed to load ratings.

+ {:else if ratingGroups.length === 0} +

+ No rated photos yet. Open a photo and use the star row in the right sidebar (or + 1–5 in bulk mode) to rate it. +

+ {:else} +
+ {#each ratingGroups as group (group.rating)} + {@const rep = group.photos[0]} + {@const hash = rep.Hash ?? primaryFile(rep).Hash} +
- - {/each} - + {starLabel(group.rating)} +
+ + {starLabel(group.rating)} + + {group.photos.length} +
+ + {/each} + + {/if} + {:else if activeTab === 'colors'} + {#if marksQuery.isPending || marksPoolQuery.isPending} +

Loading colors…

+ {:else if marksQuery.isError || marksPoolQuery.isError} +

Failed to load colors.

+ {:else if colorGroups.length === 0} +

+ No color labels yet. Open a photo and use the four-swatch row in the right + sidebar to tag it. +

+ {:else} +
+ {#each colorGroups as group (group.key)} + {@const rep = group.photos[0]} + {@const hash = rep.Hash ?? primaryFile(rep).Hash} + + {/each} +
+ {/if} {/if}