diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte
index 4d1c1f8..f0e3417 100644
--- a/web/src/lib/components/layout/LeftSidebar.svelte
+++ b/web/src/lib/components/layout/LeftSidebar.svelte
@@ -385,7 +385,7 @@
];
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: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
diff --git a/web/src/lib/components/review/CauseBadges.svelte b/web/src/lib/components/review/CauseBadges.svelte
new file mode 100644
index 0000000..6ba2763
--- /dev/null
+++ b/web/src/lib/components/review/CauseBadges.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+ {#each causes as c (c)}
+
+ {CAUSES[c].chip}
+
+ {/each}
+
diff --git a/web/src/lib/components/review/CauseGroupCard.svelte b/web/src/lib/components/review/CauseGroupCard.svelte
new file mode 100644
index 0000000..3b9e338
--- /dev/null
+++ b/web/src/lib/components/review/CauseGroupCard.svelte
@@ -0,0 +1,375 @@
+
+
+
+
+
+
+
+
+
+
+ {group.meta.suggestion}
+ {#if group.meta.suggestedAction !== 'manual'}
+
+ {group.meta.suggestedAction === 'approve' ? 'Approve all' : 'Archive all'}
+
+ {/if}
+
+
+
+ {#each group.photos as photo, i (photo.UID)}
+ {@const causes = deriveCauses(photo)}
+ {@const isFocused = i === focusedIdx}
+
+
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"
+ >
+
+
+ {#if dims(photo)}
+
+ {dims(photo)}
+
+ {/if}
+
+
+ {
+ e.stopPropagation();
+ void approveOne(photo);
+ }}
+ title="Approve (S)"
+ >
+ Approve
+
+ {
+ e.stopPropagation();
+ void archiveOne(photo);
+ }}
+ title="Archive (A)"
+ >
+ Archive
+
+
+
+
+
+
+ {photo.FileName ?? photo.Name ?? ''}
+
+
+
+ {/each}
+
+
diff --git a/web/src/lib/components/review/ReviewView.svelte b/web/src/lib/components/review/ReviewView.svelte
new file mode 100644
index 0000000..4ea6bf1
--- /dev/null
+++ b/web/src/lib/components/review/ReviewView.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+ {#if pending}
+
Loading review queue…
+ {:else if error}
+
+ Could not load review queue: {error instanceof Error ? error.message : 'unknown error'}
+
+ {:else if groups.length === 0}
+
+
The review queue is empty.
+
+ 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.
+
+
+ {:else}
+
+ {totalCount} photos in {groups.length} groups · arrow keys to focus, S to approve, A
+ to archive, Enter to inspect
+
+
+ {#each groups as g, i (g.cause)}
+
+ {/each}
+
+ {/if}
+
diff --git a/web/src/lib/components/sidebar/RelatedStrip.svelte b/web/src/lib/components/sidebar/RelatedStrip.svelte
new file mode 100644
index 0000000..4b5b69f
--- /dev/null
+++ b/web/src/lib/components/sidebar/RelatedStrip.svelte
@@ -0,0 +1,100 @@
+
+
+
+{#if stripQuery.isPending}
+ Loading {title.toLowerCase()}…
+{:else if stripQuery.isError}
+
+ {null}
+{:else if photos.length > 0}
+
+
+ {title}
+ ({photos.length}+)
+
+
+ {#each photos as p (p.UID)}
+
openOne(p.UID)}
+ title={p.FileName ?? p.Name ?? p.UID}
+ >
+ {#if p.Hash}
+
+ {/if}
+
+ {/each}
+
+
+{/if}
diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte
index 6f20bce..8fb94b0 100644
--- a/web/src/lib/components/sidebar/RightSidebar.svelte
+++ b/web/src/lib/components/sidebar/RightSidebar.svelte
@@ -40,11 +40,15 @@
import { thumbUrl } from '$lib/stores/session.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
+ import RelatedStrip from './RelatedStrip.svelte';
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 }: Props = $props();
+ let { photo, showRelated = false }: Props = $props();
const qc = useQueryClient();
@@ -496,6 +500,35 @@
+
+ {#if showRelated}
+
+
+ {#if photo.CameraID && photo.CameraID !== 1}
+
+ {/if}
+ {#if photo.Year}
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+ Review
+
+ {#snippet trailing()}
+
+ {#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
+ setThumbnailSize(size)}
+ title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
+ >
+ {THUMBNAIL_SIZE_LABELS[i]}
+
+ {/each}
+
+ {/snippet}
+
+
+
+
+
+
+
+ {#if !view.rightSidebarCollapsed && selection.focused}
+
+ {/if}
+