Files
mule-image/web/src/lib/components/duplicates/DuplicatesView.svelte
dtoro e364e4128f web: unify empty + loading states behind EmptyState/InlineLoader
Replaces ad-hoc "Loading…" text and bare empty messages with two
shared feedback primitives that carry subtle lucide icons, consistent
muted-foreground/destructive tones, and a11y signaling (role=status,
aria-busy, role=alert on destructive empties). Loading copy gains
context ("Loading photos/folders/heaps/metadata…") and the right-
sidebar idle state moves from a "ⓘ" glyph to a MousePointerClick
icon. SkeletonGrid stays as the initial-grid loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00

160 lines
5.2 KiB
Svelte

<!--
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
`stack:true` and resolve via `setPrimary` + `deleteFile`.
2. Cross-folder — files PhotoPrism silently rejected at index time
because they were byte-identical to an existing entry. PhotoPrism
never adds those rows to its DB, so we scan the filesystem via the
mule-sidecar. Resolution moves the unwanted copies into a
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
The cross-folder scan auto-fires when its tab is active — with size
pre-filtering it stays fast (~250ms for 400 files in practice) and a
long staleTime keeps tab bounces from re-running it. The button is
kept for manual "rescan after I moved files" refreshes.
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';
import { toast } from 'svelte-sonner';
import {
scanCrossFolderDuplicates,
type CrossFolderScanResult
} from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
type Tab = 'stacks' | 'cross-folder';
interface Props {
activeTab: Tab;
groups: DuplicateGroup[];
pending: boolean;
error: unknown;
}
let { activeTab, groups, pending, error }: Props = $props();
const qc = useQueryClient();
// Cross-folder scan auto-fires when the tab is active. The 5-minute
// staleTime means a fresh visit reuses the prior result; the
// "Rescan filesystem" button invalidates to force a re-scan after
// the user has moved files around.
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryFn: scanCrossFolderDuplicates,
enabled: activeTab === 'cross-folder',
staleTime: 5 * 60_000
}));
function rescan() {
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
}
$effect(() => {
if (crossQuery.error) {
toast.error(
crossQuery.error instanceof Error
? crossQuery.error.message
: 'Cross-folder scan failed'
);
}
});
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
</script>
<!-- Stacks tab ----------------------------------------------------- -->
{#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
{#if pending}
<InlineLoader label="Loading stacks…" />
{:else if error}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Could not load stacks"
description={error instanceof Error ? error.message : 'unknown error'}
/>
{:else if groups.length === 0}
<EmptyState icon={Copy} title="No stacks">
{#snippet descriptionSnippet()}
<p>
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>
{/snippet}
</EmptyState>
{: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={rescan}
>
{#if crossQuery.isFetching}
Scanning…
{:else}
Rescan filesystem
{/if}
</button>
</header>
{#if crossQuery.isFetching && !crossQuery.data}
<InlineLoader label="Hashing files under originals…" />
{:else if crossQuery.isError}
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Scan failed"
description={crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
/>
{:else if crossCount === 0}
<EmptyState icon={CheckCircle2} title="No cross-folder duplicates found">
{#snippet descriptionSnippet()}
{#if crossQuery.data}
<p class="text-[10px] text-muted-foreground/70">
scanned in {crossQuery.data.scannedMs} ms
</p>
{/if}
{/snippet}
</EmptyState>
{: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}