Mulimage 2.0 #1

Merged
dtoro merged 64 commits from new into main 2026-05-21 22:48:55 +02:00
2 changed files with 150 additions and 47 deletions
Showing only changes of commit 8ac406ac1f - Show all commits

View File

@@ -0,0 +1,68 @@
/**
* Shared action helpers for the photo-app's review / archive flows.
*
* Three callers reach for these:
* 1. `gridKeyNav` — the document-level keyboard action (S / X)
* 2. `BulkActionBar` — the footer button row that appears on selection
* 3. `/review` CauseGroupCard — per-cause "Dismiss all" / "Archive all"
*
* Centralising the toast text, undo wiring, focus advance, and cache
* invalidation here keeps the three surfaces in lockstep — change the
* toast wording in one place and everywhere shows the same verb.
*/
import { toast } from 'svelte-sonner';
import { batchEdit } from './batch';
import { invalidatePhotos } from './bulk';
import { approvePhoto, batchArchive, batchRestore } from './photoprism';
import { queryClient } from '$lib/queryClient';
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
/**
* Dismiss photos out of the review queue by bumping their quality
* score above PhotoPrism's threshold. One-way: there's no
* `/unapprove` endpoint, so we do NOT push an undo entry — a re-keyed
* action would just be a no-op on already-approved photos.
*/
export async function dismissPhotos(uids: string[]): Promise<void> {
if (uids.length === 0) return;
const { updated, errors } = await batchEdit(uids, (id) => approvePhoto(id));
// Advance focus past the dismissed set before the timeline refetches
// so the cursor doesn't snap back to photo[0]; clear the now-stale
// selection ring for the same reason.
focusAfter(uids);
clearSelection();
invalidatePhotos(uids);
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
if (errors.length) {
toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, {
description: errors[0].message
});
return;
}
toast.success(`Dismissed ${uids.length}`);
}
/**
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
*/
export async function archivePhotos(uids: string[]): Promise<void> {
if (uids.length === 0) return;
try {
await batchArchive(uids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
return;
}
pushUndo(`Archived ${uids.length}`, async () => {
await batchRestore(uids);
invalidatePhotos(uids);
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
});
focusAfter(uids);
clearSelection();
invalidatePhotos(uids);
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
toast.success(`Archived ${uids.length}`);
}

View File

@@ -1,12 +1,15 @@
<!--
/review — PhotoPrism's quality-flagged photo queue, one tab per
cause (mirrors /tags' pill row). Only causes with non-zero photos
get a tab. Active tab is URL-driven via `?tab=<cause_key>` so
refresh / share / back land on the same panel.
cause. Tiles, selection, keyboard nav, and bulk actions all come
from the shared timeline machinery (PhotoGrid, gridKeyNav,
BulkActionBar, BulkMetadataSidebar) so muscle memory carries
across routes.
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.
The route flips `filters.section = 'review'` while it's mounted —
that's what swings the shared action surface into review semantics
(BulkActionBar shows Dismiss/Archive, gridKeyNav's S maps to
approve). The previous section is restored on unmount so going
back to `/` lands on whatever the user had before.
-->
<script lang="ts">
import { goto } from '$app/navigation';
@@ -18,22 +21,36 @@
type ReviewGroup
} from '$lib/services/adapters/review';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { selection, setFocused } from '$lib/stores/selection.svelte';
import { clearSelection, selection } from '$lib/stores/selection.svelte';
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
import {
setRightSidebarWidth,
setThumbnailSize,
THUMBNAIL_SIZE_LABELS,
THUMBNAIL_SIZE_PRESETS,
view
} from '$lib/stores/view.svelte';
import { getPhoto } from '$lib/services/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import { resizable } from '$lib/actions/resizable';
import type { PpPhoto } from '$lib/types/photoprism';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
function onSelect(photo: PpPhoto) {
setFocused(photo.UID);
}
// Stash the section that was active when the user arrived; restore
// on unmount so navigating away doesn't leak `section=review` to
// the timeline (which would silently re-filter it).
const prevSection: Section = filters.section;
$effect(() => {
setSection('review');
return () => {
setSection(prevSection);
clearSelection();
};
});
const reviewQuery = createQuery<ReviewGroup[]>(() => ({
queryKey: ['review-groups'],
@@ -49,31 +66,35 @@
}));
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 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;
});
// Selection is global — without this effect, a user who multi-
// selected in `low_resolution` then switched to `stripped_exif`
// would carry the previous tab's UIDs into the new tab's
// BulkActionBar verbs and accidentally act on the wrong photos.
$effect(() => {
void activeTab;
clearSelection();
});
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
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>
<Toolbar>
@@ -81,8 +102,6 @@
Review
</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
@@ -122,43 +141,59 @@
</Toolbar>
<div class="flex min-h-0 flex-1">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6">
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
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.
<div class="flex min-w-0 flex-1 flex-col">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
Could not load review queue: {reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
</p>
</div>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} autoFocus {onSelect} />
{/key}
{/if}
</main>
{: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} />
{/key}
{/if}
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed && selection.focused}
{#if !view.rightSidebarCollapsed && (selection.focused || selection.ids.size >= 2)}
<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}
{#if selection.ids.size >= 2}
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
{:else 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>
<div
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use:resizable={{
edge: 'left',
getWidth: () => view.rightSidebarWidth,
setWidth: setRightSidebarWidth
}}
role="separator"
aria-orientation="vertical"
aria-label="Resize info panel"
></div>
</aside>
{/if}
</div>