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:
265
web/src/lib/components/duplicates/CrossFolderGroupCard.svelte
Normal file
265
web/src/lib/components/duplicates/CrossFolderGroupCard.svelte
Normal file
@@ -0,0 +1,265 @@
|
||||
<!--
|
||||
One cross-folder duplicate group rendered as a card. Lists every on-disk
|
||||
copy of the same byte-identical file. The user picks one to keep; the
|
||||
rest are archived to `.duplicates/<timestamp>/` via the sidecar.
|
||||
|
||||
Differences from StackGroupCard (which operates on PhotoPrism Files in
|
||||
a single Photo stack):
|
||||
- These photos are NOT in PhotoPrism's DB (PhotoPrism dropped them at
|
||||
index time). They're files on disk only.
|
||||
- Thumbnails come via `thumbUrl(hash, ...)` — content-addressed, so we
|
||||
can render every copy from the same hash even though only one Photo
|
||||
entry exists.
|
||||
- Resolution moves files (reversible) rather than deletes (irreversible).
|
||||
|
||||
Same keyboard contract as StackGroupCard: arrows pick the keeper,
|
||||
Enter commits.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
archiveDuplicatePaths,
|
||||
type CrossFolderDuplicateGroup
|
||||
} from '$lib/services/photoprism';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
|
||||
interface Props {
|
||||
group: CrossFolderDuplicateGroup;
|
||||
/** First-card auto-focus, same pattern as StackGroupCard. */
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
let { group, autoFocus = false }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
let keep = $state('');
|
||||
let busy = $state(false);
|
||||
let sectionEl: HTMLElement | undefined = $state();
|
||||
let gridEl: HTMLElement | undefined = $state();
|
||||
let cols = $state(1);
|
||||
|
||||
// Seed `keep` from the indexed path when available; that's the safest
|
||||
// default because losing it would leave PhotoPrism with no copy. Fall
|
||||
// back to the first listed path.
|
||||
$effect(() => {
|
||||
const validPaths = new Set(group.files.map((f) => f.path));
|
||||
if (!keep || !validPaths.has(keep)) {
|
||||
keep =
|
||||
group.indexedPath && validPaths.has(group.indexedPath)
|
||||
? group.indexedPath
|
||||
: group.files[0]?.path ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
// Column-count tracking — identical pattern to StackGroupCard.
|
||||
$effect(() => {
|
||||
if (!gridEl) return;
|
||||
const measure = () => {
|
||||
if (!gridEl) return;
|
||||
const n = getComputedStyle(gridEl)
|
||||
.gridTemplateColumns.split(' ')
|
||||
.filter(Boolean).length;
|
||||
cols = Math.max(1, n);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(gridEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
$effect(() => {
|
||||
void view.thumbnailSize;
|
||||
queueMicrotask(() => {
|
||||
if (!gridEl) return;
|
||||
const n = getComputedStyle(gridEl)
|
||||
.gridTemplateColumns.split(' ')
|
||||
.filter(Boolean).length;
|
||||
cols = Math.max(1, n);
|
||||
});
|
||||
});
|
||||
|
||||
function sizeLabel(bytes: number): string {
|
||||
if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||
return `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
function shortFolder(relPath: string): string {
|
||||
const segs = relPath.split('/').filter(Boolean);
|
||||
if (segs.length <= 1) return '(root)';
|
||||
return segs.slice(0, -1).join('/');
|
||||
}
|
||||
|
||||
function moveKeep(delta: number) {
|
||||
const i = group.files.findIndex((f) => f.path === keep);
|
||||
if (i < 0) return;
|
||||
const next = Math.min(Math.max(0, i + delta), group.files.length - 1);
|
||||
keep = group.files[next].path;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (busy) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
moveKeep(-1);
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
moveKeep(1);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
moveKeep(-cols);
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
moveKeep(cols);
|
||||
return;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
void commit();
|
||||
return;
|
||||
case 'Escape':
|
||||
(e.target as HTMLElement)?.blur();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (busy || group.files.length < 2) return;
|
||||
// Defensive guard: never archive the indexed copy. The user can
|
||||
// pick a different "keeper" but the archive list is computed AFTER
|
||||
// resolving that into "everything except the keeper". If they pick
|
||||
// a non-indexed copy as keeper, the indexed one gets archived —
|
||||
// PhotoPrism will lose its photo entry on the cleanup reindex.
|
||||
// That's a legitimate user choice (they wanted to move the
|
||||
// canonical copy), just call it out in the toast.
|
||||
const losers = group.files.filter((f) => f.path !== keep);
|
||||
if (losers.length === 0) return;
|
||||
const losingIndexed =
|
||||
group.indexedPath && losers.some((f) => f.path === group.indexedPath);
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(
|
||||
`Archived ${result.moved.length}; ${result.errors.length} failed`,
|
||||
{
|
||||
description: result.errors[0].error
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
|
||||
{
|
||||
description: losingIndexed
|
||||
? 'The previously-indexed copy was moved; PhotoPrism will drop it on the next index pass.'
|
||||
: 'Files moved to .duplicates/ inside originals.'
|
||||
}
|
||||
);
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={sectionEl}
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label={`Cross-folder duplicate · ${group.files.length} copies`}
|
||||
onkeydown={onKeydown}
|
||||
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-foreground">
|
||||
{group.files.length} copies · {sizeLabel(group.size)} each
|
||||
</div>
|
||||
<div class="truncate text-[10px] font-mono text-muted-foreground">
|
||||
sha1 {group.hash.slice(0, 16)}…
|
||||
</div>
|
||||
</div>
|
||||
<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={busy || group.files.length < 2}
|
||||
onclick={commit}
|
||||
title="Move the unselected copies to .duplicates/ (reversible)"
|
||||
>
|
||||
Keep selected, archive rest
|
||||
<kbd
|
||||
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>Enter</kbd
|
||||
>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
bind:this={gridEl}
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each group.files as file (file.path)}
|
||||
{@const isKeep = file.path === keep}
|
||||
{@const isIndexed = file.path === group.indexedPath}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (keep = file.path)}
|
||||
class:scale-95={isKeep}
|
||||
class:ring-2={isKeep}
|
||||
class:ring-blue-500={isKeep}
|
||||
class:ring-offset-2={isKeep}
|
||||
class:ring-offset-background={isKeep}
|
||||
class:transition-[transform,box-shadow]={isKeep}
|
||||
class:duration-300={isKeep}
|
||||
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isKeep}
|
||||
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
>
|
||||
<div class="relative aspect-square w-full overflow-hidden">
|
||||
<img
|
||||
src={thumbUrl(group.hash, 'tile_500')}
|
||||
alt={file.path}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{#if isKeep}
|
||||
<span
|
||||
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
Keep
|
||||
</span>
|
||||
{/if}
|
||||
{#if isIndexed}
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
title="Currently indexed by PhotoPrism"
|
||||
>
|
||||
Indexed
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
||||
title={file.path}
|
||||
>
|
||||
<div class="truncate text-foreground/90">{shortFolder(file.path)}</div>
|
||||
<div class="truncate font-mono">{file.path.split('/').pop()}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
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>
|
||||
289
web/src/lib/components/duplicates/StackGroupCard.svelte
Normal file
289
web/src/lib/components/duplicates/StackGroupCard.svelte
Normal file
@@ -0,0 +1,289 @@
|
||||
<!--
|
||||
One duplicate stack rendered as a card. Each variant file is a clickable
|
||||
tile; clicking selects it as the candidate "best". Committing promotes
|
||||
the selected file to Primary (via `setPrimary`) and deletes the rest from
|
||||
the stack (via `deleteFile` — PhotoPrism's flat `DELETE /photos/:uid/
|
||||
files/:fid` route).
|
||||
|
||||
Why DELETE instead of unstack-then-archive (which the plan started with):
|
||||
PhotoPrism's `/unstack` returns `only originals can be unstacked` for
|
||||
sidecar JPGs and `Changes could not be saved` for live-photo HEIC+MOV
|
||||
pairs. DELETE works for all of them — and cascades through the live-
|
||||
photo group automatically, so one click resolves the whole stack. The
|
||||
on-disk file is renamed with a hash suffix (not erased), so a future
|
||||
manual reindex can recover it if needed.
|
||||
|
||||
Keyboard:
|
||||
- Section is tabindex=0; focusing it captures arrow keys + Enter.
|
||||
- Left/Right move the "best" highlight one file; Up/Down move by the
|
||||
grid's computed column count (same trick the timeline uses for
|
||||
cross-row arrow nav).
|
||||
- Enter commits the current selection. Esc removes focus from the card.
|
||||
- The page's first card auto-focuses on mount so the user can drive
|
||||
the workflow keyboard-first.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { deleteFile, setPrimary } from '$lib/services/photoprism';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
|
||||
interface Props {
|
||||
group: DuplicateGroup;
|
||||
/** When true, the section auto-focuses on mount so the user can
|
||||
* arrow-key/Enter the workflow without reaching for the mouse.
|
||||
* Only the page's first card should get this. */
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
let { group, autoFocus = false }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
let best = $state('');
|
||||
let busy = $state(false);
|
||||
let sectionEl: HTMLElement | undefined = $state();
|
||||
let gridEl: HTMLElement | undefined = $state();
|
||||
let cols = $state(1);
|
||||
|
||||
$effect(() => {
|
||||
// Seed / re-seed `best` from the prop when the underlying group
|
||||
// changes (keyed each + UID key normally keeps this stable, but
|
||||
// the guard handles prop swaps without overwriting user clicks).
|
||||
if (!best || !group.files.some((f) => f.UID === best)) {
|
||||
best = group.bestFileUid;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
// Track the grid's column count via ResizeObserver — same approach
|
||||
// the timeline uses. Reading `gridTemplateColumns` from computed
|
||||
// style is O(1) regardless of how many tiles render.
|
||||
$effect(() => {
|
||||
if (!gridEl) return;
|
||||
const measure = () => {
|
||||
if (!gridEl) return;
|
||||
const n = getComputedStyle(gridEl)
|
||||
.gridTemplateColumns.split(' ')
|
||||
.filter(Boolean).length;
|
||||
cols = Math.max(1, n);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(gridEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
// thumbnailSize changes alter cols without resizing the grid; re-
|
||||
// measure on the next microtask.
|
||||
$effect(() => {
|
||||
void view.thumbnailSize;
|
||||
queueMicrotask(() => {
|
||||
if (!gridEl) return;
|
||||
const n = getComputedStyle(gridEl)
|
||||
.gridTemplateColumns.split(' ')
|
||||
.filter(Boolean).length;
|
||||
cols = Math.max(1, n);
|
||||
});
|
||||
});
|
||||
|
||||
function shortPath(name: string): string {
|
||||
const segs = name.split('/').filter(Boolean);
|
||||
if (segs.length <= 2) return name;
|
||||
return '…/' + segs.slice(-2).join('/');
|
||||
}
|
||||
|
||||
function dims(f: { Width?: number; Height?: number }): string {
|
||||
if (!f.Width || !f.Height) return '';
|
||||
return `${f.Width}×${f.Height}`;
|
||||
}
|
||||
|
||||
function sizeLabel(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||
return `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
function moveBest(delta: number) {
|
||||
const i = group.files.findIndex((f) => f.UID === best);
|
||||
if (i < 0) return;
|
||||
const next = Math.min(Math.max(0, i + delta), group.files.length - 1);
|
||||
best = group.files[next].UID;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (busy) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
moveBest(-1);
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
moveBest(1);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
moveBest(-cols);
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
moveBest(cols);
|
||||
return;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
void commit();
|
||||
return;
|
||||
case 'Escape':
|
||||
(e.target as HTMLElement)?.blur();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (busy || group.files.length < 2) return;
|
||||
busy = true;
|
||||
const photoUid = group.photo.UID;
|
||||
const losers = group.files.filter((f) => f.UID !== best);
|
||||
try {
|
||||
// 1. Promote the user's pick to Primary first (idempotent — if
|
||||
// it's already Primary, the call is a no-op on the server).
|
||||
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
|
||||
if (best !== currentPrimary) {
|
||||
await setPrimary(photoUid, best);
|
||||
}
|
||||
// 2. Delete each non-best file. PhotoPrism cascades through
|
||||
// related variants in the same logical group (live-photo
|
||||
// pairs, sidecar companions), so a single DELETE on one
|
||||
// HEIC variant clears the whole HEIC+MOV pair in one go.
|
||||
// Loop tolerates partial success — if PhotoPrism already
|
||||
// cleared the file via cascade, the next DELETE 404s and
|
||||
// we move on.
|
||||
for (const f of losers) {
|
||||
try {
|
||||
await deleteFile(photoUid, f.UID);
|
||||
} catch (err) {
|
||||
// 404 means the file's already gone (cascade) — fine.
|
||||
// Any other status means we have a real problem; bubble it.
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
if (status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
toast.success(`Resolved · kept 1 of ${group.files.length}`);
|
||||
void qc.invalidateQueries({ queryKey: ['duplicates'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error && err.message ? err.message : 'Resolve failed';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Section is focusable so we can capture arrow keys + Enter. `outline-
|
||||
none` because we paint our own focus ring on .focus-visible below
|
||||
(otherwise the browser default outline would clash with the tile
|
||||
selection ring). -->
|
||||
<!--
|
||||
`role="application"` declares this as a custom keyboard widget (arrow
|
||||
keys + Enter, not standard reading order). The element below is a
|
||||
`<div>` rather than `<section>` because Svelte's a11y linter treats
|
||||
`<section>` as strictly non-interactive even with an explicit
|
||||
application role.
|
||||
-->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={sectionEl}
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label={`Duplicate stack of ${group.files.length} files — arrow keys pick the file to keep, Enter resolves`}
|
||||
onkeydown={onKeydown}
|
||||
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-foreground">
|
||||
{group.files.length} files in this stack
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
<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={busy || group.files.length < 2}
|
||||
onclick={commit}
|
||||
title="Promote the selected file and delete the rest from this stack"
|
||||
>
|
||||
Keep selected, delete rest
|
||||
<kbd
|
||||
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>Enter</kbd
|
||||
>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
bind:this={gridEl}
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each group.files as file (file.UID)}
|
||||
{@const isBest = file.UID === best}
|
||||
{@const sizeStr = sizeLabel(file.Size)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (best = file.UID)}
|
||||
class:scale-95={isBest}
|
||||
class:ring-2={isBest}
|
||||
class:ring-blue-500={isBest}
|
||||
class:ring-offset-2={isBest}
|
||||
class:ring-offset-background={isBest}
|
||||
class:transition-[transform,box-shadow]={isBest}
|
||||
class:duration-300={isBest}
|
||||
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isBest}
|
||||
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
>
|
||||
<div class="relative aspect-square w-full overflow-hidden">
|
||||
<img
|
||||
src={thumbUrl(file.Hash, 'tile_500')}
|
||||
alt={file.Name}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{#if isBest}
|
||||
<span
|
||||
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
Best
|
||||
</span>
|
||||
{/if}
|
||||
{#if dims(file)}
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
||||
>
|
||||
{dims(file)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
||||
title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
||||
>
|
||||
<div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
|
||||
{#if sizeStr}
|
||||
<div>{sizeStr}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user