Mulimage 2.0 #1
68
web/src/lib/services/photoActions.ts
Normal file
68
web/src/lib/services/photoActions.ts
Normal 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}`);
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<!--
|
<!--
|
||||||
/review — PhotoPrism's quality-flagged photo queue, one tab per
|
/review — PhotoPrism's quality-flagged photo queue, one tab per
|
||||||
cause (mirrors /tags' pill row). Only causes with non-zero photos
|
cause. Tiles, selection, keyboard nav, and bulk actions all come
|
||||||
get a tab. Active tab is URL-driven via `?tab=<cause_key>` so
|
from the shared timeline machinery (PhotoGrid, gridKeyNav,
|
||||||
refresh / share / back land on the same panel.
|
BulkActionBar, BulkMetadataSidebar) so muscle memory carries
|
||||||
|
across routes.
|
||||||
|
|
||||||
Clicking a tile focuses that photo in the global selection store;
|
The route flips `filters.section = 'review'` while it's mounted —
|
||||||
the right-hand aside mounts `RightSidebar` with `showRelated=true`
|
that's what swings the shared action surface into review semantics
|
||||||
to surface same-folder / camera / year strips.
|
(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">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -18,22 +21,36 @@
|
|||||||
type ReviewGroup
|
type ReviewGroup
|
||||||
} from '$lib/services/adapters/review';
|
} 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 { clearSelection, selection } from '$lib/stores/selection.svelte';
|
||||||
|
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
|
||||||
import {
|
import {
|
||||||
|
setRightSidebarWidth,
|
||||||
setThumbnailSize,
|
setThumbnailSize,
|
||||||
THUMBNAIL_SIZE_LABELS,
|
THUMBNAIL_SIZE_LABELS,
|
||||||
THUMBNAIL_SIZE_PRESETS,
|
THUMBNAIL_SIZE_PRESETS,
|
||||||
view
|
view
|
||||||
} from '$lib/stores/view.svelte';
|
} from '$lib/stores/view.svelte';
|
||||||
import { getPhoto } from '$lib/services/photoprism';
|
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 type { PpPhoto } from '$lib/types/photoprism';
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
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 RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
|
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
||||||
|
|
||||||
function onSelect(photo: PpPhoto) {
|
// Stash the section that was active when the user arrived; restore
|
||||||
setFocused(photo.UID);
|
// 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[]>(() => ({
|
const reviewQuery = createQuery<ReviewGroup[]>(() => ({
|
||||||
queryKey: ['review-groups'],
|
queryKey: ['review-groups'],
|
||||||
@@ -49,31 +66,35 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const groups = $derived(reviewQuery.data ?? []);
|
const groups = $derived(reviewQuery.data ?? []);
|
||||||
// Only causes with hits get a tab — empty buckets are filtered out
|
const tabs = $derived(
|
||||||
// in the adapter already, but $derived re-runs whenever data changes.
|
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'));
|
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(() => {
|
const activeTab: CauseKey | null = $derived.by(() => {
|
||||||
if (tabs.length === 0) return null;
|
if (tabs.length === 0) return null;
|
||||||
const want = tabs.find((t) => t.id === requestedTab);
|
const want = tabs.find((t) => t.id === requestedTab);
|
||||||
return (want ?? tabs[0]).id;
|
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) {
|
function setTab(id: CauseKey) {
|
||||||
const params = new URLSearchParams();
|
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);
|
if (tabs.length > 0 && tabs[0].id !== id) params.set('tab', id);
|
||||||
void goto(`/review${params.size ? '?' + params : ''}`, {
|
void goto(`/review${params.size ? '?' + params : ''}`, {
|
||||||
keepFocus: true,
|
keepFocus: true,
|
||||||
noScroll: true
|
noScroll: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
@@ -81,8 +102,6 @@
|
|||||||
Review
|
Review
|
||||||
</span>
|
</span>
|
||||||
{#if tabs.length > 0}
|
{#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">
|
<div class="flex items-center gap-1">
|
||||||
{#each tabs as t (t.id)}
|
{#each tabs as t (t.id)}
|
||||||
<button
|
<button
|
||||||
@@ -122,43 +141,59 @@
|
|||||||
</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 px-6 py-4 pb-6">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
{#if reviewQuery.isPending}
|
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
||||||
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
{#if reviewQuery.isPending}
|
||||||
{:else if reviewQuery.error}
|
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
||||||
<p class="text-sm text-destructive">
|
{:else if reviewQuery.error}
|
||||||
Could not load review queue: {reviewQuery.error instanceof Error
|
<p class="text-sm text-destructive">
|
||||||
? reviewQuery.error.message
|
Could not load review queue: {reviewQuery.error instanceof Error
|
||||||
: 'unknown error'}
|
? reviewQuery.error.message
|
||||||
</p>
|
: 'unknown error'}
|
||||||
{: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>
|
</p>
|
||||||
</div>
|
{:else if groups.length === 0}
|
||||||
{:else if activeGroup}
|
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||||
{#key activeGroup.cause}
|
<p>The review queue is empty.</p>
|
||||||
<CauseGroupCard group={activeGroup} autoFocus {onSelect} />
|
<p class="text-xs">
|
||||||
{/key}
|
PhotoPrism's indexer flags photos with a low quality score for human
|
||||||
{/if}
|
review. New arrivals with missing EXIF, low resolution, or unknown
|
||||||
</main>
|
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
|
<aside
|
||||||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||||
style="width: {view.rightSidebarWidth}px;"
|
style="width: {view.rightSidebarWidth}px;"
|
||||||
>
|
>
|
||||||
<div class="h-full overflow-y-auto">
|
<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 />
|
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
|
||||||
{:else if focusedPhotoQuery.isFetching}
|
{:else if focusedPhotoQuery.isFetching}
|
||||||
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user