Replaces ad-hoc "Loading…" text and bare empty messages with two
shared feedback primitives that carry subtle lucide icons, consistent
muted-foreground/destructive tones, and a11y signaling (role=status,
aria-busy, role=alert on destructive empties). Loading copy gains
context ("Loading photos/folders/heaps/metadata…") and the right-
sidebar idle state moves from a "ⓘ" glyph to a MousePointerClick
icon. SkeletonGrid stays as the initial-grid loader.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
8.4 KiB
Svelte
250 lines
8.4 KiB
Svelte
<!--
|
|
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, FolderOpen, Loader2 } from 'lucide-svelte';
|
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
|
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, toOriginalsPath } 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() {
|
|
// pickedPath === '' is the root selection; falsy check would
|
|
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
|
|
if (!heap || pickedPath === null) return;
|
|
// pickedPath is user-relative (listFolders strips BasePath). The
|
|
// sidecar moves files on disk so it needs a server-absolute path —
|
|
// translate before submitting.
|
|
convertMut.mutate({
|
|
uid: heap.UID,
|
|
body: {
|
|
targetFolder: toOriginalsPath(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}
|
|
<InlineLoader size="sm" label="Loading folders…" />
|
|
{:else if (foldersQuery.data ?? []).length === 0}
|
|
<EmptyState
|
|
size="compact"
|
|
icon={FolderOpen}
|
|
title="No folders"
|
|
description="Create one from the sidebar first."
|
|
/>
|
|
{:else}
|
|
<!-- Root row: lets the user drop the heap directly into
|
|
originals/ without picking a subfolder. The empty
|
|
string is the sidecar's "root" sentinel — matches
|
|
resolveUnderRoot's special case in handlers_heap. -->
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
|
class:bg-primary={pickedPath === ''}
|
|
class:text-primary-foreground={pickedPath === ''}
|
|
class:hover:bg-primary={pickedPath === ''}
|
|
onclick={() => (pickedPath = '')}
|
|
>
|
|
/
|
|
</button>
|
|
<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 === null || 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>
|