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:
@@ -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}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user