- CauseGroupCard now wraps PhotoGrid so selection, keyboard nav, and preview flow through the standard timeline plumbing. Per-tile hover Approve/Archive and the group-wide Approve all are gone — the bottom BulkActionBar's review-section Keep/Archive handle single + bulk. - Low Resolution tab opts into a new PhotoTile dimensionBadge prop so WxH stays visible on each tile. - New suggestDateFromPath util parses YYYY-MM-DD from filename or folder path. RightSidebar surfaces it as an amber Apply row above the Taken-at input whenever the photo lacks a trusted TakenAt. - BulkActionBar gains a "Accept date & Keep" button (review section only) that patches each selected photo's TakenAt from its path suggestion when available, then approves. - Drop the Same folder / Same camera / Same year strips and the RelatedStrip component from the metadata sidebar. Also bundles in-progress Notes route + tile components and small tweaks to LeftSidebar, DuplicatesView, CrossFolderGroupCard, and photoprism.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
266 lines
8.2 KiB
Svelte
266 lines
8.2 KiB
Svelte
<!--
|
|
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; the indexer 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={`Duplicate group · ${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 in the library"
|
|
>
|
|
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>
|