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>
160 lines
5.2 KiB
Svelte
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}
|