Review view: cause-grouped UI with actions + suggestions
Builds a dedicated /review route that mirrors /duplicates' chrome: - /review/+page.svelte mounts Toolbar + ReviewView + RightSidebar - CauseGroupCard.svelte renders one card per cause with Approve all and Archive all bulk actions plus a per-cause suggestion line - CauseBadges.svelte shows every matching cause as chips on each tile - services/adapters/review.ts fetches review:true and groups photos by primary cause; current taxonomy is low_resolution > stripped_exif > implausible_year > non_image_type > quality_other (low_resolution ranks first because it's the most actionable signal) Sidebar gains an opt-in showRelated prop that adds three RelatedStrip panels (same folder / camera / year) for the 'decide these together' workflow. LeftSidebar's Review entry switches from a section filter to a route link so /review picks up the click. PpPhoto gains the missing Resolution field PhotoPrism actually returns on list responses.
This commit is contained in:
@@ -385,7 +385,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const manageViews: ViewItem[] = [
|
const manageViews: ViewItem[] = [
|
||||||
{ kind: 'section', id: 'review', label: 'Review', getCount: () => configQuery.data?.count?.review },
|
{ kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review },
|
||||||
{ kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined },
|
{ kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined },
|
||||||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
||||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
|
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
|
||||||
|
|||||||
26
web/src/lib/components/review/CauseBadges.svelte
Normal file
26
web/src/lib/components/review/CauseBadges.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!--
|
||||||
|
Per-tile cause chips inside a CauseGroupCard. The tile's primary
|
||||||
|
cause is already implied by the group it sits in, so we render
|
||||||
|
*every* matching cause here as a short chip so the user sees the
|
||||||
|
full reason set at a glance. Visually intentionally tiny — these
|
||||||
|
stack at the bottom of a thumbnail without crowding it.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { CAUSES, type CauseKey } from '$lib/services/adapters/review';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
causes: CauseKey[];
|
||||||
|
}
|
||||||
|
let { causes }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each causes as c (c)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-muted/70 px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
title={CAUSES[c].title}
|
||||||
|
>
|
||||||
|
{CAUSES[c].chip}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
375
web/src/lib/components/review/CauseGroupCard.svelte
Normal file
375
web/src/lib/components/review/CauseGroupCard.svelte
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
<!--
|
||||||
|
One review-queue cause group rendered as a card. Mirrors StackGroupCard's
|
||||||
|
chrome (focusable container, ResizeObserver column tracking, keyboard
|
||||||
|
nav) but the per-tile semantics differ:
|
||||||
|
|
||||||
|
- Click a tile → emits `select` so the parent can open the metadata
|
||||||
|
sidebar. Shift-click toggles bulk-select instead of opening.
|
||||||
|
- Header has `Approve all` + `Archive all` for the whole group.
|
||||||
|
- Suggestion line above the grid spotlights the likely-correct bulk
|
||||||
|
action with an inline button (per the plan).
|
||||||
|
- Keyboard: arrows move the focused tile; `S` approves the focused
|
||||||
|
tile, `A` archives it, `Enter` opens detail, `Esc` blurs.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import {
|
||||||
|
approvePhoto,
|
||||||
|
batchArchive
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { view } from '$lib/stores/view.svelte';
|
||||||
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import {
|
||||||
|
deriveCauses,
|
||||||
|
type ReviewGroup
|
||||||
|
} from '$lib/services/adapters/review';
|
||||||
|
import CauseBadges from './CauseBadges.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: ReviewGroup;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
/** Parent emits when the user picks a tile to inspect (Enter or
|
||||||
|
* plain click). Parent owns the RightSidebar mount. */
|
||||||
|
onSelect?: (photo: PpPhoto) => void;
|
||||||
|
}
|
||||||
|
let { group, autoFocus = false, onSelect }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
let sectionEl: HTMLElement | undefined = $state();
|
||||||
|
let gridEl: HTMLElement | undefined = $state();
|
||||||
|
let focusedIdx = $state(0);
|
||||||
|
let cols = $state(1);
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Match StackGroupCard's column-tracking trick so arrow Up/Down jump
|
||||||
|
// by row width.
|
||||||
|
$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 moveFocus(delta: number) {
|
||||||
|
if (group.photos.length === 0) return;
|
||||||
|
focusedIdx = Math.min(
|
||||||
|
Math.max(0, focusedIdx + delta),
|
||||||
|
group.photos.length - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dims(p: PpPhoto): string {
|
||||||
|
const f = primaryFile(p);
|
||||||
|
const w = p.Width ?? f.Width;
|
||||||
|
const h = p.Height ?? f.Height;
|
||||||
|
if (!w || !h) return '';
|
||||||
|
return `${w}×${h}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumb(p: PpPhoto): string {
|
||||||
|
// list endpoint puts the hash on the photo itself; primaryFile is
|
||||||
|
// the fallback for detail responses.
|
||||||
|
const h = p.Hash ?? primaryFile(p).Hash;
|
||||||
|
return h ? thumbUrl(h, 'tile_500') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveOne(p: PpPhoto) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await approvePhoto(p.UID);
|
||||||
|
toast.success('Approved');
|
||||||
|
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Approve failed');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveOne(p: PpPhoto) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await batchArchive([p.UID]);
|
||||||
|
toast.success('Archived');
|
||||||
|
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveAll() {
|
||||||
|
if (busy || group.photos.length === 0) return;
|
||||||
|
if (!confirm(`Approve all ${group.photos.length} photos in "${group.meta.title}"?`)) return;
|
||||||
|
busy = true;
|
||||||
|
const total = group.photos.length;
|
||||||
|
let done = 0;
|
||||||
|
const toastId = toast.loading(`Approving 0 / ${total}…`);
|
||||||
|
try {
|
||||||
|
// PhotoPrism has no batch-approve, so fan out one-at-a-time.
|
||||||
|
// A small concurrency cap keeps the server responsive without
|
||||||
|
// stalling for very large groups.
|
||||||
|
const QUEUE = 4;
|
||||||
|
const uids = group.photos.map((p) => p.UID);
|
||||||
|
let idx = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (idx < uids.length) {
|
||||||
|
const my = idx++;
|
||||||
|
try {
|
||||||
|
await approvePhoto(uids[my]);
|
||||||
|
} catch {
|
||||||
|
// Carry on — partial success is better than abort.
|
||||||
|
}
|
||||||
|
done++;
|
||||||
|
toast.loading(`Approving ${done} / ${total}…`, { id: toastId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(QUEUE, uids.length) }, worker));
|
||||||
|
toast.success(`Approved ${done} / ${total}`, { id: toastId });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Approve all failed', {
|
||||||
|
id: toastId
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveAll() {
|
||||||
|
if (busy || group.photos.length === 0) return;
|
||||||
|
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
|
||||||
|
return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await batchArchive(group.photos.map((p) => p.UID));
|
||||||
|
toast.success(`Archived ${group.photos.length}`);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Archive all failed');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (busy) return;
|
||||||
|
const p = group.photos[focusedIdx];
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(-1);
|
||||||
|
return;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(1);
|
||||||
|
return;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(-cols);
|
||||||
|
return;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(cols);
|
||||||
|
return;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (p) onSelect?.(p);
|
||||||
|
return;
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
e.preventDefault();
|
||||||
|
if (p) void approveOne(p);
|
||||||
|
return;
|
||||||
|
case 'a':
|
||||||
|
case 'A':
|
||||||
|
e.preventDefault();
|
||||||
|
if (p) void archiveOne(p);
|
||||||
|
return;
|
||||||
|
case 'Escape':
|
||||||
|
(e.target as HTMLElement)?.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSuggestion() {
|
||||||
|
if (group.meta.suggestedAction === 'approve') void approveAll();
|
||||||
|
else if (group.meta.suggestedAction === 'archive') void archiveAll();
|
||||||
|
// 'manual' suggestion has no button — the suggestion line is text-only.
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={sectionEl}
|
||||||
|
tabindex="0"
|
||||||
|
role="application"
|
||||||
|
aria-label={`Cause group ${group.meta.title} with ${group.photos.length} photos`}
|
||||||
|
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.meta.title}
|
||||||
|
<span class="ml-1 text-muted-foreground">({group.photos.length})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={busy || group.photos.length === 0}
|
||||||
|
onclick={approveAll}
|
||||||
|
title="Approve every photo in this group"
|
||||||
|
>
|
||||||
|
Approve all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={busy || group.photos.length === 0}
|
||||||
|
onclick={archiveAll}
|
||||||
|
title="Archive every photo in this group"
|
||||||
|
>
|
||||||
|
Archive all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Suggestion line — sits above the grid, surfaces the likely-correct
|
||||||
|
bulk action with an inline trigger. 'manual' causes get no
|
||||||
|
inline button; the user has to use the header bulk bar instead. -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-3 rounded border border-dashed border-border/60 bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>{group.meta.suggestion}</span>
|
||||||
|
{#if group.meta.suggestedAction !== 'manual'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded border border-border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={runSuggestion}
|
||||||
|
>
|
||||||
|
{group.meta.suggestedAction === 'approve' ? 'Approve all' : 'Archive all'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={gridEl}
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each group.photos as photo, i (photo.UID)}
|
||||||
|
{@const causes = deriveCauses(photo)}
|
||||||
|
{@const isFocused = i === focusedIdx}
|
||||||
|
<!-- Tile is a <div> with role=button so the inner per-tile
|
||||||
|
action buttons aren't nested inside another <button> (which
|
||||||
|
is invalid HTML and trips a11y linters). -->
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={`${photo.FileName ?? photo.Name ?? photo.UID} — press Enter to inspect`}
|
||||||
|
onclick={() => onSelect?.(photo)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect?.(photo);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class:ring-2={isFocused}
|
||||||
|
class:ring-blue-500={isFocused}
|
||||||
|
class:ring-offset-2={isFocused}
|
||||||
|
class:ring-offset-background={isFocused}
|
||||||
|
class="group relative flex cursor-pointer flex-col overflow-hidden rounded-md border border-border bg-secondary text-left transition-shadow"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-square w-full overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={thumb(photo)}
|
||||||
|
alt={photo.FileName ?? photo.Name ?? ''}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{#if dims(photo)}
|
||||||
|
<span
|
||||||
|
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
||||||
|
>
|
||||||
|
{dims(photo)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<!-- Per-tile hover actions: stop propagation so a click
|
||||||
|
here doesn't also open the sidebar. -->
|
||||||
|
<div
|
||||||
|
class="absolute bottom-1.5 right-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void approveOne(photo);
|
||||||
|
}}
|
||||||
|
title="Approve (S)"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void archiveOne(photo);
|
||||||
|
}}
|
||||||
|
title="Archive (A)"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 px-2 py-1.5">
|
||||||
|
<CauseBadges {causes} />
|
||||||
|
<div
|
||||||
|
class="truncate text-[10px] leading-tight text-muted-foreground"
|
||||||
|
title={photo.FileName ?? photo.Name ?? ''}
|
||||||
|
>
|
||||||
|
{photo.FileName ?? photo.Name ?? ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
48
web/src/lib/components/review/ReviewView.svelte
Normal file
48
web/src/lib/components/review/ReviewView.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!--
|
||||||
|
Review-page body. Renders one CauseGroupCard per ReviewGroup the
|
||||||
|
adapter returned. Mirrors DuplicatesView's pending/error/empty
|
||||||
|
branching.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ReviewGroup } from '$lib/services/adapters/review';
|
||||||
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import CauseGroupCard from './CauseGroupCard.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groups: ReviewGroup[];
|
||||||
|
pending: boolean;
|
||||||
|
error: unknown;
|
||||||
|
onSelect?: (photo: PpPhoto) => void;
|
||||||
|
}
|
||||||
|
let { groups, pending, error, onSelect }: Props = $props();
|
||||||
|
|
||||||
|
const totalCount = $derived(groups.reduce((acc, g) => acc + g.photos.length, 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 pb-6">
|
||||||
|
{#if pending}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-sm text-destructive">
|
||||||
|
Could not load review queue: {error instanceof Error ? error.message : 'unknown error'}
|
||||||
|
</p>
|
||||||
|
{:else if groups.length === 0}
|
||||||
|
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>The review queue is empty.</p>
|
||||||
|
<p class="text-xs">
|
||||||
|
PhotoPrism's indexer flags photos with a low quality score for human review. New
|
||||||
|
arrivals with missing EXIF, low resolution, or unknown cameras will land here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-3 text-[11px] text-muted-foreground">
|
||||||
|
{totalCount} photos in {groups.length} groups · arrow keys to focus, S to approve, A
|
||||||
|
to archive, Enter to inspect
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each groups as g, i (g.cause)}
|
||||||
|
<CauseGroupCard group={g} autoFocus={i === 0} {onSelect} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
100
web/src/lib/components/sidebar/RelatedStrip.svelte
Normal file
100
web/src/lib/components/sidebar/RelatedStrip.svelte
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<!--
|
||||||
|
One horizontal strip of related-photo thumbnails for the metadata
|
||||||
|
sidebar. Used three times on the /review sidebar (folder / camera /
|
||||||
|
year). Self-fetches via the PhotoPrism DSL so each strip stays
|
||||||
|
independent.
|
||||||
|
|
||||||
|
The header is clickable: it navigates back to the timeline with the
|
||||||
|
same DSL applied as a `?q=` param, so the user can drill into the
|
||||||
|
full result set if they want to. Strips with zero hits collapse
|
||||||
|
silently — no header, no whitespace.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import { listPhotos } from '$lib/services/photoprism';
|
||||||
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { setFocused } from '$lib/stores/selection.svelte';
|
||||||
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
/** PhotoPrism DSL fragment, e.g. `path:"2024/lyon"` or `year:2024`. */
|
||||||
|
q: string;
|
||||||
|
/** Cap on tiles rendered in the strip. Defaults to a small set
|
||||||
|
* that fits one row in a typical sidebar width. */
|
||||||
|
limit?: number;
|
||||||
|
/** UID to filter out — usually the photo whose sidebar this strip
|
||||||
|
* is on, so the user doesn't see itself in its own "related"
|
||||||
|
* list. */
|
||||||
|
excludeUid?: string;
|
||||||
|
}
|
||||||
|
let { title, q, limit = 12, excludeUid }: Props = $props();
|
||||||
|
|
||||||
|
const stripQuery = createQuery<PpPhoto[]>(() => ({
|
||||||
|
queryKey: ['related', q, limit],
|
||||||
|
queryFn: () => listPhotos({ q, count: limit + 1, order: 'newest' }),
|
||||||
|
// Strips are cheap to refetch; the data behind them changes
|
||||||
|
// rarely, but a stale-while-revalidate window keeps the sidebar
|
||||||
|
// snappy when the user clicks through similar photos.
|
||||||
|
staleTime: 60_000,
|
||||||
|
enabled: q.length > 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const photos = $derived(
|
||||||
|
(stripQuery.data ?? []).filter((p) => p.UID !== excludeUid).slice(0, limit)
|
||||||
|
);
|
||||||
|
|
||||||
|
function openTimeline() {
|
||||||
|
// Same `?q=` param the timeline already accepts (see filters store)
|
||||||
|
// — clicking the strip header pivots the main timeline into the
|
||||||
|
// same filtered scope so the user can browse the full set.
|
||||||
|
const params = new URLSearchParams({ q });
|
||||||
|
void goto(`/?${params.toString()}`, { keepFocus: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOne(uid: string) {
|
||||||
|
// Focus the picked photo so the sidebar re-renders against it.
|
||||||
|
// Useful for the "decide these together" workflow without leaving
|
||||||
|
// the review page.
|
||||||
|
setFocused(uid);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if stripQuery.isPending}
|
||||||
|
<div class="text-[10px] text-muted-foreground/70">Loading {title.toLowerCase()}…</div>
|
||||||
|
{:else if stripQuery.isError}
|
||||||
|
<!-- Errors shouldn't break the sidebar; just hide the strip. -->
|
||||||
|
{null}
|
||||||
|
{:else if photos.length > 0}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-baseline justify-between text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
||||||
|
onclick={openTimeline}
|
||||||
|
title={`Open the timeline filtered by ${q}`}
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
<span class="text-muted-foreground/70">({photos.length}+)</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex gap-1 overflow-x-auto">
|
||||||
|
{#each photos as p (p.UID)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-12 w-12 shrink-0 overflow-hidden rounded border border-border bg-secondary hover:border-primary"
|
||||||
|
onclick={() => openOne(p.UID)}
|
||||||
|
title={p.FileName ?? p.Name ?? p.UID}
|
||||||
|
>
|
||||||
|
{#if p.Hash}
|
||||||
|
<img
|
||||||
|
src={thumbUrl(p.Hash, 'tile_100')}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -40,11 +40,15 @@
|
|||||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import RelatedStrip from './RelatedStrip.svelte';
|
||||||
|
|
||||||
interface Props {
|
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;
|
photo: PpPhoto;
|
||||||
}
|
}
|
||||||
let { photo }: Props = $props();
|
let { photo, showRelated = false }: Props = $props();
|
||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
@@ -496,6 +500,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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}
|
||||||
|
|
||||||
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-only:
|
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-only:
|
||||||
editing labels requires re-indexing on PhotoPrism's side. The
|
editing labels requires re-indexing on PhotoPrism's side. The
|
||||||
dashed border + lower contrast distinguishes them from the user-
|
dashed border + lower contrast distinguishes them from the user-
|
||||||
|
|||||||
175
web/src/lib/services/adapters/review.ts
Normal file
175
web/src/lib/services/adapters/review.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Adapter for the /review route. Fetches PhotoPrism's review queue and
|
||||||
|
* groups photos by the most likely reason the indexer flagged them for
|
||||||
|
* review.
|
||||||
|
*
|
||||||
|
* PhotoPrism exposes `Quality` as a single 1–7 score and the
|
||||||
|
* `review:true` DSL term filters to `Quality < 3`, but it never tells us
|
||||||
|
* *why* a given photo scored low. We derive that on the client from the
|
||||||
|
* other metadata it does return — `TakenSrc`, `CameraID`, `Resolution`,
|
||||||
|
* `Year`, `Type`. A photo can match multiple causes; the group it lands
|
||||||
|
* in is determined by the priority order below, while every matching
|
||||||
|
* cause is shown as a chip on the tile so the user sees the full
|
||||||
|
* picture.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { listPhotos } from '$lib/services/photoprism';
|
||||||
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
export type CauseKey =
|
||||||
|
| 'low_resolution'
|
||||||
|
| 'stripped_exif'
|
||||||
|
| 'implausible_year'
|
||||||
|
| 'non_image_type'
|
||||||
|
| 'quality_other';
|
||||||
|
|
||||||
|
export interface CauseMeta {
|
||||||
|
/** Group card header. */
|
||||||
|
title: string;
|
||||||
|
/** Short label on a tile chip. */
|
||||||
|
chip: string;
|
||||||
|
/** Sentence guiding the bulk decision. */
|
||||||
|
suggestion: string;
|
||||||
|
/** Which bulk action the suggestion line should preselect. */
|
||||||
|
suggestedAction: 'approve' | 'archive' | 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CAUSES: Record<CauseKey, CauseMeta> = {
|
||||||
|
low_resolution: {
|
||||||
|
title: 'Low resolution',
|
||||||
|
chip: '< 2 MP',
|
||||||
|
suggestion:
|
||||||
|
"Mostly messenger / web-saved images. Archive all if you don't want them in the timeline.",
|
||||||
|
suggestedAction: 'archive'
|
||||||
|
},
|
||||||
|
stripped_exif: {
|
||||||
|
title: 'EXIF stripped',
|
||||||
|
chip: 'no EXIF',
|
||||||
|
suggestion:
|
||||||
|
"Date and camera info were missing — usually downloaded keepers worth reviewing one-by-one. Open the first to fix the date, then bulk-approve.",
|
||||||
|
suggestedAction: 'manual'
|
||||||
|
},
|
||||||
|
implausible_year: {
|
||||||
|
title: 'Implausible year',
|
||||||
|
chip: 'bad year',
|
||||||
|
suggestion:
|
||||||
|
"Filenames suggest a date PhotoPrism doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.",
|
||||||
|
suggestedAction: 'manual'
|
||||||
|
},
|
||||||
|
non_image_type: {
|
||||||
|
title: 'Animated / vector / scan',
|
||||||
|
chip: 'special type',
|
||||||
|
suggestion: 'Decide per item — no obvious bulk move.',
|
||||||
|
suggestedAction: 'manual'
|
||||||
|
},
|
||||||
|
quality_other: {
|
||||||
|
title: 'Other quality issues',
|
||||||
|
chip: 'low quality',
|
||||||
|
suggestion:
|
||||||
|
'PhotoPrism flagged these but the metadata looks fine. Open the first one to investigate.',
|
||||||
|
suggestedAction: 'manual'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority order for picking the primary cause when a photo matches
|
||||||
|
* several. `low_resolution` ranks first because it's the most actionable
|
||||||
|
* — under-2 MP photos are almost always non-keepers, regardless of how
|
||||||
|
* the EXIF looks. `stripped_exif` catches the higher-res survivors so
|
||||||
|
* the user only sees them as their own group.
|
||||||
|
*/
|
||||||
|
const CAUSE_PRIORITY: CauseKey[] = [
|
||||||
|
'low_resolution',
|
||||||
|
'stripped_exif',
|
||||||
|
'implausible_year',
|
||||||
|
'non_image_type',
|
||||||
|
'quality_other'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All causes that apply to a photo. Stable order = priority order, so
|
||||||
|
* the first entry of the returned array is the primary cause.
|
||||||
|
*/
|
||||||
|
export function deriveCauses(photo: PpPhoto): CauseKey[] {
|
||||||
|
const causes: CauseKey[] = [];
|
||||||
|
const now = new Date().getUTCFullYear();
|
||||||
|
|
||||||
|
// Resolution is the integer megapixel count PhotoPrism stored at
|
||||||
|
// index time; 0 means "couldn't compute" (treat as low). Anything
|
||||||
|
// under 3 MP is the messenger / web-image bucket.
|
||||||
|
if (typeof photo.Resolution === 'number' && photo.Resolution < 3) {
|
||||||
|
causes.push('low_resolution');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stripped-EXIF lumps the two signals that almost always co-occur:
|
||||||
|
// no trusted date (TakenSrc empty or guessed from filename) and an
|
||||||
|
// unknown camera (CameraID 1 is PhotoPrism's "Unknown" sentinel).
|
||||||
|
// Splitting them produced two heavily-overlapping groups; in
|
||||||
|
// practice the user wants one decision per group.
|
||||||
|
if (!photo.TakenSrc || photo.TakenSrc === 'name' || photo.CameraID === 1) {
|
||||||
|
causes.push('stripped_exif');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof photo.Year === 'number' && photo.Year > 0) {
|
||||||
|
if (photo.Year < 1900 || photo.Year > now + 1) {
|
||||||
|
causes.push('implausible_year');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated GIFs and vector images are perfectly valid but PhotoPrism
|
||||||
|
// doesn't trust the timeline placement; scanned documents look like
|
||||||
|
// photos but rarely belong with them.
|
||||||
|
if (photo.Type === 'animated' || photo.Type === 'vector') {
|
||||||
|
causes.push('non_image_type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch-all: PhotoPrism flagged it but nothing above explained why.
|
||||||
|
if (causes.length === 0) {
|
||||||
|
causes.push('quality_other');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by global priority so the primary cause comes first.
|
||||||
|
causes.sort((a, b) => CAUSE_PRIORITY.indexOf(a) - CAUSE_PRIORITY.indexOf(b));
|
||||||
|
return causes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewGroup {
|
||||||
|
cause: CauseKey;
|
||||||
|
meta: CauseMeta;
|
||||||
|
/** All photos whose primary cause is this group's cause. */
|
||||||
|
photos: PpPhoto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the review queue and bucket every photo into a ReviewGroup by
|
||||||
|
* its primary cause. Empty buckets are filtered out so the page renders
|
||||||
|
* only the causes that actually have hits today.
|
||||||
|
*/
|
||||||
|
export async function listReviewGroups(): Promise<ReviewGroup[]> {
|
||||||
|
// `count: 1000` is well above the realistic review-queue size for
|
||||||
|
// this LXC (290 today). PhotoPrism caps `count` server-side at 1000
|
||||||
|
// — paginating further would require multiple calls and a merge,
|
||||||
|
// which we'll bolt on if the queue ever crosses the cap.
|
||||||
|
const photos = await listPhotos({
|
||||||
|
q: 'review:true',
|
||||||
|
count: 1000,
|
||||||
|
order: 'newest',
|
||||||
|
merged: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const buckets = new Map<CauseKey, PpPhoto[]>();
|
||||||
|
for (const p of photos) {
|
||||||
|
const primary = deriveCauses(p)[0];
|
||||||
|
const arr = buckets.get(primary) ?? [];
|
||||||
|
arr.push(p);
|
||||||
|
buckets.set(primary, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return in priority order so the cause that matters most ranks
|
||||||
|
// first on the page, regardless of how big each bucket is.
|
||||||
|
return CAUSE_PRIORITY.flatMap((cause) => {
|
||||||
|
const ps = buckets.get(cause);
|
||||||
|
if (!ps || ps.length === 0) return [];
|
||||||
|
return [{ cause, meta: CAUSES[cause], photos: ps }];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -136,6 +136,7 @@ export interface PpPhoto {
|
|||||||
FNumber?: number;
|
FNumber?: number;
|
||||||
FocalLength?: number;
|
FocalLength?: number;
|
||||||
Exposure?: string;
|
Exposure?: string;
|
||||||
|
Resolution?: number;
|
||||||
Quality?: number;
|
Quality?: number;
|
||||||
Type?: string;
|
Type?: string;
|
||||||
Camera?: PpCamera;
|
Camera?: PpCamera;
|
||||||
|
|||||||
103
web/src/routes/review/+page.svelte
Normal file
103
web/src/routes/review/+page.svelte
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!--
|
||||||
|
/review — PhotoPrism's quality-flagged photo queue rendered as
|
||||||
|
cause-grouped cards (mirrors /duplicates). A click on any tile
|
||||||
|
focuses that photo in the existing selection store; the right-hand
|
||||||
|
aside picks up the change and mounts RightSidebar in its existing
|
||||||
|
"single-photo metadata" mode, extended with related-photo strips
|
||||||
|
via `showRelated`.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import { listReviewGroups } from '$lib/services/adapters/review';
|
||||||
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
import { selection, setFocused } from '$lib/stores/selection.svelte';
|
||||||
|
import {
|
||||||
|
setThumbnailSize,
|
||||||
|
THUMBNAIL_SIZE_LABELS,
|
||||||
|
THUMBNAIL_SIZE_PRESETS,
|
||||||
|
view
|
||||||
|
} from '$lib/stores/view.svelte';
|
||||||
|
import { getPhoto } from '$lib/services/photoprism';
|
||||||
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
|
import ReviewView from '$lib/components/review/ReviewView.svelte';
|
||||||
|
|
||||||
|
// Reuse the global selection store so the sidebar wiring matches the
|
||||||
|
// timeline — focus a photo here, the same `RightSidebar` consumer
|
||||||
|
// can render it without a parallel store.
|
||||||
|
function onSelect(photo: PpPhoto) {
|
||||||
|
setFocused(photo.UID);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewQuery = createQuery(() => ({
|
||||||
|
queryKey: ['review-groups'],
|
||||||
|
queryFn: listReviewGroups,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
// Keep the queue fresh on tab focus; mutations invalidate
|
||||||
|
// directly so this is mostly insurance against external pushes
|
||||||
|
// (re-index, etc).
|
||||||
|
staleTime: 30_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Detail fetch for the focused photo — same pattern as the timeline,
|
||||||
|
// keyed on the UID so swap is automatic.
|
||||||
|
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||||
|
queryKey: ['photo', selection.focused ?? ''],
|
||||||
|
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
|
||||||
|
enabled: isAuthenticated() && Boolean(selection.focused)
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Review
|
||||||
|
</span>
|
||||||
|
{#snippet trailing()}
|
||||||
|
<div
|
||||||
|
class="flex items-center overflow-hidden rounded border border-border"
|
||||||
|
role="group"
|
||||||
|
aria-label="Thumbnail size"
|
||||||
|
>
|
||||||
|
{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
|
||||||
|
class:bg-accent={view.thumbnailSize === size}
|
||||||
|
class:text-foreground={view.thumbnailSize === size}
|
||||||
|
class:text-muted-foreground={view.thumbnailSize !== size}
|
||||||
|
onclick={() => setThumbnailSize(size)}
|
||||||
|
title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
|
||||||
|
>
|
||||||
|
{THUMBNAIL_SIZE_LABELS[i]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<main class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<ReviewView
|
||||||
|
groups={reviewQuery.data ?? []}
|
||||||
|
pending={reviewQuery.isPending}
|
||||||
|
error={reviewQuery.error}
|
||||||
|
{onSelect}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if !view.rightSidebarCollapsed && selection.focused}
|
||||||
|
<aside
|
||||||
|
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||||
|
style="width: {view.rightSidebarWidth}px;"
|
||||||
|
>
|
||||||
|
<div class="h-full overflow-y-auto">
|
||||||
|
{#if focusedPhotoQuery.data}
|
||||||
|
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
|
||||||
|
{:else if focusedPhotoQuery.isFetching}
|
||||||
|
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user