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,190 @@
<script lang="ts" module>
/**
* Build a nested folder tree from PhotoPrism's flat `Path`-keyed
* folder list. The API returns one row per subfolder
* (`2024`, `2024/lyon`, `2024/paris`, …); we group by the parent
* segment so the UI can render a real <ul> tree.
*/
export interface TreeNode {
path: string;
name: string;
children: TreeNode[];
}
export function buildTree(paths: string[]): TreeNode[] {
const root: TreeNode = { path: '', name: '', children: [] };
const index = new Map<string, TreeNode>([['', root]]);
const sorted = [...paths].sort();
for (const p of sorted) {
const parts = p.split('/');
let parentPath = '';
for (let i = 0; i < parts.length; i++) {
const here = parts.slice(0, i + 1).join('/');
if (!index.has(here)) {
const node: TreeNode = {
path: here,
name: parts[i],
children: []
};
const parent = index.get(parentPath);
if (parent) parent.children.push(node);
index.set(here, node);
}
parentPath = here;
}
}
return root.children;
}
</script>
<script lang="ts">
import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment';
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
interface Props {
nodes: TreeNode[];
depth?: number;
onPick: (path: string) => void;
/** Mutating callbacks are only required when readonly !== true. The
* picker (HeapConvertDialog) reuses the tree just for `onPick`. */
onRename?: (path: string) => void;
onDelete?: (path: string) => void;
onCreateChild?: (parent: string) => void;
/** Read-only mode: hides the kebab menu and disables double-click
* rename, so the tree can be reused as a folder picker. */
readonly?: boolean;
/** Override the active-row predicate. By default rows light up when
* `filters.folderPath` matches (the sidebar nav case); the picker
* passes its own selection so the dialog has independent state. */
selectedPath?: string | null;
}
let {
nodes,
depth = 0,
onPick,
onRename,
onDelete,
onCreateChild,
readonly = false,
selectedPath
}: Props = $props();
// Auto-expanded folders, persisted to localStorage so the tree state
// survives reloads. Empty set = everything collapsed at start.
const KEY = 'mule_folder_open';
let openSet = $state<Set<string>>(loadOpen());
function loadOpen(): Set<string> {
if (!browser) return new Set();
try {
const raw = localStorage.getItem(KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
function persist() {
if (browser) localStorage.setItem(KEY, JSON.stringify([...openSet]));
}
function toggle(p: string) {
if (openSet.has(p)) openSet.delete(p);
else openSet.add(p);
openSet = new Set(openSet); // re-trigger reactivity
persist();
}
function isActive(path: string): boolean {
if (selectedPath !== undefined) return selectedPath === path;
return filters.folderPath === path;
}
</script>
<ul>
{#each nodes as node (node.path)}
{@const open = openSet.has(node.path)}
{@const active = isActive(node.path)}
{@const hasChildren = node.children.length > 0}
<li>
<!--
Indent via padding-left rather than nested margin+border, so the
active row's background bleeds edge-to-edge of the sidebar (matches
mule-image's compact tree). Depth × 12px keeps lines aligned with
the chevron of the previous level.
-->
<div
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: {depth * 12}px;"
>
{#if hasChildren}
<button
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
class:text-muted-foreground={!active}
onclick={() => toggle(node.path)}
title={open ? 'Collapse' : 'Expand'}
aria-label={open ? 'Collapse' : 'Expand'}
>
{open ? '▾' : '▸'}
</button>
{:else}
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<button
class="flex flex-1 items-center truncate px-1 text-left"
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path}
>
<span class="truncate">{node.name}</span>
</button>
{#if !readonly}
<!-- Hover-revealed kebab. Reserves zero width when idle so the
row stays compact; expands on hover and stays visible while
the menu is open. Suppressed in readonly mode (picker). -->
<div class="mr-1">
<KebabMenu label="Folder actions">
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
onSelect={() => onCreateChild?.(node.path)}
>
<FolderPlus class="h-3.5 w-3.5 text-muted-foreground" />
New subfolder
</Item>
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
onSelect={() => onRename?.(node.path)}
>
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
Rename
</Item>
<Separator class="my-1 h-px bg-border" />
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] text-destructive outline-none hover:bg-destructive/10 focus:bg-destructive/10"
onSelect={() => onDelete?.(node.path)}
>
<Trash2 class="h-3.5 w-3.5" />
Delete folder…
</Item>
</KebabMenu>
</div>
{/if}
</div>
{#if hasChildren && open}
<Self
nodes={node.children}
depth={depth + 1}
{onPick}
{onRename}
{onDelete}
{onCreateChild}
{readonly}
{selectedPath}
/>
{/if}
</li>
{/each}
</ul>