feat(duplicates): show count badges on Stacks/Cross-folder tabs

Stacks count appears immediately (cheap query); cross-folder count fills
in after its tab is visited (lazy disk scan). Page observes both queries
from cache so badges stay in sync with the panel content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:14:51 +02:00
parent 8ac406ac1f
commit a38c3c6e9b

View File

@@ -6,6 +6,10 @@
listDuplicateGroups,
type DuplicateGroup
} from '$lib/services/adapters/duplicates';
import {
scanCrossFolderDuplicates,
type CrossFolderScanResult
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import {
setThumbnailSize,
@@ -19,10 +23,6 @@
// 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';
@@ -39,12 +39,31 @@
// 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.
// Enabled on both tabs so the stacks-count badge stays accurate even
// while the cross-folder tab is open.
const dupesQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
enabled: isAuthenticated() && activeTab === 'stacks',
enabled: isAuthenticated(),
staleTime: 30_000
}));
// Observe-only: DuplicatesView's matching query (same key) is what
// actually triggers the scan when the cross-folder tab is active.
// Here we just read the cached count for the tab badge.
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryFn: scanCrossFolderDuplicates,
enabled: false,
staleTime: 5 * 60_000
}));
const stacksCount = $derived(dupesQuery.data?.length);
const crossCount = $derived(crossQuery.data?.groups.length);
const TABS = $derived<{ id: Tab; label: string; count: number | undefined }[]>([
{ id: 'stacks', label: 'Stacks', count: stacksCount },
{ id: 'cross-folder', label: 'Cross-folder', count: crossCount }
]);
</script>
<Toolbar>
@@ -52,17 +71,29 @@
Duplicates
</span>
<!-- Tabs mirror /tags' pill row visually: same height, same active
treatment, same hover affordance. -->
treatment, same hover affordance. Count badge appears once the
underlying query has data — cross-folder stays unbadged until
the tab has been opened at least once (lazy scan). -->
<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
class="inline-flex items-center gap-1 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}
<span>{t.label}</span>
{#if t.count !== undefined}
<span
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
t.id
? 'bg-primary/15 text-primary'
: 'bg-secondary text-muted-foreground'}"
>
{t.count}
</span>
{/if}
</button>
{/each}
</div>