Issue 1 — colors/labels not applying in bulk: - sidecar validColors only accepted 4 of the 8 UI swatches, so teal/blue/ purple/pink returned "invalid color" and rolled back the whole bulk txn. Add teal, blue, purple, pink to validColors. - Add invalidateFacets() and call it on the success path of bulk marks, patchTargets, and single-photo edits so the Colors/Ratings/Notes facet sections refresh immediately instead of waiting out staleTime. Issue 2 — archived photos linger in the grid: - Add a UI-only removedIds set to the bulkAction store; archive/delete/ restore/keep call markRemoved() on success so tiles vanish instantly, cleared once the server-reconcile refetch lands (no cache eviction). Issue 3 — per-photo progress state: - Wire startBulk/doneBulk/failBulk into all metadata applies, bulk (BulkMetadataSidebar) and single (RightSidebar), so colors/ratings/ notes/dates/keywords show the spinner -> check -> X overlay. Issue 4 — remove Left-sidebar count badges: - Drop count badges from root folder, Archive, heaps, Notes, and the folder tree, plus the now-dead count queries and unused imports. Facet drill-panel counts are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
461 lines
16 KiB
Svelte
461 lines
16 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||
import { toast } from 'svelte-sonner';
|
||
import {
|
||
addToHeap,
|
||
approvePhoto,
|
||
batchArchive,
|
||
batchDelete,
|
||
batchRestore,
|
||
listHeaps,
|
||
removeFromHeap,
|
||
type PpAlbum
|
||
} from '$lib/services/photoprism';
|
||
import { batchEdit } from '$lib/services/batch';
|
||
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||
import { photoNameAndDir } from '$lib/types/photoprism';
|
||
import {
|
||
clearBulkToFirst,
|
||
clearSelection,
|
||
focusAfter,
|
||
selection,
|
||
setFocused
|
||
} from '$lib/stores/selection.svelte';
|
||
import { filters } from '$lib/stores/filters.svelte';
|
||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||
import {
|
||
startBulk,
|
||
setDetail,
|
||
doneBulk,
|
||
failBulk,
|
||
markRemoved,
|
||
clearRemoved
|
||
} from '$lib/stores/bulkAction.svelte';
|
||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||
import { Layers } from 'lucide-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);
|
||
// Filename for the single-focus label. Re-derives whenever
|
||
// selection.focused flips — cachedPhoto reads from the same query
|
||
// cache that drives the visible tiles, so the name resolves on the
|
||
// same tick the tile renders.
|
||
const focusedPhoto = $derived(selection.focused ? cachedPhoto(selection.focused) : undefined);
|
||
const focusedName = $derived(
|
||
focusedPhoto ? photoNameAndDir(focusedPhoto).fileName : ''
|
||
);
|
||
// Review section uses a two-button decision flow (Keep / Archive) —
|
||
// every other action is hidden so the choice can't be confused with
|
||
// heap-adding / restoring. The S keybinding is rerouted to approve
|
||
// from gridKeyNav for the same reason.
|
||
const isReview = $derived(filters.section === 'review');
|
||
// "Accept date & Keep" is scoped to the EXIF Stripped review tab —
|
||
// that's where path-derived dates are the most useful fix. Outside the
|
||
// tab the button stays hidden even if a selected photo would otherwise
|
||
// have a path-parseable date, to keep other tabs uncluttered.
|
||
const onExifStrippedTab = $derived(
|
||
isReview && page.url.searchParams.get('tab') === 'stripped_exif'
|
||
);
|
||
// Surface the button only when EVERY targeted photo has a derivable
|
||
// suggestion — otherwise clicking it would silently approve some
|
||
// photos without a date fix, which contradicts the verb. A uid not in
|
||
// any cache also counts as "no suggestion" so we don't promise
|
||
// something we can't verify.
|
||
const allHaveSuggestion = $derived.by(() => {
|
||
if (!onExifStrippedTab) return false;
|
||
const ids =
|
||
selection.ids.size > 0
|
||
? Array.from(selection.ids)
|
||
: selection.focused
|
||
? [selection.focused]
|
||
: [];
|
||
if (ids.length === 0) return false;
|
||
for (const id of ids) {
|
||
const p = cachedPhoto(id);
|
||
if (!p) return false;
|
||
const { fileName, path } = photoNameAndDir(p);
|
||
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
// Archive section is the parallel two-button flow: Keep (restore back
|
||
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
||
// from "archive" to "delete" since the photo is already archived;
|
||
// gridKeyNav mirrors the rerouting.
|
||
const isArchive = $derived(filters.section === 'archive');
|
||
|
||
function clearAll() {
|
||
// Mirror gridKeyNav's Esc: a multi-selection collapses back to its
|
||
// first member (the user keeps a single-focus reference) before
|
||
// the next Clear/Esc fully dismisses focus.
|
||
if (clearBulkToFirst()) return;
|
||
clearSelection();
|
||
setFocused(null);
|
||
}
|
||
|
||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||
|
||
interface BulkConfig {
|
||
ids: string[];
|
||
label: string;
|
||
doneLabel: string;
|
||
}
|
||
|
||
async function withBusy<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
|
||
busy = true;
|
||
if (bulk) startBulk(`${bulk.label}…`, bulk.ids);
|
||
try {
|
||
const result = await fn();
|
||
if (bulk) {
|
||
doneBulk(bulk.doneLabel, bulk.ids);
|
||
await delay(1000);
|
||
}
|
||
return result;
|
||
} catch (e) {
|
||
if (bulk) failBulk(bulk.ids);
|
||
throw e;
|
||
} finally {
|
||
busy = false;
|
||
const settled = Promise.all([
|
||
qc.invalidateQueries({ queryKey: ['photos'] }),
|
||
qc.invalidateQueries({ queryKey: ['marks'] }),
|
||
qc.invalidateQueries({ queryKey: ['review-groups'] })
|
||
]);
|
||
// Clear the optimistic-removal overlay only once the refetch has
|
||
// landed, so tiles never flash back in before the fresh (archived-
|
||
// filtered) page replaces the old one.
|
||
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
|
||
}
|
||
}
|
||
|
||
async function onApprove() {
|
||
const ids = snapshotIds();
|
||
if (ids.length === 0) return;
|
||
const tid = toast.loading(`Keeping ${ids.length}…`);
|
||
await withBusy(async () => {
|
||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
|
||
onProgress: (_done, _total, completedId) => {
|
||
const p = cachedPhoto(completedId);
|
||
setDetail(p?.FileName ?? completedId);
|
||
}
|
||
});
|
||
if (errors.length) {
|
||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid });
|
||
} else {
|
||
toast.success(`Kept ${ids.length}`, { id: tid });
|
||
}
|
||
// Approved photos leave the review section — hide them immediately.
|
||
markRemoved(ids);
|
||
focusAfter(ids);
|
||
clearSelection();
|
||
}, { ids, label: 'Keeping', doneLabel: `Kept ${ids.length}` });
|
||
}
|
||
|
||
async function onAcceptDateAndKeep() {
|
||
const ids = snapshotIds();
|
||
if (ids.length === 0) return;
|
||
await withBusy(() => acceptDateAndKeep(ids), {
|
||
ids,
|
||
label: 'Updating',
|
||
doneLabel: `Updated ${ids.length}`
|
||
});
|
||
}
|
||
|
||
async function onArchive() {
|
||
const ids = snapshotIds();
|
||
if (ids.length === 0) return;
|
||
const tid = toast.loading(`Archiving ${ids.length}…`);
|
||
await withBusy(async () => {
|
||
try {
|
||
await batchArchive(ids);
|
||
markRemoved(ids);
|
||
pushUndo(`Archived ${ids.length}`, async () => {
|
||
await batchRestore(ids);
|
||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||
});
|
||
focusAfter(ids);
|
||
clearSelection();
|
||
toast.success(`Archived ${ids.length}`, { id: tid });
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
|
||
}
|
||
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
|
||
}
|
||
|
||
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;
|
||
const tid = toast.loading(`Deleting ${ids.length}…`);
|
||
await withBusy(async () => {
|
||
try {
|
||
await batchDelete(ids);
|
||
markRemoved(ids);
|
||
focusAfter(ids);
|
||
clearSelection();
|
||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
|
||
}
|
||
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` });
|
||
}
|
||
|
||
async function onRestore() {
|
||
const ids = snapshotIds();
|
||
if (ids.length === 0) return;
|
||
const tid = toast.loading(`Restoring ${ids.length}…`);
|
||
await withBusy(async () => {
|
||
try {
|
||
await batchRestore(ids);
|
||
markRemoved(ids);
|
||
pushUndo(`Restored ${ids.length}`, async () => {
|
||
await batchArchive(ids);
|
||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||
});
|
||
focusAfter(ids);
|
||
clearSelection();
|
||
toast.success(`Restored ${ids.length}`, { id: tid });
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'Restore failed', { id: tid });
|
||
}
|
||
}, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` });
|
||
}
|
||
|
||
async function onAddToHeap(heap: PpAlbum) {
|
||
const ids = snapshotIds();
|
||
if (!ids.length) return;
|
||
heapPickerOpen = false;
|
||
const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`);
|
||
startBulk(`Adding to ${heap.Title}…`, ids);
|
||
await withBusy(async () => {
|
||
try {
|
||
const { added } = await addToHeap(heap.UID, ids);
|
||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||
if (added.length === 0) {
|
||
failBulk(ids);
|
||
toast.error(`Nothing added to ${heap.Title}`, {
|
||
id: tid,
|
||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||
});
|
||
return;
|
||
}
|
||
doneBulk(`Added ${added.length} → ${heap.Title}`, ids);
|
||
await delay(400);
|
||
if (added.length < ids.length) {
|
||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||
id: tid,
|
||
description: 'The rest were already in this heap.'
|
||
});
|
||
} else {
|
||
toast.success(`Added ${added.length} → ${heap.Title}`, { id: tid });
|
||
}
|
||
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||
await removeFromHeap(heap.UID, added);
|
||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||
});
|
||
clearSelection();
|
||
} catch (err) {
|
||
failBulk(ids);
|
||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid });
|
||
}
|
||
});
|
||
}
|
||
</script>
|
||
|
||
{#if targetCount > 0}
|
||
<!--
|
||
Inline row at the bottom of the main content column (NOT fixed) so
|
||
the sidebars stay reachable. Matches the Toolbar's h-9 / px-3 /
|
||
bg-background/80 backdrop-blur visual so it reads as the timeline's
|
||
own footer.
|
||
-->
|
||
<div
|
||
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
|
||
>
|
||
{#if isBulk}
|
||
<span class="shrink-0 text-[11px] font-medium text-foreground">
|
||
{targetCount} selected
|
||
</span>
|
||
{:else}
|
||
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">Focused</span>
|
||
<span
|
||
class="min-w-0 truncate text-[11px] font-medium text-foreground"
|
||
title={focusedName || undefined}
|
||
>
|
||
{focusedName || 'photo'}
|
||
</span>
|
||
{/if}
|
||
|
||
<!--
|
||
`overflow-x-auto` would clip the heap-picker dropdown — CSS
|
||
forces `overflow-y: auto` whenever `overflow-x` is non-visible,
|
||
so the dropdown's `bottom-full` placement is clipped to zero
|
||
pixels above the 36px bar (it renders but is invisible). We
|
||
use `flex-wrap` instead so very narrow viewports get a second
|
||
row rather than a horizontal scroll, and the dropdown stays
|
||
free to escape upward.
|
||
-->
|
||
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
|
||
{#if isReview}
|
||
<!-- Review pile = binary decision. Keep approves (Quality →
|
||
3+, lands in the main timeline); Archive batches into
|
||
the archive section. Everything else (heap, restore)
|
||
is hidden so the choice reads as decisive. -->
|
||
<button
|
||
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||
disabled={busy}
|
||
onclick={onApprove}
|
||
title="Keep — accept into timeline"
|
||
>
|
||
✓ Keep
|
||
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
|
||
</button>
|
||
{#if allHaveSuggestion}
|
||
<!-- Visible only when every selected photo has a path-
|
||
derivable date. Clicking applies each photo's
|
||
suggestion then approves it; mirrored by the bare
|
||
`a` shortcut in gridKeyNav. -->
|
||
<button
|
||
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
|
||
disabled={busy}
|
||
onclick={onAcceptDateAndKeep}
|
||
title="Accept the date suggested from the file/folder path, then keep"
|
||
>
|
||
📅 Accept date & Keep
|
||
<kbd class="rounded bg-amber-200/40 px-1 text-[9px] font-medium text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">A</kbd>
|
||
</button>
|
||
{/if}
|
||
<button
|
||
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||
disabled={busy}
|
||
onclick={onArchive}
|
||
title="Archive"
|
||
>
|
||
Archive
|
||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||
</button>
|
||
{:else if isArchive}
|
||
<!-- Archive section = mirror of review: Keep restores back
|
||
to the timeline; Delete is permanent and can't be
|
||
undone. X is repurposed from archive→delete since the
|
||
photo is already archived; the destructive styling
|
||
reinforces the irreversibility. -->
|
||
<button
|
||
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||
disabled={busy}
|
||
onclick={onRestore}
|
||
title="Keep — restore to timeline"
|
||
>
|
||
✓ Keep
|
||
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
|
||
</button>
|
||
<button
|
||
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||
disabled={busy}
|
||
onclick={onDelete}
|
||
title="Permanently delete (no undo)"
|
||
>
|
||
Delete
|
||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||
</button>
|
||
{:else}
|
||
<div class="relative">
|
||
<button
|
||
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||
disabled={busy}
|
||
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
||
title="Add to heap (S then 1–9 picks a heap)"
|
||
>
|
||
+ Add to heap
|
||
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90"
|
||
>S 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}
|
||
<InlineLoader size="sm" label="Loading heaps…" />
|
||
{:else if (heapsQuery.data ?? []).length === 0}
|
||
<EmptyState size="compact" icon={Layers} title="No heaps yet" />
|
||
{: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 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||
disabled={busy}
|
||
onclick={onArchive}
|
||
title="Archive"
|
||
>
|
||
Archive
|
||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||
</button>
|
||
{/if}
|
||
<button
|
||
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||
onclick={clearAll}
|
||
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||
>
|
||
{isBulk ? 'Clear' : 'Dismiss'}
|
||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">Esc</kbd>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|