feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
289
web/src/lib/components/timeline/BulkActionBar.svelte
Normal file
289
web/src/lib/components/timeline/BulkActionBar.svelte
Normal file
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
addToHeap,
|
||||
batchArchive,
|
||||
batchDelete,
|
||||
batchRestore,
|
||||
likePhoto,
|
||||
listHeaps,
|
||||
removeFromHeap,
|
||||
unlikePhoto,
|
||||
type PpAlbum
|
||||
} from '$lib/services/photoprism';
|
||||
import { batchEdit } from '$lib/services/batch';
|
||||
import { clearSelection, selection, setFocused } from '$lib/stores/selection.svelte';
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
|
||||
const qc = useQueryClient();
|
||||
let busy = $state(false);
|
||||
let heapPickerOpen = $state(false);
|
||||
|
||||
const heapsQuery = createQuery<PpAlbum[]>(() => ({
|
||||
queryKey: ['heaps'],
|
||||
queryFn: listHeaps,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
/**
|
||||
* Targets of an action: the multi-selected set when one exists, else the
|
||||
* focused tile alone. Mule-image's design treats focus as "implicit single
|
||||
* selection" so the bar's actions always have something to operate on.
|
||||
*/
|
||||
function snapshotIds(): string[] {
|
||||
if (selection.ids.size > 0) return Array.from(selection.ids);
|
||||
if (selection.focused) return [selection.focused];
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetCount = $derived(
|
||||
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
||||
);
|
||||
const isBulk = $derived(selection.ids.size > 0);
|
||||
|
||||
function clearAll() {
|
||||
clearSelection();
|
||||
setFocused(null);
|
||||
}
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
||||
busy = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
busy = false;
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
}
|
||||
}
|
||||
|
||||
async function onArchive() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchArchive(ids);
|
||||
pushUndo(`Archived ${ids.length}`, async () => {
|
||||
await batchRestore(ids);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
clearSelection();
|
||||
toast.success(`Archived ${ids.length}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
const msg =
|
||||
ids.length === 1
|
||||
? 'Permanently delete this photo? This cannot be undone.'
|
||||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||
if (!confirm(msg)) return;
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchDelete(ids);
|
||||
clearSelection();
|
||||
toast.success(`Deleted ${ids.length}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onRestore() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchRestore(ids);
|
||||
pushUndo(`Restored ${ids.length}`, async () => {
|
||||
await batchArchive(ids);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
clearSelection();
|
||||
toast.success(`Restored ${ids.length}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Restore failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onFavorite() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
await withBusy(async () => {
|
||||
const { updated, errors } = await batchEdit(ids, (id) => likePhoto(id));
|
||||
if (errors.length) {
|
||||
toast.error(`Favorited ${updated.length}; ${errors.length} failed`);
|
||||
} else {
|
||||
toast.success(`Favorited ${ids.length}`);
|
||||
}
|
||||
pushUndo(`Favorited ${ids.length}`, async () => {
|
||||
await batchEdit(ids, (id) => unlikePhoto(id));
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
async function onUndo() {
|
||||
const entry = await popAndRun();
|
||||
if (entry) toast.success(`Undone: ${entry.label}`);
|
||||
else toast.message('Nothing to undo');
|
||||
}
|
||||
|
||||
async function onAddToHeap(heap: PpAlbum) {
|
||||
const ids = snapshotIds();
|
||||
if (!ids.length) return;
|
||||
heapPickerOpen = false;
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await addToHeap(heap.UID, ids);
|
||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||
toast.success(`Added ${ids.length} → ${heap.Title}`);
|
||||
pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => {
|
||||
await removeFromHeap(heap.UID, ids);
|
||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||
});
|
||||
clearSelection();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if targetCount > 0}
|
||||
<div
|
||||
class="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-background/95 px-6 py-3 shadow-lg backdrop-blur"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center gap-3">
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{#if isBulk}
|
||||
{targetCount} selected
|
||||
{:else}
|
||||
Focused photo
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<div class="relative">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
||||
title="Add to heap (S then 1–9 picks a heap)"
|
||||
>
|
||||
+ Add to heap
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>S N</kbd
|
||||
>
|
||||
</button>
|
||||
{#if heapPickerOpen}
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg"
|
||||
>
|
||||
{#if heapsQuery.isPending}
|
||||
<p class="px-2 py-1 text-muted-foreground">Loading…</p>
|
||||
{:else if (heapsQuery.data ?? []).length === 0}
|
||||
<p class="px-2 py-1 text-muted-foreground">No heaps yet</p>
|
||||
{:else}
|
||||
{#each heapsQuery.data ?? [] as heap, i (heap.UID)}
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-accent"
|
||||
onclick={() => onAddToHeap(heap)}
|
||||
>
|
||||
{#if i < 9}
|
||||
<kbd
|
||||
class="shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
title={`S ${i + 1}`}
|
||||
>
|
||||
{i + 1}
|
||||
</kbd>
|
||||
{:else}
|
||||
<span class="w-3 shrink-0"></span>
|
||||
{/if}
|
||||
<span class="flex-1 truncate">{heap.Title}</span>
|
||||
<span class="shrink-0 text-muted-foreground">
|
||||
({heap.PhotoCount ?? 0})
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onFavorite}
|
||||
title="Favorite"
|
||||
>
|
||||
♥ Favorite
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>F</kbd
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onArchive}
|
||||
title="Archive"
|
||||
>
|
||||
Archive
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>X</kbd
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onRestore}
|
||||
title="Restore"
|
||||
>
|
||||
Restore
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>U</kbd
|
||||
>
|
||||
</button>
|
||||
{#if filters.section === 'archive'}
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onDelete}
|
||||
title="Permanently delete (no undo)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || undoStack.entries.length === 0}
|
||||
onclick={onUndo}
|
||||
title="Undo last action"
|
||||
>
|
||||
Undo
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>⌘Z</kbd
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent"
|
||||
onclick={clearAll}
|
||||
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||||
>
|
||||
{isBulk ? 'Clear' : 'Dismiss'}
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>Esc</kbd
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user