feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
225
web/src/lib/components/duplicates/DuplicatesView.svelte
Normal file
225
web/src/lib/components/duplicates/DuplicatesView.svelte
Normal file
@@ -0,0 +1,225 @@
|
||||
<!--
|
||||
Duplicate-resolution page body. Two tabs:
|
||||
|
||||
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 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).
|
||||
-->
|
||||
<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';
|
||||
|
||||
interface Props {
|
||||
groups: DuplicateGroup[];
|
||||
pending: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
let { 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.
|
||||
let scanRequested = $state(false);
|
||||
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: scanRequested,
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
function triggerScan() {
|
||||
if (scanRequested && !crossQuery.isFetching) {
|
||||
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
||||
} else {
|
||||
scanRequested = true;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (crossQuery.error) {
|
||||
toast.error(
|
||||
crossQuery.error instanceof Error
|
||||
? crossQuery.error.message
|
||||
: 'Cross-folder scan failed'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- 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 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>
|
||||
Reference in New Issue
Block a user