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:
@@ -7,11 +7,15 @@
|
||||
batchArchive,
|
||||
batchDelete,
|
||||
batchRestore,
|
||||
buildTakenAtPatch,
|
||||
listHeaps,
|
||||
removeFromHeap,
|
||||
updatePhoto,
|
||||
type PpAlbum
|
||||
} from '$lib/services/photoprism';
|
||||
import { batchEdit } from '$lib/services/batch';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
import {
|
||||
clearBulkToFirst,
|
||||
clearSelection,
|
||||
@@ -46,6 +50,43 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Walk every cache that might hold this UID's metadata: the timeline
|
||||
* list (flat or infinite), the review-groups bucket, and the
|
||||
* per-photo detail. Mirrors `gridKeyNav`'s `cachedPhoto` so the date-
|
||||
* suggestion lookup behaves consistently with the rest of the
|
||||
* selection plumbing. */
|
||||
function cachedPhoto(uid: string): PpPhoto | undefined {
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
const review = qc.getQueryData<{ photos?: PpPhoto[] }[]>(['review-groups']);
|
||||
if (review) {
|
||||
for (const group of review) {
|
||||
const hit = group.photos?.find((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return qc.getQueryData<PpPhoto>(['photo', uid]);
|
||||
}
|
||||
|
||||
function suggestionFor(p: PpPhoto): string | null {
|
||||
const needs = !p.TakenSrc || p.TakenSrc === 'name' || !p.TakenAt;
|
||||
if (!needs) return null;
|
||||
return suggestDateFromPath({ fileName: p.FileName, path: p.Path });
|
||||
}
|
||||
|
||||
const targetCount = $derived(
|
||||
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
||||
);
|
||||
@@ -55,6 +96,24 @@
|
||||
// heap-adding / restoring. The S keybinding is rerouted to approve
|
||||
// from gridKeyNav for the same reason.
|
||||
const isReview = $derived(filters.section === 'review');
|
||||
// "Accept date & Keep" surfaces only on review section, and only when
|
||||
// at least one targeted photo has a derivable date suggestion that it
|
||||
// would actually benefit from (no trusted TakenAt). Reads strictly from
|
||||
// query caches — uids missing from cache count as "no suggestion".
|
||||
const hasAnySuggestion = $derived.by(() => {
|
||||
if (!isReview) return false;
|
||||
const ids =
|
||||
selection.ids.size > 0
|
||||
? Array.from(selection.ids)
|
||||
: selection.focused
|
||||
? [selection.focused]
|
||||
: [];
|
||||
for (const id of ids) {
|
||||
const p = cachedPhoto(id);
|
||||
if (p && suggestionFor(p)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// Archive section is the parallel two-button flow: Keep (restore back
|
||||
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
||||
// from "archive" to "delete" since the photo is already archived;
|
||||
@@ -99,6 +158,34 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function onAcceptDateAndKeep() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
await withBusy(async () => {
|
||||
// For each id: if there's a path-derived date and the photo lacks a
|
||||
// trusted TakenAt, apply the date patch first; then approve. UIDs
|
||||
// without a usable suggestion just get approved. Errors are tallied
|
||||
// per-id rather than aborting the loop.
|
||||
const { updated, errors } = await batchEdit(ids, async (id) => {
|
||||
const p = cachedPhoto(id);
|
||||
const iso = p ? suggestionFor(p) : null;
|
||||
if (p && iso) {
|
||||
await updatePhoto(p, buildTakenAtPatch(`${iso}T00:00:00Z`));
|
||||
}
|
||||
await approvePhoto(id);
|
||||
return id;
|
||||
});
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
||||
} else {
|
||||
toast.success(`Kept ${ids.length}`);
|
||||
}
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
});
|
||||
}
|
||||
|
||||
async function onArchive() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
@@ -239,6 +326,21 @@
|
||||
✓ Keep
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||
</button>
|
||||
{#if hasAnySuggestion}
|
||||
<!-- Surfaces only when at least one selected photo has a
|
||||
derivable date from its path AND lacks a trusted
|
||||
TakenAt. Applies the suggested date patch then
|
||||
approves in one go; uids without a usable suggestion
|
||||
are just approved. -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
|
||||
disabled={busy}
|
||||
onclick={onAcceptDateAndKeep}
|
||||
title="Accept the date suggested from the file/folder path, then keep"
|
||||
>
|
||||
📅 Accept date & Keep
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
|
||||
Reference in New Issue
Block a user