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:
389
web/src/lib/components/layout/LeftSidebar.svelte
Normal file
389
web/src/lib/components/layout/LeftSidebar.svelte
Normal 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)} />
|
||||
Reference in New Issue
Block a user