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,265 @@
<!--
One cross-folder duplicate group rendered as a card. Lists every on-disk
copy of the same byte-identical file. The user picks one to keep; the
rest are archived to `.duplicates/<timestamp>/` via the sidecar.
Differences from StackGroupCard (which operates on PhotoPrism Files in
a single Photo stack):
- These photos are NOT in PhotoPrism's DB (PhotoPrism dropped them at
index time). They're files on disk only.
- Thumbnails come via `thumbUrl(hash, ...)` — content-addressed, so we
can render every copy from the same hash even though only one Photo
entry exists.
- Resolution moves files (reversible) rather than deletes (irreversible).
Same keyboard contract as StackGroupCard: arrows pick the keeper,
Enter commits.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
archiveDuplicatePaths,
type CrossFolderDuplicateGroup
} from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
interface Props {
group: CrossFolderDuplicateGroup;
/** First-card auto-focus, same pattern as StackGroupCard. */
autoFocus?: boolean;
}
let { group, autoFocus = false }: Props = $props();
const qc = useQueryClient();
let keep = $state('');
let busy = $state(false);
let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let cols = $state(1);
// Seed `keep` from the indexed path when available; that's the safest
// default because losing it would leave PhotoPrism with no copy. Fall
// back to the first listed path.
$effect(() => {
const validPaths = new Set(group.files.map((f) => f.path));
if (!keep || !validPaths.has(keep)) {
keep =
group.indexedPath && validPaths.has(group.indexedPath)
? group.indexedPath
: group.files[0]?.path ?? '';
}
});
$effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
});
// Column-count tracking — identical pattern to StackGroupCard.
$effect(() => {
if (!gridEl) return;
const measure = () => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
});
function sizeLabel(bytes: number): string {
if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
return `${Math.round(bytes / 1024)} KB`;
}
function shortFolder(relPath: string): string {
const segs = relPath.split('/').filter(Boolean);
if (segs.length <= 1) return '(root)';
return segs.slice(0, -1).join('/');
}
function moveKeep(delta: number) {
const i = group.files.findIndex((f) => f.path === keep);
if (i < 0) return;
const next = Math.min(Math.max(0, i + delta), group.files.length - 1);
keep = group.files[next].path;
}
function onKeydown(e: KeyboardEvent) {
if (busy) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
moveKeep(-1);
return;
case 'ArrowRight':
e.preventDefault();
moveKeep(1);
return;
case 'ArrowUp':
e.preventDefault();
moveKeep(-cols);
return;
case 'ArrowDown':
e.preventDefault();
moveKeep(cols);
return;
case 'Enter':
e.preventDefault();
void commit();
return;
case 'Escape':
(e.target as HTMLElement)?.blur();
return;
}
}
async function commit() {
if (busy || group.files.length < 2) return;
// Defensive guard: never archive the indexed copy. The user can
// pick a different "keeper" but the archive list is computed AFTER
// resolving that into "everything except the keeper". If they pick
// a non-indexed copy as keeper, the indexed one gets archived —
// PhotoPrism will lose its photo entry on the cleanup reindex.
// That's a legitimate user choice (they wanted to move the
// canonical copy), just call it out in the toast.
const losers = group.files.filter((f) => f.path !== keep);
if (losers.length === 0) return;
const losingIndexed =
group.indexedPath && losers.some((f) => f.path === group.indexedPath);
busy = true;
try {
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
if (result.errors.length > 0) {
toast.error(
`Archived ${result.moved.length}; ${result.errors.length} failed`,
{
description: result.errors[0].error
}
);
} else {
toast.success(
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
{
description: losingIndexed
? 'The previously-indexed copy was moved; PhotoPrism will drop it on the next index pass.'
: 'Files moved to .duplicates/ inside originals.'
}
);
}
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
} finally {
busy = false;
}
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Cross-folder duplicate · ${group.files.length} copies`}
onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
focus-visible:ring-2 focus-visible:ring-primary/50"
>
<header class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-foreground">
{group.files.length} copies · {sizeLabel(group.size)} each
</div>
<div class="truncate text-[10px] font-mono text-muted-foreground">
sha1 {group.hash.slice(0, 16)}
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 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 || group.files.length < 2}
onclick={commit}
title="Move the unselected copies to .duplicates/ (reversible)"
>
Keep selected, archive rest
<kbd
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
</button>
</header>
<div
bind:this={gridEl}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.files as file (file.path)}
{@const isKeep = file.path === keep}
{@const isIndexed = file.path === group.indexedPath}
<button
type="button"
onclick={() => (keep = file.path)}
class:scale-95={isKeep}
class:ring-2={isKeep}
class:ring-blue-500={isKeep}
class:ring-offset-2={isKeep}
class:ring-offset-background={isKeep}
class:transition-[transform,box-shadow]={isKeep}
class:duration-300={isKeep}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isKeep}
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
>
<div class="relative aspect-square w-full overflow-hidden">
<img
src={thumbUrl(group.hash, 'tile_500')}
alt={file.path}
loading="lazy"
class="h-full w-full object-cover"
/>
{#if isKeep}
<span
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
Keep
</span>
{/if}
{#if isIndexed}
<span
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
title="Currently indexed by PhotoPrism"
>
Indexed
</span>
{/if}
</div>
<div
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
title={file.path}
>
<div class="truncate text-foreground/90">{shortFolder(file.path)}</div>
<div class="truncate font-mono">{file.path.split('/').pop()}</div>
</div>
</button>
{/each}
</div>
</div>

View File

@@ -0,0 +1,225 @@
<!--
Duplicate-resolution page body. Two tabs:
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via
`stack:true` and resolve via `setPrimary` + `deleteFile`.
2. Cross-folder — files PhotoPrism silently rejected at index time
because they were byte-identical to an existing entry. PhotoPrism
never adds those rows to its DB, so we scan the filesystem via the
mule-sidecar. Resolution moves the unwanted copies into a
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
The cross-folder scan is opt-in (button-triggered) rather than
auto-run because it's an O(disk) operation. With size pre-filtering
the scan stays fast (~250ms for 400 files in practice).
-->
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
scanCrossFolderDuplicates,
type CrossFolderScanResult
} from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
interface Props {
groups: DuplicateGroup[];
pending: boolean;
error: unknown;
}
let { groups, pending, error }: Props = $props();
const qc = useQueryClient();
type Tab = 'stacks' | 'cross-folder';
let activeTab = $state<Tab>('stacks');
// Cross-folder scan is a manually-triggered query: `enabled` stays
// false until the user clicks "Scan filesystem". Subsequent clicks
// invalidate the cache so each press kicks a fresh scan.
let scanRequested = $state(false);
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryFn: scanCrossFolderDuplicates,
enabled: scanRequested,
staleTime: 5 * 60_000
}));
function triggerScan() {
if (scanRequested && !crossQuery.isFetching) {
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
} else {
scanRequested = true;
}
}
$effect(() => {
if (crossQuery.error) {
toast.error(
crossQuery.error instanceof Error
? crossQuery.error.message
: 'Cross-folder scan failed'
);
}
});
const stackCount = $derived(groups.length);
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
// Tabs: only the visible card under the active tab should auto-focus.
// We pass `autoFocus={i === 0}` into the FIRST card of the active tab
// (and only when that tab is selected) so keyboard navigation lands
// on the right place when the user switches tabs.
function tabBtnClass(tab: Tab) {
const base =
'inline-flex items-center gap-2 border-b-2 px-3 py-1.5 text-sm transition-colors';
return tab === activeTab
? `${base} border-foreground text-foreground`
: `${base} border-transparent text-muted-foreground hover:text-foreground`;
}
</script>
<div class="space-y-4">
<!-- Tab bar — sticky so it stays visible while the panel scrolls.
Same horizontal padding as the panels below so labels line up. -->
<div
role="tablist"
class="sticky top-0 z-10 flex items-center gap-1 border-b border-border bg-background px-6"
>
<button
type="button"
role="tab"
aria-selected={activeTab === 'stacks'}
class={tabBtnClass('stacks')}
onclick={() => (activeTab = 'stacks')}
>
Stacks
<span
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{pending ? '…' : stackCount}
</span>
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'cross-folder'}
class={tabBtnClass('cross-folder')}
onclick={() => (activeTab = 'cross-folder')}
>
Cross-folder
<span
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{#if !scanRequested}
·
{:else if crossQuery.isFetching && !crossQuery.data}
{:else}
{crossCount}
{/if}
</span>
</button>
</div>
<!-- Stacks tab ----------------------------------------------------- -->
{#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 pb-6">
{#if pending}
<p class="text-sm text-muted-foreground">Loading stacks…</p>
{:else if error}
<p class="text-sm text-destructive">
Could not load stacks: {error instanceof Error
? error.message
: 'unknown error'}
</p>
{:else if stackCount === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>No stacks.</p>
<p class="text-xs">
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you
don't have any, this tab stays empty. Cross-folder copies that
PhotoPrism rejected at index time live under the
<button
type="button"
class="underline hover:text-foreground"
onclick={() => (activeTab = 'cross-folder')}
>
Cross-folder
</button>
tab.
</p>
</div>
{:else}
<div class="space-y-3">
{#each groups as group, i (group.photo.UID)}
<StackGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}
<!-- Cross-folder tab ----------------------------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 pb-6">
<header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground">
Byte-identical files PhotoPrism dropped at index time. Found by
scanning the originals tree directly.
</p>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={triggerScan}
>
{#if crossQuery.isFetching}
Scanning…
{:else if scanRequested}
Rescan filesystem
{:else}
Scan filesystem
{/if}
</button>
</header>
{#if !scanRequested}
<p class="text-sm text-muted-foreground">
Click <em>Scan filesystem</em> to look for byte-identical files spread
across folders. Pre-filtered by size, so even large libraries finish
in a few seconds.
</p>
{:else if crossQuery.isFetching && !crossQuery.data}
<p class="text-sm text-muted-foreground">
Hashing files under originals…
</p>
{:else if crossQuery.isError}
<p class="text-sm text-destructive">
Scan failed: {crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
</p>
{:else if crossCount === 0}
<p class="text-sm text-muted-foreground">
No cross-folder duplicates found.
{#if crossQuery.data}
<span class="ml-1 text-[10px] text-muted-foreground/70">
(scanned in {crossQuery.data.scannedMs} ms)
</span>
{/if}
</p>
{:else}
<div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
<CrossFolderGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,289 @@
<!--
One duplicate stack rendered as a card. Each variant file is a clickable
tile; clicking selects it as the candidate "best". Committing promotes
the selected file to Primary (via `setPrimary`) and deletes the rest from
the stack (via `deleteFile` — PhotoPrism's flat `DELETE /photos/:uid/
files/:fid` route).
Why DELETE instead of unstack-then-archive (which the plan started with):
PhotoPrism's `/unstack` returns `only originals can be unstacked` for
sidecar JPGs and `Changes could not be saved` for live-photo HEIC+MOV
pairs. DELETE works for all of them — and cascades through the live-
photo group automatically, so one click resolves the whole stack. The
on-disk file is renamed with a hash suffix (not erased), so a future
manual reindex can recover it if needed.
Keyboard:
- Section is tabindex=0; focusing it captures arrow keys + Enter.
- Left/Right move the "best" highlight one file; Up/Down move by the
grid's computed column count (same trick the timeline uses for
cross-row arrow nav).
- Enter commits the current selection. Esc removes focus from the card.
- The page's first card auto-focuses on mount so the user can drive
the workflow keyboard-first.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { deleteFile, setPrimary } from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import { view } from '$lib/stores/view.svelte';
interface Props {
group: DuplicateGroup;
/** When true, the section auto-focuses on mount so the user can
* arrow-key/Enter the workflow without reaching for the mouse.
* Only the page's first card should get this. */
autoFocus?: boolean;
}
let { group, autoFocus = false }: Props = $props();
const qc = useQueryClient();
let best = $state('');
let busy = $state(false);
let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let cols = $state(1);
$effect(() => {
// Seed / re-seed `best` from the prop when the underlying group
// changes (keyed each + UID key normally keeps this stable, but
// the guard handles prop swaps without overwriting user clicks).
if (!best || !group.files.some((f) => f.UID === best)) {
best = group.bestFileUid;
}
});
$effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
});
// Track the grid's column count via ResizeObserver — same approach
// the timeline uses. Reading `gridTemplateColumns` from computed
// style is O(1) regardless of how many tiles render.
$effect(() => {
if (!gridEl) return;
const measure = () => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
// thumbnailSize changes alter cols without resizing the grid; re-
// measure on the next microtask.
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
});
function shortPath(name: string): string {
const segs = name.split('/').filter(Boolean);
if (segs.length <= 2) return name;
return '…/' + segs.slice(-2).join('/');
}
function dims(f: { Width?: number; Height?: number }): string {
if (!f.Width || !f.Height) return '';
return `${f.Width}×${f.Height}`;
}
function sizeLabel(bytes?: number): string {
if (!bytes) return '';
if (bytes > 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
return `${Math.round(bytes / 1024)} KB`;
}
function moveBest(delta: number) {
const i = group.files.findIndex((f) => f.UID === best);
if (i < 0) return;
const next = Math.min(Math.max(0, i + delta), group.files.length - 1);
best = group.files[next].UID;
}
function onKeydown(e: KeyboardEvent) {
if (busy) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
moveBest(-1);
return;
case 'ArrowRight':
e.preventDefault();
moveBest(1);
return;
case 'ArrowUp':
e.preventDefault();
moveBest(-cols);
return;
case 'ArrowDown':
e.preventDefault();
moveBest(cols);
return;
case 'Enter':
e.preventDefault();
void commit();
return;
case 'Escape':
(e.target as HTMLElement)?.blur();
return;
}
}
async function commit() {
if (busy || group.files.length < 2) return;
busy = true;
const photoUid = group.photo.UID;
const losers = group.files.filter((f) => f.UID !== best);
try {
// 1. Promote the user's pick to Primary first (idempotent — if
// it's already Primary, the call is a no-op on the server).
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
if (best !== currentPrimary) {
await setPrimary(photoUid, best);
}
// 2. Delete each non-best file. PhotoPrism cascades through
// related variants in the same logical group (live-photo
// pairs, sidecar companions), so a single DELETE on one
// HEIC variant clears the whole HEIC+MOV pair in one go.
// Loop tolerates partial success — if PhotoPrism already
// cleared the file via cascade, the next DELETE 404s and
// we move on.
for (const f of losers) {
try {
await deleteFile(photoUid, f.UID);
} catch (err) {
// 404 means the file's already gone (cascade) — fine.
// Any other status means we have a real problem; bubble it.
const status = (err as { response?: { status?: number } })?.response
?.status;
if (status !== 404) throw err;
}
}
toast.success(`Resolved · kept 1 of ${group.files.length}`);
void qc.invalidateQueries({ queryKey: ['duplicates'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
const msg =
err instanceof Error && err.message ? err.message : 'Resolve failed';
toast.error(msg);
} finally {
busy = false;
}
}
</script>
<!-- Section is focusable so we can capture arrow keys + Enter. `outline-
none` because we paint our own focus ring on .focus-visible below
(otherwise the browser default outline would clash with the tile
selection ring). -->
<!--
`role="application"` declares this as a custom keyboard widget (arrow
keys + Enter, not standard reading order). The element below is a
`<div>` rather than `<section>` because Svelte's a11y linter treats
`<section>` as strictly non-interactive even with an explicit
application role.
-->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={sectionEl}
tabindex="0"
role="application"
aria-label={`Duplicate stack of ${group.files.length} files — arrow keys pick the file to keep, Enter resolves`}
onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
focus-visible:ring-2 focus-visible:ring-primary/50"
>
<header class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-foreground">
{group.files.length} files in this stack
</div>
<div class="truncate text-xs text-muted-foreground">
{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 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 || group.files.length < 2}
onclick={commit}
title="Promote the selected file and delete the rest from this stack"
>
Keep selected, delete rest
<kbd
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
</button>
</header>
<div
bind:this={gridEl}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.files as file (file.UID)}
{@const isBest = file.UID === best}
{@const sizeStr = sizeLabel(file.Size)}
<button
type="button"
onclick={() => (best = file.UID)}
class:scale-95={isBest}
class:ring-2={isBest}
class:ring-blue-500={isBest}
class:ring-offset-2={isBest}
class:ring-offset-background={isBest}
class:transition-[transform,box-shadow]={isBest}
class:duration-300={isBest}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isBest}
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
>
<div class="relative aspect-square w-full overflow-hidden">
<img
src={thumbUrl(file.Hash, 'tile_500')}
alt={file.Name}
loading="lazy"
class="h-full w-full object-cover"
/>
{#if isBest}
<span
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
Best
</span>
{/if}
{#if dims(file)}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
>
{dims(file)}
</span>
{/if}
</div>
<div
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
>
<div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
{#if sizeStr}
<div>{sizeStr}</div>
{/if}
</div>
</button>
{/each}
</div>
</div>

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>

View File

@@ -0,0 +1,99 @@
<!--
Ported pixel-art header from the legacy mule-image React TopBar.
- Tiled `desert.png` scrolling right→left under a dusk gradient.
- 3×2 sprite-sheet of the mule cycling at 6 frames / 0.6s for a walk.
- ASCII "Mulimago" wordmark on a black plate so the mule has company.
The PNGs live in /static/mule/ so SvelteKit's static handler serves them
at /mule/*; the import-via-Vite trick from the React version isn't
necessary here.
-->
<script lang="ts">
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
▛▖▞▌▌▌▐ ▌▛▛▌▀▌▛▌█▌
▌▝ ▌▙▌▐▖▌▌▌▌█▌▙▌▙▖`;
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
</script>
<header class="mule-header relative flex h-16 items-center justify-between overflow-hidden border-b border-border px-4">
<div class="relative flex items-center gap-3">
<div class="mule-sprite h-12 w-14" aria-label="Mulimago" role="img"></div>
<pre
aria-label="Mulimago"
class="rounded bg-black px-2 py-1 font-mono text-[8px] leading-[1.05] text-white"
style="letter-spacing: 0;"
>{MULIMAGO_ASCII}</pre>
</div>
<div class="relative flex items-center gap-3">
{@render children?.()}
</div>
</header>
<style>
/*
* Two layers in the background: tiled desert.png on top scrolling
* right→left, dusk-sky gradient underneath. The 200px tile width is
* fixed so the `desert-scroll` keyframe moves by exactly one tile and
* loops seamlessly.
*/
.mule-header {
background-image:
url('/mule/desert.png'),
linear-gradient(to bottom, #2b3a5c 0%, #6b6b8a 35%, #d68a5c 75%, #f0c188 100%);
background-repeat: repeat-x, no-repeat;
background-size:
200px 100%,
100% 100%;
background-position: 0 bottom, 0 0;
image-rendering: pixelated;
animation: desert-scroll 24s linear infinite;
}
/* 3×2 sprite-sheet, 6-frame walk cycle. `steps(1)` makes each keyframe
* snap (no interpolation between frames). */
.mule-sprite {
background-image: url('/mule/mule-sprites.png');
background-size: 300% 200%;
background-repeat: no-repeat;
image-rendering: pixelated;
animation: mule-walk 0.6s steps(1) infinite;
}
@keyframes mule-walk {
0% {
background-position: 0% 0%;
}
16.66% {
background-position: 50% 0%;
}
33.33% {
background-position: 100% 0%;
}
50% {
background-position: 0% 100%;
}
66.66% {
background-position: 50% 100%;
}
83.33% {
background-position: 100% 100%;
}
100% {
background-position: 0% 0%;
}
}
@keyframes desert-scroll {
from {
background-position-x: 0px, 0px;
}
to {
background-position-x: -200px, 0px;
}
}
</style>

View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { tick } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism';
import {
closePreview,
preview,
previewNext,
previewPrev
} from '$lib/stores/preview.svelte';
import { thumbUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
const photoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', preview.uid ?? ''],
queryFn: () => getPhoto(preview.uid as string),
enabled: Boolean(preview.uid)
}));
// Track the last visible uid so we can return focus to the matching
// timeline tile when the overlay closes — lets the user keep moving
// with arrow keys without re-clicking.
let lastShown: string | null = null;
$effect(() => {
if (preview.uid !== null) {
lastShown = preview.uid;
setFocused(preview.uid);
} else if (lastShown) {
const target = lastShown;
lastShown = null;
// Wait for the overlay to unmount before grabbing focus, otherwise
// the browser swallows it as the modal element is removed.
void tick().then(() => {
const tile = document.querySelector<HTMLElement>(`[data-uid="${target}"]`);
tile?.focus({ preventScroll: false });
tile?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
}
});
// Keyboard handling lives at the document level so it works regardless
// of focus location. Form fields inside the sidebar still keep their
// own arrow-key behaviour because we ignore events whose target is an
// input/textarea.
$effect(() => {
function onKey(e: KeyboardEvent) {
if (preview.uid === null) return;
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
const inField = tag === 'input' || tag === 'textarea' || tag === 'select';
switch (e.key) {
case 'Escape':
e.preventDefault();
closePreview();
break;
case 'ArrowLeft':
if (inField) return;
e.preventDefault();
previewPrev();
break;
case 'ArrowRight':
if (inField) return;
e.preventDefault();
previewNext();
break;
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
// Prevent body scroll while the overlay is up.
$effect(() => {
if (typeof document === 'undefined') return;
const prev = document.body.style.overflow;
if (preview.uid !== null) document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
});
function onBackdrop(e: MouseEvent) {
// Clicking the dimmed area (but not the image or sidebar) closes.
if (e.target === e.currentTarget) closePreview();
}
const currentIndex = $derived(
preview.uid ? preview.order.indexOf(preview.uid) : -1
);
</script>
{#if preview.uid !== null}
<div
class="fixed inset-0 z-50 flex bg-black/80 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Photo preview"
onclick={onBackdrop}
onkeydown={(e) => {
if (e.key === 'Escape') closePreview();
}}
tabindex="-1"
>
<!-- Left: image area -->
<div
class="relative flex flex-1 items-center justify-center p-6"
onclick={onBackdrop}
role="presentation"
>
<button
class="absolute left-4 top-4 z-10 rounded-md bg-background/80 px-2.5 py-1.5 text-xs hover:bg-background"
onclick={closePreview}
aria-label="Close preview"
>
✕ Close
</button>
{#if currentIndex > 0}
<button
class="absolute left-2 z-10 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
onclick={previewPrev}
aria-label="Previous photo"
>
</button>
{/if}
{#if currentIndex >= 0 && currentIndex < preview.order.length - 1}
<button
class="absolute right-2 z-10 rounded-full bg-background/80 px-3 py-2 text-lg hover:bg-background"
onclick={previewNext}
aria-label="Next photo"
>
</button>
{/if}
{#if photoQuery.isPending}
<p class="text-sm text-white/80">Loading…</p>
{:else if photoQuery.isError}
<p class="text-sm text-red-300">Failed to load photo.</p>
{:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)}
<img
src={thumbUrl(pf.Hash, 'fit_1920')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
/>
{/if}
</div>
<!-- Right: metadata sidebar -->
<aside
class="w-[360px] shrink-0 overflow-y-auto border-l border-border bg-background p-4"
>
{#if photoQuery.data}
<RightSidebar photo={photoQuery.data} />
{:else}
<p class="text-sm text-muted-foreground">Loading metadata…</p>
{/if}
</aside>
</div>
{/if}

View File

@@ -0,0 +1,341 @@
<!--
Multi-select metadata panel. Mirrors mule-image's RightSidebar bulk mode:
apply the same Note / Date / Keyword to every selected photo.
Apply-button-driven (not blur-on-edit) so the user controls when the
mutation fans out — accidental focus loss won't rewrite N photos.
-->
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { Calendar, Star, Tag } from 'lucide-svelte';
import {
buildTakenAtPatch,
bulkSetMarks,
type PhotoMark,
type PhotoMarksMap,
type UpdatePhotoBody
} from '$lib/services/photoprism';
import { patchTargets } from '$lib/services/bulk';
const qc = useQueryClient();
interface Props {
ids: string[];
}
let { ids }: Props = $props();
let noteDraft = $state('');
let dateDraft = $state('');
let keywordDraft = $state('');
// `null` = nothing picked yet; `0` / `''` = explicit clear.
let ratingDraft = $state<number | null>(null);
let colorDraft = $state<string | null>(null);
let busy = $state(false);
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
busy = true;
try {
return await fn();
} finally {
busy = false;
}
}
async function applyNote() {
if (busy) return;
const value = noteDraft;
await withBusy(() =>
patchTargets(
ids,
{ Caption: value, CaptionSrc: 'manual' },
value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`,
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
)
);
noteDraft = '';
}
async function applyDate() {
if (busy || !dateDraft) return;
// datetime-local omits the timezone; treat the input as UTC (same
// convention as the single-photo sidebar) and let PhotoPrism's
// backwrite stamp the local timezone field downstream.
const iso = `${dateDraft}:00Z`;
await withBusy(() =>
patchTargets(
ids,
buildTakenAtPatch(iso),
`Date → ${ids.length}`,
(p) =>
p.TakenAt
? buildTakenAtPatch(p.TakenAt)
: ({ TakenSrc: '' } as UpdatePhotoBody)
)
);
dateDraft = '';
}
async function applyMarks(patch: PhotoMark, label: string) {
if (busy) return;
await withBusy(async () => {
// Optimistic: patch every selected photo's mark in the local
// cache before round-tripping. Sidecar bulk endpoint is
// authoritative; on failure we just invalidate so the next
// list query overrides.
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
const map = { ...(prev ?? {}) };
for (const id of ids) {
const merged: PhotoMark = { ...(map[id] ?? {}), ...patch };
if (!merged.rating) delete merged.rating;
if (!merged.color) delete merged.color;
if (merged.rating == null && !merged.color) delete map[id];
else map[id] = merged;
}
return map;
});
try {
await bulkSetMarks(ids, patch);
toast.success(`${label} · ${ids.length}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Save failed');
void qc.invalidateQueries({ queryKey: ['marks'] });
}
});
}
async function applyRating() {
if (ratingDraft === null) return;
const value = ratingDraft;
await applyMarks({ rating: value }, value === 0 ? 'Cleared score' : `★ ${value}`);
ratingDraft = null;
}
async function applyColor() {
if (colorDraft === null) return;
const value = colorDraft;
await applyMarks({ color: value }, value ? `Color ${value}` : 'Cleared color');
colorDraft = null;
}
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
];
async function applyKeyword() {
if (busy) return;
const kw = keywordDraft.trim().replace(/,/g, '');
if (!kw) return;
keywordDraft = '';
await withBusy(() =>
patchTargets(
ids,
(p) => {
const cur = (p.Details?.Keywords ?? '')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
if (cur.includes(kw)) return {};
const next = [...cur, kw].join(', ');
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
},
`Tagged "${kw}" → ${ids.length}`,
(p) => ({
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
})
)
);
}
function onKeywordKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
void applyKeyword();
}
}
</script>
<aside class="space-y-4 p-3 text-xs">
<header class="border-b border-border pb-2">
<div class="text-sm font-medium text-foreground">{ids.length} selected</div>
<p class="mt-0.5 text-[10px] text-muted-foreground">
Edits apply to every selected photo.
</p>
</header>
<!-- Note (Caption) -->
<section class="space-y-1">
<div
class="flex items-center justify-between text-[10px] uppercase tracking-wide text-muted-foreground"
>
<span>Note</span>
<span class="font-normal normal-case text-muted-foreground/70">
Overwrites each photo
</span>
</div>
<textarea
rows="3"
placeholder="Add a note for all selected…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={noteDraft}
disabled={busy}
></textarea>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={applyNote}
>
Apply note to {ids.length}
</button>
</section>
<!-- Date (TakenAt) -->
<section class="space-y-1">
<div
class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<Calendar class="h-3 w-3" /> Date taken
</div>
<input
type="datetime-local"
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={dateDraft}
disabled={busy}
/>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || !dateDraft}
onclick={applyDate}
>
Apply date to {ids.length}
</button>
</section>
<!-- Score (rating) — pick a value with the stars, then Apply. The "Clear"
button picks `0` so the Apply step explicitly wipes the score across
the selection. Stored on the mule-sidecar; PhotoPrism's PUT can't
persist these. -->
<section class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Score">
{#each [1, 2, 3, 4, 5] as n (n)}
<button
type="button"
class="p-0.5 transition-colors disabled:opacity-50"
class:text-yellow-400={ratingDraft !== null && ratingDraft >= n}
class:text-muted-foreground={!(ratingDraft !== null && ratingDraft >= n)}
disabled={busy}
onclick={() => (ratingDraft = n)}
title={`Pick ★ ${n}`}
aria-label={`Score ${n}`}
>
<Star
class="h-4 w-4"
fill={ratingDraft !== null && ratingDraft >= n ? 'currentColor' : 'none'}
/>
</button>
{/each}
<button
type="button"
class="ml-1 rounded px-1 text-[10px] text-muted-foreground hover:bg-accent disabled:opacity-50"
class:bg-accent={ratingDraft === 0}
class:text-foreground={ratingDraft === 0}
disabled={busy}
onclick={() => (ratingDraft = 0)}
title="Pick: clear score"
>
</button>
</div>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || ratingDraft === null}
onclick={applyRating}
>
{#if ratingDraft === null}
Pick a score
{:else if ratingDraft === 0}
Clear score on {ids.length}
{:else}
Apply ★ {ratingDraft} to {ids.length}
{/if}
</button>
</section>
<!-- Color label — same pattern as Score. -->
<section class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label">
{#each COLOR_SWATCHES as c (c.key)}
<button
type="button"
class="h-4 w-4 rounded-full ring-2 transition-all disabled:opacity-50 {c.bg}"
class:ring-foreground={colorDraft === c.key}
class:ring-transparent={colorDraft !== c.key}
disabled={busy}
onclick={() => (colorDraft = c.key)}
title={`Pick ${c.title}`}
aria-label={`Color ${c.key}`}
></button>
{/each}
<button
type="button"
class="ml-0.5 rounded px-1 text-[10px] text-muted-foreground hover:bg-accent disabled:opacity-50"
class:bg-accent={colorDraft === ''}
class:text-foreground={colorDraft === ''}
disabled={busy}
onclick={() => (colorDraft = '')}
title="Pick: clear color"
>
</button>
</div>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || colorDraft === null}
onclick={applyColor}
>
{#if colorDraft === null}
Pick a color
{:else if colorDraft === ''}
Clear color on {ids.length}
{:else}
Apply {colorDraft} to {ids.length}
{/if}
</button>
</section>
<!-- Keywords (additive — merge into each photo's existing list) -->
<section class="space-y-1">
<div
class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<Tag class="h-3 w-3" /> Add keyword
</div>
<input
type="text"
placeholder="tag name + Enter"
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={keywordDraft}
disabled={busy}
onkeydown={onKeywordKeydown}
/>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || !keywordDraft.trim()}
onclick={applyKeyword}
>
Add to {ids.length}
</button>
<p class="text-[10px] text-muted-foreground/80">
Adds to existing keywords; doesn't replace them.
</p>
</section>
{#if busy}
<div class="text-[10px] text-muted-foreground">Applying…</div>
{/if}
</aside>

View File

@@ -0,0 +1,614 @@
<!--
Metadata sidebar — compact, icon-led layout drawing from Apple Photos
(slim row stack, mini-map link), Lightroom (collapsible IPTC + EXIF
sections), and Immich (icon + value pairs). Editable fields are inline:
click → type → blur to save. Mutations deep-merge through PhotoPrism's
PUT (Details fields need the full body).
-->
<script lang="ts">
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
Aperture,
Calendar,
Camera,
ExternalLink,
Heart,
ImageIcon,
Lock,
MapPin,
Star,
Tag,
Timer,
X
} from 'lucide-svelte';
import {
buildTakenAtPatch,
getAllMarks,
likePhoto,
renameOnDisk,
setMark,
unlikePhoto,
updatePhoto,
type PhotoMark,
type PhotoMarksMap,
type UpdatePhotoBody
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { thumbUrl } from '$lib/stores/session.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
interface Props {
photo: PpPhoto;
}
let { photo }: Props = $props();
const qc = useQueryClient();
let filename = $state('');
let caption = $state('');
let takenAt = $state('');
let lat = $state('');
let lng = $state('');
let country = $state('');
let keywords = $state<string[]>([]);
let keywordDraft = $state('');
let subject = $state('');
let artist = $state('');
let copyright = $state('');
let license = $state('');
let notes = $state('');
let renaming = $state(false);
$effect(() => {
const pf = primaryFile(photo);
filename = pf.Name ?? '';
caption = photo.Caption ?? '';
takenAt = (photo.TakenAt ?? '').slice(0, 16);
lat = photo.Lat ? String(photo.Lat) : '';
lng = photo.Lng ? String(photo.Lng) : '';
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
const det = photo.Details ?? {};
keywords = (det.Keywords ?? '')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
subject = det.Subject ?? '';
artist = det.Artist ?? '';
copyright = det.Copyright ?? '';
license = det.License ?? '';
notes = det.Notes ?? '';
});
const patchMutation = createMutation(() => ({
mutationFn: (patch: UpdatePhotoBody) => {
const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
return updatePhoto(fresh, patch);
},
onSuccess: (data) => {
qc.setQueryData(['photo', data.UID], data);
void qc.invalidateQueries({ queryKey: ['photos'] });
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Save failed')
}));
const favoriteMutation = createMutation(() => ({
mutationFn: async (next: boolean) => {
if (next) await likePhoto(photo.UID);
else await unlikePhoto(photo.UID);
return next;
},
onSuccess: (next) => {
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
void qc.invalidateQueries({ queryKey: ['photos'] });
pushUndo(next ? 'Favorited' : 'Unfavorited', async () => {
if (next) await unlikePhoto(photo.UID);
else await likePhoto(photo.UID);
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
void qc.invalidateQueries({ queryKey: ['photos'] });
});
}
}));
function commit(patch: UpdatePhotoBody) {
patchMutation.mutate(patch);
}
async function commitFilename() {
const pf = primaryFile(photo);
const next = filename.trim();
if (!next || next === pf.Name) return;
renaming = true;
try {
const result = await renameOnDisk(photo.UID, next);
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
void qc.invalidateQueries({ queryKey: ['photos'] });
toast.success(`Renamed → ${result.newName}`);
pushUndo(`Renamed to ${result.newName}`, async () => {
await renameOnDisk(photo.UID, result.oldName);
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
void qc.invalidateQueries({ queryKey: ['photos'] });
});
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Rename failed');
filename = pf.Name ?? '';
} finally {
renaming = false;
}
}
function commitCaption() {
if (caption === (photo.Caption ?? '')) return;
commit({ Caption: caption, CaptionSrc: 'manual' });
}
function commitTakenAt() {
if (!takenAt) return;
const iso = `${takenAt}:00Z`;
if (iso === photo.TakenAt) return;
commit(buildTakenAtPatch(iso));
}
function commitGps() {
const nlat = parseFloat(lat);
const nlng = parseFloat(lng);
const patch: UpdatePhotoBody = {};
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
if (Object.keys(patch).length) commit(patch);
}
function commitCountry() {
const next = country.toLowerCase().slice(0, 2);
const prev = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
if (next === prev) return;
commit({ Country: next || 'zz', CountrySrc: 'manual' });
}
type DetailsKey = 'Keywords' | 'Subject' | 'Artist' | 'Copyright' | 'License' | 'Notes';
function commitDetails(field: DetailsKey, value: string) {
const prev = (photo.Details ?? {})[field] ?? '';
if (value === prev) return;
commit({ Details: { [field]: value, [`${field}Src`]: 'manual' } });
}
function addKeyword() {
const next = keywordDraft.trim().replace(/,/g, '');
keywordDraft = '';
if (!next || keywords.includes(next)) return;
keywords = [...keywords, next];
commitDetails('Keywords', keywords.join(', '));
}
function removeKeyword(k: string) {
keywords = keywords.filter((x) => x !== k);
commitDetails('Keywords', keywords.join(', '));
}
function togglePrivate() {
const prev = photo.Private ?? false;
commit({ Private: !prev });
pushUndo(prev ? 'Made public' : 'Made private', () => {
commit({ Private: prev });
});
}
// Marks (rating + color) live on the mule-sidecar — PhotoPrism's PUT
// silently drops these fields. One query holds the whole map; mutations
// patch the cache optimistically and PUT to the sidecar.
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
function patchMarksCache(uid: string, next: PhotoMark | null) {
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
const map = { ...(prev ?? {}) };
if (!next || (next.rating == null && !next.color)) delete map[uid];
else map[uid] = next;
return map;
});
}
async function applyMark(patch: PhotoMark) {
const prevMap = qc.getQueryData<PhotoMarksMap>(['marks']) ?? {};
const prev = prevMap[photo.UID] ?? {};
const optimistic: PhotoMark = { ...prev, ...patch };
// Strip zero/empty so the cache matches what the sidecar persists.
if (!optimistic.rating) delete optimistic.rating;
if (!optimistic.color) delete optimistic.color;
patchMarksCache(photo.UID, optimistic);
try {
const saved = await setMark(photo.UID, patch);
patchMarksCache(photo.UID, saved);
} catch (err) {
// Rollback on failure.
patchMarksCache(photo.UID, prev);
toast.error(err instanceof Error ? err.message : 'Save failed');
}
}
/** Click-to-toggle: clicking the same star clears, clicking a higher star
* sets to that value. Mirrors mule-image's single-photo rating row. */
function setRating(next: number) {
const value = currentRating === next ? 0 : next;
if (value === currentRating) return;
void applyMark({ rating: value });
}
/** Click-to-toggle: clicking the current color clears it; clicking a
* different swatch swaps. Same four-swatch palette as mule-image. */
function setColor(next: string) {
const value = currentColor === next ? '' : next;
if (value === currentColor) return;
void applyMark({ color: value });
}
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
];
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
const currentRating = $derived(photoMark.rating ?? 0);
const currentColor = $derived(photoMark.color ?? '');
const pf = $derived(primaryFile(photo));
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
const sizeStr = $derived(
pf.Size
? pf.Size > 1_000_000
? `${(pf.Size / 1_000_000).toFixed(1)} MB`
: `${(pf.Size / 1024).toFixed(0)} KB`
: '—'
);
const cameraStr = $derived(formatCameraLens(photo.Camera));
const lensStr = $derived(formatCameraLens(photo.Lens));
const exposureParts = $derived(formatExposureParts(photo));
const placeLabel = $derived(
photo.Place?.PlaceLabel && photo.Place.PlaceLabel !== 'Unknown'
? photo.Place.PlaceLabel
: photo.Country && photo.Country !== 'zz'
? photo.Country.toUpperCase()
: ''
);
const mapsHref = $derived(
photo.Lat && photo.Lng
? `https://www.openstreetmap.org/?mlat=${photo.Lat}&mlon=${photo.Lng}&zoom=15`
: ''
);
function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
if (!c) return '';
const make = c.Make ?? '';
const model = c.Model ?? c.Name ?? '';
const joined = `${make} ${model}`.trim();
return joined && joined !== 'Unknown' ? joined : '';
}
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
return {
iso: p.Iso ? `ISO ${p.Iso}` : '',
fnum: p.FNumber ? `f/${p.FNumber}` : '',
focal: p.FocalLength ? `${p.FocalLength}mm` : '',
exp: p.Exposure ?? ''
};
}
</script>
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
<!-- Header strip — thumb + filename + favorite + private -->
<div class="flex items-center gap-2">
<img
src={thumbUrl(pf.Hash, 'tile_100')}
alt=""
class="h-10 w-10 shrink-0 rounded object-cover"
/>
<input
type="text"
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
bind:value={filename}
disabled={renaming}
onblur={commitFilename}
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
/>
<button
class="rounded p-1 hover:bg-accent disabled:opacity-50"
class:text-red-500={photo.Favorite}
class:text-muted-foreground={!photo.Favorite}
disabled={favoriteMutation.isPending}
onclick={() => favoriteMutation.mutate(!photo.Favorite)}
title={photo.Favorite ? 'Remove favorite (F)' : 'Add favorite (F)'}
>
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
</button>
<button
class="rounded p-1 hover:bg-accent disabled:opacity-50"
class:text-foreground={photo.Private}
class:text-muted-foreground={!photo.Private}
disabled={patchMutation.isPending}
onclick={togglePrivate}
title={photo.Private ? 'Private' : 'Public'}
>
<Lock class="h-3.5 w-3.5" />
</button>
</div>
<!-- Compact info rows -->
<dl class="space-y-1">
<!-- Taken at -->
<div class="flex items-center gap-2">
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
type="datetime-local"
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={takenAt}
onblur={commitTakenAt}
/>
</div>
<!-- Location -->
<div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-muted-foreground">
{placeLabel || 'No location'}
</span>
{#if mapsHref}
<a
href={mapsHref}
target="_blank"
rel="noopener"
class="text-muted-foreground hover:text-foreground"
title="Open in OpenStreetMap"
>
<ExternalLink class="h-3 w-3" />
</a>
{/if}
</div>
<!-- Camera / lens — only render if something to show -->
{#if cameraStr || lensStr || exposureParts.iso || exposureParts.fnum}
<div class="flex items-start gap-2">
<Camera class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1 space-y-0.5 text-muted-foreground">
{#if cameraStr}<div class="truncate">{cameraStr}</div>{/if}
{#if lensStr && lensStr !== cameraStr}<div class="truncate">{lensStr}</div>{/if}
{#if exposureParts.iso || exposureParts.fnum || exposureParts.focal || exposureParts.exp}
<div class="flex flex-wrap gap-x-2 text-[10px]">
{#if exposureParts.fnum}
<span class="flex items-center gap-0.5">
<Aperture class="h-2.5 w-2.5" /> {exposureParts.fnum}
</span>
{/if}
{#if exposureParts.exp}
<span class="flex items-center gap-0.5">
<Timer class="h-2.5 w-2.5" /> {exposureParts.exp}
</span>
{/if}
{#if exposureParts.iso}<span>{exposureParts.iso}</span>{/if}
{#if exposureParts.focal}<span>{exposureParts.focal}</span>{/if}
</div>
{/if}
</div>
</div>
{/if}
</dl>
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
mule-image's nomenclature). -->
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
rows="2"
placeholder="Add a note…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={caption}
onblur={commitCaption}
></textarea>
</div>
<!-- Score + color label — two separate sections. Click a star/swatch to
set, click the active one to clear. Sits next to Keywords because
these are the per-photo culling marks the user reaches for in the
same workflow. Stored on the mule-sidecar; PhotoPrism's PUT can't
persist them. -->
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
{#each [1, 2, 3, 4, 5] as n (n)}
<button
type="button"
class="p-0.5 transition-colors disabled:opacity-50"
class:text-yellow-400={currentRating >= n}
class:text-muted-foreground={currentRating < n}
onclick={() => setRating(n)}
title={`Rate ${n}`}
aria-label={`Rate ${n}`}
>
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
</button>
{/each}
</div>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label">
{#each COLOR_SWATCHES as c (c.key)}
<button
type="button"
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}"
class:ring-foreground={currentColor === c.key}
class:ring-transparent={currentColor !== c.key}
onclick={() => setColor(c.key)}
title={c.title}
aria-label={`Color ${c.key}`}
></button>
{/each}
</div>
</div>
<!-- Keywords as chips -->
<div class="space-y-1">
<div class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground">
<Tag class="h-3 w-3" /> Keywords
</div>
<div class="flex flex-wrap gap-1">
{#each keywords as kw (kw)}
<span
class="inline-flex items-center gap-0.5 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px]"
>
{kw}
<button
class="text-muted-foreground hover:text-destructive"
onclick={() => removeKeyword(kw)}
aria-label={`Remove ${kw}`}
>
<X class="h-2.5 w-2.5" />
</button>
</span>
{/each}
<input
type="text"
placeholder="+ tag"
class="w-16 rounded border border-input bg-background px-1.5 py-0.5 text-[10px] shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={keywordDraft}
onblur={addKeyword}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addKeyword();
}
}}
/>
</div>
</div>
<!-- GPS detail (collapsed by default) -->
<details class="rounded border border-border" open={Boolean(photo.Lat || photo.Lng)}>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
GPS
</summary>
<div class="grid grid-cols-3 gap-1 p-2 pt-1">
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Lat</span>
<input
type="number"
step="0.0001"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={lat}
onblur={commitGps}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Lng</span>
<input
type="number"
step="0.0001"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={lng}
onblur={commitGps}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Country</span>
<input
type="text"
maxlength="2"
placeholder="us"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={country}
onblur={commitCountry}
/>
</label>
</div>
</details>
<!-- IPTC credits (collapsed unless something set) -->
<details
class="rounded border border-border"
open={Boolean(subject || artist || copyright || license || notes)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Credits & notes
</summary>
<div class="space-y-1 p-2 pt-1">
<label class="flex items-center gap-1">
<span class="w-16 text-[10px] text-muted-foreground">Subject</span>
<input
type="text"
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={subject}
onblur={() => commitDetails('Subject', subject)}
/>
</label>
<label class="flex items-center gap-1">
<span class="w-16 text-[10px] text-muted-foreground">Artist</span>
<input
type="text"
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={artist}
onblur={() => commitDetails('Artist', artist)}
/>
</label>
<label class="flex items-center gap-1">
<span class="w-16 text-[10px] text-muted-foreground">Copyright</span>
<input
type="text"
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={copyright}
onblur={() => commitDetails('Copyright', copyright)}
/>
</label>
<label class="flex items-center gap-1">
<span class="w-16 text-[10px] text-muted-foreground">License</span>
<input
type="text"
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={license}
onblur={() => commitDetails('License', license)}
/>
</label>
<label class="flex items-start gap-1">
<span class="w-16 pt-0.5 text-[10px] text-muted-foreground">Private notes</span>
<textarea
rows="2"
class="min-w-0 flex-1 resize-y rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={notes}
onblur={() => commitDetails('Notes', notes)}
></textarea>
</label>
</div>
</details>
<!-- File (collapsed by default) -->
<details class="rounded border border-border">
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<span class="inline-flex items-center gap-1">
<ImageIcon class="h-3 w-3" /> File
</span>
</summary>
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
<dt class="text-muted-foreground">Size</dt>
<dd class="text-foreground/80">{dims} · {sizeStr}</dd>
<dt class="text-muted-foreground">Type</dt>
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
<dt class="text-muted-foreground">Hash</dt>
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}</dd>
<dt class="text-muted-foreground">Indexed</dt>
<dd class="text-foreground/80">{(photo.IndexedAt ?? '').slice(0, 10) || '—'}</dd>
</dl>
</details>
{#if patchMutation.isPending || renaming}
<div class="text-[10px] text-muted-foreground">Saving…</div>
{/if}
</aside>

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}