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>

View File

@@ -0,0 +1,226 @@
<!--
Move/copy every photo in a heap into a folder under originals/.
Picker reuses the existing FolderTree in readonly mode; the dialog owns
the selection (`pickedPath`) so it doesn't conflict with the global
folderPath filter the sidebar drives.
Submit goes to the sidecar's POST /albums/:uid/convert. On success we
invalidate the photos / folders / heaps queries so the timeline and
sidebar refresh; if the heap was deleted and was active, route home.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { FolderInput, Loader2 } from 'lucide-svelte';
import {
convertHeap,
listFolders,
type HeapConvertBody,
type HeapConvertResult,
type PpAlbum,
type PpFolder
} from '$lib/services/photoprism';
import { filters, setSection } from '$lib/stores/filters.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
interface Props {
heap: PpAlbum | null;
onClose: () => void;
}
let { heap, onClose }: Props = $props();
const qc = useQueryClient();
// Reuse the same folders cache the sidebar uses — same key so we share
// the in-flight request, and the picker invalidates it on success.
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'],
queryFn: listFolders,
enabled: isAuthenticated()
}));
const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
);
let pickedPath = $state<string | null>(null);
let mode = $state<'move' | 'copy'>('move');
let subfolder = $state('');
let deleteHeap = $state(false);
// Reset draft state whenever a new heap is picked (or the dialog closes
// and reopens). $effect runs after the prop change, so the form is
// blank on every fresh open.
$effect(() => {
void heap;
pickedPath = null;
mode = 'move';
subfolder = '';
deleteHeap = false;
});
const convertMut = createMutation(() => ({
mutationFn: (args: { uid: string; body: HeapConvertBody }) =>
convertHeap(args.uid, args.body),
onSuccess: (result: HeapConvertResult, vars) => {
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['heaps'] });
const verb = mode === 'copy' ? 'Copied' : 'Moved';
const count = mode === 'copy' ? result.copied : result.moved;
const tail =
result.errors.length > 0
? ` · ${result.errors.length} skipped`
: '';
toast.success(`${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`);
// If the heap got deleted and we were viewing it, fall back home.
if (
result.heap_deleted &&
filters.section === 'heap' &&
filters.heapUid === vars.uid
) {
setSection('all-photos');
void goto('/', { keepFocus: true, noScroll: true });
}
onClose();
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Convert failed')
}));
function submit() {
if (!heap || !pickedPath) return;
convertMut.mutate({
uid: heap.UID,
body: {
targetFolder: pickedPath,
mode,
subfolder: subfolder.trim() || null,
deleteHeap: mode === 'move' && deleteHeap
}
});
}
// Copy mode doesn't change membership, so "delete heap after" is
// meaningless. Force-clear it when the user flips back to copy.
$effect(() => {
if (mode === 'copy' && deleteHeap) deleteHeap = false;
});
const open = $derived(heap !== null);
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
{mode === 'copy' ? 'Copy' : 'Move'} heap to folder
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
{heap?.Title ?? ''} · {heap?.PhotoCount ?? 0} photo{heap?.PhotoCount === 1
? ''
: 's'}
</Dialog.Description>
</div>
</div>
<!-- Folder picker. Readonly FolderTree so the user can't kebab/
rename their way out of the picker mid-flow. -->
<div class="rounded-md border border-border bg-background p-2">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Destination
</div>
<div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending}
<p class="px-2 py-1 text-[11px] text-muted-foreground">Loading folders…</p>
{:else if (foldersQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-[11px] text-muted-foreground">
No folders. Create one from the sidebar first.
</p>
{:else}
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
selectedPath={pickedPath}
readonly
/>
{/if}
</div>
</div>
<!-- Mode + options. Plain radio + checkbox; bits-ui has dedicated
primitives but inline form controls keep the dialog small. -->
<div class="space-y-2">
<div class="flex items-center gap-4 text-[12px]">
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="move" />
Move
</label>
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="copy" />
Copy
</label>
</div>
<label class="flex flex-col gap-1 text-[12px]">
<span class="text-muted-foreground">
New subfolder (optional)
</span>
<input
type="text"
placeholder="e.g. {heap?.Title ?? 'My heap'}"
bind:value={subfolder}
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex items-center gap-1.5 text-[12px]">
<input
type="checkbox"
bind:checked={deleteHeap}
disabled={mode === 'copy'}
/>
<span class:text-muted-foreground={mode === 'copy'}>
Delete heap after move
</span>
</label>
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={onClose}
disabled={convertMut.isPending}
>
Cancel
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit}
disabled={!pickedPath || convertMut.isPending}
>
{#if convertMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{mode === 'copy' ? 'Copy' : 'Move'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -0,0 +1,56 @@
<!--
Thin wrapper around bits-ui's DM. Provides:
- A round ⋯ trigger button styled like the rest of the sidebar's hover
affordances (muted, becomes accent on hover/open).
- A portal-positioned content container with shadcn-zinc styling.
- An `Item` re-export consumers compose into the menu body so we don't
also have to redeclare the item styling at every call site.
Items are passed as a snippet via `children` so callers can mix the
`Item` re-export, separators, or destructive variants freely.
-->
<script lang="ts" module>
import { DropdownMenu as DM } from 'bits-ui';
export const Item = DM.Item;
export const Separator = DM.Separator;
</script>
<script lang="ts">
import { MoreHorizontal } from 'lucide-svelte';
interface Props {
/** Tooltip + aria-label for the trigger button. */
label?: string;
/** Force the trigger visible regardless of hover state. Used when
* the menu is open so it doesn't disappear underneath a row hover
* transition while the user is interacting with it. */
alwaysVisible?: boolean;
children: import('svelte').Snippet;
}
let { label = 'More', alwaysVisible = false, children }: Props = $props();
let open = $state(false);
</script>
<DM.Root bind:open>
<DM.Trigger
class="rounded p-0.5 text-xs text-muted-foreground transition-opacity hover:bg-accent hover:text-foreground focus:outline-none {open ||
alwaysVisible
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'}"
title={label}
aria-label={label}
onclick={(e) => e.stopPropagation()}
>
<MoreHorizontal class="h-3.5 w-3.5" />
</DM.Trigger>
<DM.Portal>
<DM.Content
class="z-50 min-w-[180px] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none"
sideOffset={4}
align="end"
>
{@render children()}
</DM.Content>
</DM.Portal>
</DM.Root>

View File

@@ -0,0 +1,389 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
createFolder,
createHeap,
deleteFolder,
deleteHeap,
duplicateHeap,
heapDownloadUrl,
listFolders,
listHeaps,
renameFolder,
renameHeap,
triggerDownload,
type PpAlbum,
type PpFolder
} from '$lib/services/photoprism';
import {
filters,
setFolderPath,
setSection,
type Section
} from '$lib/stores/filters.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import { Copy, Download, FolderInput, Pencil, Trash2 } from 'lucide-svelte';
const qc = useQueryClient();
const heapsQuery = createQuery<PpAlbum[]>(() => ({
queryKey: ['heaps'],
queryFn: listHeaps,
enabled: isAuthenticated()
}));
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'],
queryFn: listFolders,
enabled: isAuthenticated()
}));
const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
);
const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title),
onSuccess: (h) => {
qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success(`Heap created: ${h.Title}`);
navigateTo('heap', h.UID);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create heap')
}));
const renameMut = createMutation(() => ({
mutationFn: (args: { uid: string; title: string }) => renameHeap(args.uid, args.title),
onSuccess: () => qc.invalidateQueries({ queryKey: ['heaps'] })
}));
const deleteMut = createMutation(() => ({
mutationFn: (uid: string) => deleteHeap(uid),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success('Heap deleted');
if (filters.section === 'heap') navigateTo('all-photos');
}
}));
const duplicateMut = createMutation(() => ({
mutationFn: (uid: string) => duplicateHeap(uid),
onSuccess: (copy) => {
qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success(`Duplicated → ${copy.Title}`);
navigateTo('heap', copy.UID);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not duplicate heap')
}));
// Heap currently being converted (move/copy to folder). Setting this
// mounts <HeapConvertDialog>; the dialog clears it on close.
let convertingHeap = $state<PpAlbum | null>(null);
async function navigateTo(section: Section, heapUid: string | null = null) {
setSection(section, heapUid);
setFolderPath(null);
const params = new URLSearchParams();
if (section !== 'all-photos') params.set('section', section);
if (heapUid) params.set('heap', heapUid);
const qs = params.toString();
await goto(`/${qs ? '?' + qs : ''}`, { keepFocus: true, noScroll: true });
}
const createFolderMut = createMutation(() => ({
mutationFn: (relPath: string) => createFolder(relPath),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(`Folder created: ${r.path}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create folder')
}));
const renameFolderMut = createMutation(() => ({
mutationFn: (args: { rel: string; newName: string }) =>
renameFolder(args.rel, args.newName),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['photos'] });
// If the active folder filter was on this folder, follow the rename.
if (filters.folderPath === r.oldPath) {
setFolderPath(r.newPath);
const params = new URLSearchParams({ folder: r.newPath });
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
toast.success(`Renamed: ${r.oldPath}${r.newPath}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Rename failed')
}));
const deleteFolderMut = createMutation(() => ({
mutationFn: (rel: string) => deleteFolder(rel),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
if (filters.folderPath && filters.folderPath.startsWith(r.path)) {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}
toast.success(`Folder deleted: ${r.path}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Delete failed')
}));
function onCreateFolder(parent: string | null = null) {
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
if (!name) return;
const rel = parent ? `${parent}/${name}` : name;
createFolderMut.mutate(rel);
}
function onRenameFolder(rel: string) {
const segs = rel.split('/');
const cur = segs[segs.length - 1];
const next = prompt(`Rename folder "${rel}"`, cur)?.trim();
if (!next || next === cur) return;
renameFolderMut.mutate({ rel, newName: next });
}
function onDeleteFolder(rel: string) {
if (!confirm(`Delete folder "${rel}"? Must be empty.`)) return;
deleteFolderMut.mutate(rel);
}
async function pickFolder(folderPath: string) {
// Folder selection works on top of the All Photos section; clearing
// the heap/section context mirrors mule-image's "drill into folder"
// behaviour. The URL sync $effect on the timeline picks this up.
setSection('all-photos');
setFolderPath(folderPath);
const params = new URLSearchParams();
params.set('folder', folderPath);
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
function onCreateHeap() {
const title = prompt('Heap name')?.trim();
if (title) createMut.mutate(title);
}
function onRenameHeap(h: PpAlbum) {
const title = prompt('Rename heap', h.Title)?.trim();
if (title && title !== h.Title) renameMut.mutate({ uid: h.UID, title });
}
function onDeleteHeap(h: PpAlbum) {
if (confirm(`Delete heap "${h.Title}"? Photos stay in the library.`)) {
deleteMut.mutate(h.UID);
}
}
// Sync section into URL when filters change (so back/forward works).
function isActive(section: Section, heapUid: string | null = null): boolean {
if (page.url.pathname !== '/') return false;
if (filters.section !== section) return false;
if (section === 'heap' && filters.heapUid !== heapUid) return false;
return true;
}
// Single Views group — section-driven entries and route-driven entries
// mixed in display order. `kind` discriminates which click handler runs
// (sections go through `navigateTo` to seed filter state; routes are
// plain links). Archive intentionally sits at the bottom to keep it out
// of the way of the everyday-browse rows.
type ViewItem =
| { kind: 'section'; id: Section; label: string }
| { kind: 'route'; href: string; label: string };
const views: ViewItem[] = [
{ kind: 'section', id: 'all-photos', label: 'All photos' },
{ kind: 'section', id: 'favorites', label: 'Favorites' },
{ kind: 'route', href: '/duplicates', label: 'Duplicates' },
{ kind: 'route', href: '/map', label: 'Map' },
{ kind: 'route', href: '/ratings', label: 'Ratings' },
{ kind: 'route', href: '/colors', label: 'Colors' },
{ kind: 'route', href: '/tags', label: 'Tags' },
{ kind: 'section', id: 'archive', label: 'Archive' }
];
function isRouteActive(href: string): boolean {
return page.url.pathname === href;
}
</script>
<nav class="space-y-3">
<!-- Views — section-driven entries + route-driven entries under a
single uppercase eyebrow. Compact rows, no icons. -->
<div>
<div class="px-3 pb-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Views
</span>
</div>
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{#if v.kind === 'section'}
<button
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={isActive(v.id)}
class:text-primary-foreground={isActive(v.id)}
class:hover:bg-primary={isActive(v.id)}
onclick={() => navigateTo(v.id)}
>
<span class="truncate">{v.label}</span>
</button>
{:else}
<a
href={v.href}
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={isRouteActive(v.href)}
class:text-primary-foreground={isRouteActive(v.href)}
class:hover:bg-primary={isRouteActive(v.href)}
>
<span class="truncate">{v.label}</span>
</a>
{/if}
{/each}
</div>
<div>
<div class="group/header flex items-center px-3 pb-1">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Heaps
</span>
<button
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={onCreateHeap}
title="New heap"
aria-label="New heap"
>
</button>
</div>
{#if heapsQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
{:else if heapsQuery.isError}
<p class="px-2 text-[11px] text-destructive">Failed to load heaps</p>
{:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 text-[11px] text-muted-foreground">No heaps yet.</p>
{:else}
<ul>
{#each heapsQuery.data ?? [] as heap (heap.UID)}
{@const active = isActive('heap', heap.UID)}
<li class="group flex items-center">
<button
class="flex h-[24px] flex-1 items-center gap-2 rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
>
<span class="truncate">{heap.Title}</span>
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
</button>
<div class="pl-0.5">
<KebabMenu label="Heap 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={() => onRenameHeap(heap)}
>
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
Rename
</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={() => duplicateMut.mutate(heap.UID)}
>
<Copy class="h-3.5 w-3.5 text-muted-foreground" />
Duplicate
</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={() => triggerDownload(heapDownloadUrl(heap.UID))}
>
<Download class="h-3.5 w-3.5 text-muted-foreground" />
Download as zip
</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={() => (convertingHeap = heap)}
>
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
Move to folder…
</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={() => onDeleteHeap(heap)}
>
<Trash2 class="h-3.5 w-3.5" />
Delete heap…
</Item>
</KebabMenu>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<div>
<div class="group/header flex items-center px-3 pb-1">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Folders
</span>
<button
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => onCreateFolder(null)}
title="New top-level folder"
aria-label="New top-level folder"
>
</button>
</div>
{#if foldersQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
{:else if (foldersQuery.data ?? []).length === 0}
<p class="px-2 text-[11px] text-muted-foreground">No subfolders.</p>
{:else}
<FolderTree
nodes={folderTree}
onPick={pickFolder}
onRename={onRenameFolder}
onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)}
/>
{/if}
{#if filters.folderPath}
<button
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}}
title="Clear folder filter"
>
<span class="truncate">{filters.folderPath}</span>
</button>
{/if}
</div>
</nav>
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />

View File

@@ -0,0 +1,73 @@
<!--
Thin sub-header bar that sits below the AnimatedMule. Matches the legacy
mule-image FilterBar height (h-9) and toggle layout: left-sidebar toggle
pinned to the far-left edge, right-sidebar toggle pinned to the far-right.
Page-specific content (section badge, search, etc.) goes in the middle,
and page-specific buttons (dark-mode, sign-out, route counts…) live in
the trailing slot.
The bar is sticky-top so it stays visible as the timeline scrolls past
the animated header above.
-->
<script lang="ts">
import {
PanelLeftOpen,
PanelLeftClose,
PanelRightOpen,
PanelRightClose
} from 'lucide-svelte';
import {
toggleLeftSidebar,
toggleRightSidebar,
view
} from '$lib/stores/view.svelte';
interface Props {
/** Render the right-sidebar toggle. Routes without a right panel
* (map, ratings, colors, tags) leave this off. */
showRightToggle?: boolean;
children?: import('svelte').Snippet;
trailing?: import('svelte').Snippet;
}
let { showRightToggle = false, children, trailing }: Props = $props();
</script>
<div
class="flex h-9 shrink-0 items-center gap-3 border-b border-border bg-background/80 px-3 backdrop-blur"
>
<button
class="flex shrink-0 items-center rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={toggleLeftSidebar}
title={view.leftSidebarCollapsed ? 'Expand nav (b)' : 'Collapse nav (b)'}
aria-label={view.leftSidebarCollapsed ? 'Expand left panel' : 'Collapse left panel'}
>
{#if view.leftSidebarCollapsed}
<PanelLeftOpen class="h-3.5 w-3.5" />
{:else}
<PanelLeftClose class="h-3.5 w-3.5" />
{/if}
</button>
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
{@render children?.()}
</div>
<div class="flex shrink-0 items-center gap-2">
{@render trailing?.()}
</div>
{#if showRightToggle}
<button
class="flex shrink-0 items-center rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={toggleRightSidebar}
title={view.rightSidebarCollapsed ? 'Show info (i)' : 'Hide info (i)'}
aria-label={view.rightSidebarCollapsed ? 'Show right panel' : 'Hide right panel'}
>
{#if view.rightSidebarCollapsed}
<PanelRightOpen class="h-3.5 w-3.5" />
{:else}
<PanelRightClose class="h-3.5 w-3.5" />
{/if}
</button>
{/if}
</div>