Mulimage 2.0 #1
@@ -1,48 +0,0 @@
|
|||||||
<!--
|
|
||||||
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>
|
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
<!--
|
<!--
|
||||||
/review — PhotoPrism's quality-flagged photo queue rendered as
|
/review — PhotoPrism's quality-flagged photo queue, one tab per
|
||||||
cause-grouped cards (mirrors /duplicates). A click on any tile
|
cause (mirrors /tags' pill row). Only causes with non-zero photos
|
||||||
focuses that photo in the existing selection store; the right-hand
|
get a tab. Active tab is URL-driven via `?tab=<cause_key>` so
|
||||||
aside picks up the change and mounts RightSidebar in its existing
|
refresh / share / back land on the same panel.
|
||||||
"single-photo metadata" mode, extended with related-photo strips
|
|
||||||
via `showRelated`.
|
Clicking a tile focuses that photo in the global selection store;
|
||||||
|
the right-hand aside mounts `RightSidebar` with `showRelated=true`
|
||||||
|
to surface same-folder / camera / year strips.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import { listReviewGroups } from '$lib/services/adapters/review';
|
import {
|
||||||
|
listReviewGroups,
|
||||||
|
type CauseKey,
|
||||||
|
type ReviewGroup
|
||||||
|
} from '$lib/services/adapters/review';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
import { selection, setFocused } from '$lib/stores/selection.svelte';
|
import { selection, setFocused } from '$lib/stores/selection.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -21,38 +29,75 @@
|
|||||||
import type { PpPhoto } from '$lib/types/photoprism';
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
import ReviewView from '$lib/components/review/ReviewView.svelte';
|
import CauseGroupCard from '$lib/components/review/CauseGroupCard.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) {
|
function onSelect(photo: PpPhoto) {
|
||||||
setFocused(photo.UID);
|
setFocused(photo.UID);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewQuery = createQuery(() => ({
|
const reviewQuery = createQuery<ReviewGroup[]>(() => ({
|
||||||
queryKey: ['review-groups'],
|
queryKey: ['review-groups'],
|
||||||
queryFn: listReviewGroups,
|
queryFn: listReviewGroups,
|
||||||
enabled: isAuthenticated(),
|
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
|
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>(() => ({
|
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||||
queryKey: ['photo', selection.focused ?? ''],
|
queryKey: ['photo', selection.focused ?? ''],
|
||||||
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
|
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
|
||||||
enabled: isAuthenticated() && Boolean(selection.focused)
|
enabled: isAuthenticated() && Boolean(selection.focused)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const groups = $derived(reviewQuery.data ?? []);
|
||||||
|
// Only causes with hits get a tab — empty buckets are filtered out
|
||||||
|
// in the adapter already, but $derived re-runs whenever data changes.
|
||||||
|
const tabs = $derived(groups.map((g) => ({ id: g.cause, label: g.meta.title, count: g.photos.length })));
|
||||||
|
|
||||||
|
const requestedTab = $derived(page.url.searchParams.get('tab'));
|
||||||
|
// Resolve the active tab: honour the URL when it points at a tab
|
||||||
|
// that still has hits; otherwise fall back to the first available
|
||||||
|
// tab so refresh after clearing a category doesn't strand the user.
|
||||||
|
const activeTab: CauseKey | null = $derived.by(() => {
|
||||||
|
if (tabs.length === 0) return null;
|
||||||
|
const want = tabs.find((t) => t.id === requestedTab);
|
||||||
|
return (want ?? tabs[0]).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setTab(id: CauseKey) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
// First tab is the implicit default; keep the URL clean for it.
|
||||||
|
if (tabs.length > 0 && tabs[0].id !== id) params.set('tab', id);
|
||||||
|
void goto(`/review${params.size ? '?' + params : ''}`, {
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
Review
|
Review
|
||||||
</span>
|
</span>
|
||||||
|
{#if tabs.length > 0}
|
||||||
|
<!-- Pill row of cause tabs. Same chrome as /tags so the active
|
||||||
|
state reads consistently across the app. -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#each tabs as t (t.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||||
|
? 'border-primary/40 bg-primary/10 text-primary'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||||
|
onclick={() => setTab(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
<span class="ml-1 text-muted-foreground/70">({t.count})</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<div
|
<div
|
||||||
class="flex items-center overflow-hidden rounded border border-border"
|
class="flex items-center overflow-hidden rounded border border-border"
|
||||||
@@ -77,13 +122,29 @@
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
<main class="min-h-0 flex-1 overflow-y-auto">
|
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6">
|
||||||
<ReviewView
|
{#if reviewQuery.isPending}
|
||||||
groups={reviewQuery.data ?? []}
|
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
||||||
pending={reviewQuery.isPending}
|
{:else if reviewQuery.error}
|
||||||
error={reviewQuery.error}
|
<p class="text-sm text-destructive">
|
||||||
{onSelect}
|
Could not load review queue: {reviewQuery.error instanceof Error
|
||||||
/>
|
? reviewQuery.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 if activeGroup}
|
||||||
|
{#key activeGroup.cause}
|
||||||
|
<CauseGroupCard group={activeGroup} autoFocus {onSelect} />
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{#if !view.rightSidebarCollapsed && selection.focused}
|
{#if !view.rightSidebarCollapsed && selection.focused}
|
||||||
|
|||||||
Reference in New Issue
Block a user