web(review): switch cause tabs to PhotoGrid, add date-from-path suggestion

- 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>
This commit is contained in:
2026-05-20 11:20:02 +02:00
parent 55c870c155
commit d1ddc48f81
15 changed files with 607 additions and 512 deletions

View File

@@ -39,15 +39,12 @@
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import RelatedStrip from './RelatedStrip.svelte';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
interface Props {
/** When true, append related-photo strips (folder/camera/year) below
* Keywords. Used by the /review route; left off on the timeline. */
showRelated?: boolean;
photo: PpPhoto;
}
let { photo, showRelated = false }: Props = $props();
let { photo }: Props = $props();
const qc = useQueryClient();
@@ -131,6 +128,25 @@
commit({ Caption: caption, CaptionSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
// Path-based date guess. Surfaced only when the photo's stored date is
// missing or untrusted — same heuristic the review adapter uses to
// bucket photos into `stripped_exif`. Tying it to the heuristic rather
// than a specific tab means the suggestion shows up wherever the user
// lands on a date-less photo (timeline drill-in, archive, etc.).
const needsDate = $derived(
!photo.TakenSrc || photo.TakenSrc === 'name' || !photo.TakenAt
);
const dateSuggestion = $derived(
suggestDateFromPath({ fileName: photo.FileName, path: photo.Path })
);
const showDateSuggestion = $derived(
needsDate && !!dateSuggestion && dateSuggestion !== takenAt
);
function applyDateSuggestion() {
if (!dateSuggestion) return;
takenAt = dateSuggestion;
commitTakenAt();
}
function commitTakenAt() {
if (!takenAt) return;
if (!isValidISODate(takenAt)) {
@@ -310,6 +326,29 @@
{/if}
</div>
<!-- Date suggestion derived from the file/folder path. Shown only
when the photo's stored date is missing or untrusted (the
`stripped_exif` heuristic). Amber styling marks it as
unconfirmed — clicking Apply commits as a manual TakenAt
edit. -->
{#if showDateSuggestion}
<div
class="flex items-center gap-2 rounded border border-amber-300/70 bg-amber-50/40 px-1.5 py-1 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
>
<Folder class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">
Suggested from path: <span class="font-medium">{dateSuggestion}</span>
</span>
<button
type="button"
class="shrink-0 rounded border border-amber-400/60 bg-amber-100/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-400/30 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30"
onclick={applyDateSuggestion}
>
Apply
</button>
</div>
{/if}
<!-- Taken at -->
<div class="flex items-center gap-2">
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -373,25 +412,14 @@
</div>
</dl>
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
mule-image's nomenclature). -->
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
rows="2"
placeholder="Add a note…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={caption}
onblur={commitCaption}
></textarea>
</div>
<!-- Tags — score, color label, keywords, and auto-labels grouped under
one collapsible section. Score + color are stored on the mule-
sidecar (PhotoPrism's PUT can't persist them); keywords live on
Details; auto-labels come from PhotoPrism's TF classifier and are
read-only. Open by default since these are the culling marks the
user reaches for first. -->
<!-- Tags — note, score, color label, keywords, and auto-labels grouped
under one collapsible section. Note (PhotoPrism's Caption field,
labelled here to match mule-image's nomenclature) sits at the top
of the group since it's the most-edited per-photo field. Score +
color are stored on the mule-sidecar (PhotoPrism's PUT can't
persist them); keywords live on Details; auto-labels come from
PhotoPrism's TF classifier and are read-only. Open by default
since these are the culling marks the user reaches for first. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('tags', true)}
@@ -405,6 +433,17 @@
</span>
</summary>
<div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
rows="2"
placeholder="Add a note…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={caption}
onblur={commitCaption}
></textarea>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
@@ -504,35 +543,6 @@
</div>
</details>
<!-- Related strips (only the /review route opts in). The three
scopes match the three decisions the user usually makes here:
"did all these come from the same shoot?" (folder), "same
camera, EXIF-stripped together?" (camera), "right year?"
(year). Strips with zero hits collapse silently. -->
{#if showRelated}
<div class="space-y-2 border-t border-border pt-2">
<RelatedStrip
title="Same folder"
q={`path:"${photo.Path ?? ''}"`}
excludeUid={photo.UID}
/>
{#if photo.CameraID && photo.CameraID !== 1}
<RelatedStrip
title="Same camera"
q={`camera:${photo.CameraID}`}
excludeUid={photo.UID}
/>
{/if}
{#if photo.Year}
<RelatedStrip
title="Same year"
q={`year:${photo.Year}`}
excludeUid={photo.UID}
/>
{/if}
</div>
{/if}
<!-- GPS detail. Static default (closed); user's expand/collapse
choice persists across photo switches via the view store.
Avoid data-driven defaults here — they make the `open` attr