From f290784bf36a2c3986857433103935e08268e767 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 00:16:48 +0200 Subject: [PATCH] ui(duplicates): show parent folder + full-path tooltip on each thumbnail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two copies of IMG_1234.jpg sitting in different folders looked identical on the duplicates grid — same filename, same dimensions, same Best heuristic. The user had no way to pick which copy to keep without opening each in the preview overlay. Backend: include filepath in the per-member payload from GET /api/v1/library/duplicates/groups (was filename-only). Frontend: a black 65% strip at the bottom of every duplicate thumbnail showing the parent folder name (the actual discriminator when filenames match), with the full filepath surfaced via the native title tooltip on hover. The dimensions chip moves from bottom-left to top-left so the bottom strip can run edge-to-edge. memberToPhoto stops faking filepath=filename (a years-old workaround that broke any code path needing the real path); the synthetic Photo the grid hands to PhotoThumbnail now carries the real filepath. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/routers/library.py | 24 +++++++----- .../components/duplicates/DuplicatesView.tsx | 38 ++++++++++++++++--- frontend/src/services/api.ts | 4 ++ 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 976dc14..b30668c 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -733,6 +733,7 @@ async def get_duplicate_groups( select( Photo.id, Photo.filename, + Photo.filepath, Photo.taken_at, Photo.file_size, Photo.width, @@ -751,22 +752,25 @@ async def get_duplicate_groups( ) ).all() - # Bucket members by group_id. + # Bucket members by group_id. filepath is included so the + # Duplicates view can show "which folder does this copy live in" + # — the discriminator the user needs to pick a winner. groups: dict[str, list[dict]] = {} for row in rows: member = { "id": row[0], "filename": row[1], - "taken_at": row[2].isoformat() if row[2] else None, - "file_size": row[3], - "width": row[4], - "height": row[5], - "thumb_small": row[6], - "file_hash": row[7], - "folder_id": row[8], - "media_type": row[9], + "filepath": row[2], + "taken_at": row[3].isoformat() if row[3] else None, + "file_size": row[4], + "width": row[5], + "height": row[6], + "thumb_small": row[7], + "file_hash": row[8], + "folder_id": row[9], + "media_type": row[10], } - groups.setdefault(row[10], []).append(member) + groups.setdefault(row[11], []).append(member) def earliest(g: list[dict]) -> str: # Used as a secondary sort key. Photos with no taken_at sort last diff --git a/frontend/src/components/duplicates/DuplicatesView.tsx b/frontend/src/components/duplicates/DuplicatesView.tsx index 91555bf..4d3264f 100644 --- a/frontend/src/components/duplicates/DuplicatesView.tsx +++ b/frontend/src/components/duplicates/DuplicatesView.tsx @@ -450,19 +450,28 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({ Keep this )} - {/* Dimensions chip — bottom-LEFT. Neutral metadata variant - * matches the family. Rare collision with a manual rating - * (also bottom-left) is tolerated: rated duplicates are - * uncommon in practice. */} + {/* Dimensions chip — top-LEFT (Best / Keep this lives + * top-right; bottom is reserved for the path strip). */}
{formatDimensions(member)}
+ {/* Path strip — full-width across the bottom. Shows the + * parent folder name (the actual discriminator when two + * copies share the same filename), truncating from the + * right if necessary. Full filepath surfaces via the + * native tooltip on hover. */} +
+ {duplicatePathLabel(photoByMemberId.get(member.id)!.filepath)} +
) })} @@ -505,6 +514,23 @@ function formatBytes(n: number): string { return `${n}B` } +/** Short label for the bottom path strip on a duplicate thumbnail. + * Goal: tell two copies-with-the-same-filename apart at a glance, so + * we want the parent folder name — that's almost always the + * discriminator (different shoots, different years, different + * upload sources). When the parent folder is unhelpful (e.g. the + * filename is itself sitting at the root) we fall back to the file + * basename. Long folder names get text-overflow-ellipsis'd by the + * enclosing strip's `truncate`. */ +function duplicatePathLabel(filepath: string): string { + if (!filepath) return '' + const parts = filepath.split('/').filter(Boolean) + if (parts.length === 0) return filepath + if (parts.length === 1) return parts[0] + // The second-to-last segment IS the parent directory. + return parts[parts.length - 2] +} + /** Adapt a DuplicateGroupMember (the slim API shape) to a Photo, which * is what PhotoThumbnail expects. We deliberately set is_duplicate=false * on the synthetic Photo so the duplicate badge isn't drawn on every @@ -512,7 +538,7 @@ function formatBytes(n: number): string { function memberToPhoto(m: DuplicateGroupMember): Photo { return { id: m.id, - filepath: m.filename, // good enough for the RAW/video extension regex + filepath: m.filepath, filename: m.filename, media_type: m.media_type, width: m.width, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5c77d73..ee0f1eb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -538,6 +538,10 @@ export const library = { export interface DuplicateGroupMember { id: string filename: string + /** Full path of the file on disk. Powers the path strip in the + * Duplicates view so the user can tell two same-named-different- + * folder copies apart at a glance. */ + filepath: string taken_at: string | null file_size: number | null width: number | null