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:
2026-05-17 16:06:58 +02:00
parent 423a73a8a6
commit 8c2526d982
69 changed files with 12048 additions and 0 deletions

View 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 19 picks a heap)"
>
Add to heap
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>S&nbsp;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}