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