fix: bulk label apply, instant archive removal, per-photo state, drop sidebar counts

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>
This commit is contained in:
2026-06-08 00:21:04 +02:00
parent a13e171295
commit ccf2c6b7c7
8 changed files with 161 additions and 251 deletions

View File

@@ -10,13 +10,18 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// validColors is the four-color palette mule-image always shipped. The // validColors is the color palette the web client offers (COLOR_SWATCHES in
// empty string is the explicit "clear color" sentinel. // web/src/lib/utils/tagGroups.ts) — keep the two in sync. The empty string is
// the explicit "clear color" sentinel.
var validColors = map[string]struct{}{ var validColors = map[string]struct{}{
"red": {}, "red": {},
"orange": {}, "orange": {},
"yellow": {}, "yellow": {},
"green": {}, "green": {},
"teal": {},
"blue": {},
"purple": {},
"pink": {},
} }
// markPatch is the request body for all three mutating mark endpoints. // markPatch is the request body for all three mutating mark endpoints.

View File

@@ -7,27 +7,21 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { import {
aggregateKeywords, aggregateKeywords,
countPhotos,
createFolder, createFolder,
createHeap, createHeap,
deleteFolder, deleteFolder,
deleteHeap, deleteHeap,
duplicateHeap, duplicateHeap,
getConfig,
heapDownloadUrl, heapDownloadUrl,
listFolderCounts,
listFolders, listFolders,
listHeaps, listHeaps,
listPhotosWithNotes,
logout, logout,
renameFolder, renameFolder,
renameHeap, renameHeap,
scanCrossFolderDuplicates, scanCrossFolderDuplicates,
triggerDownload, triggerDownload,
type CrossFolderScanResult, type CrossFolderScanResult,
type PhotoWithNote,
type PpAlbum, type PpAlbum,
type PpClientConfig,
type PpFolder type PpFolder
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { import {
@@ -87,84 +81,10 @@
gcTime: 0 gcTime: 0
})); }));
// View counts come from PhotoPrism's `/config` response, which carries a // Stacks + cross-folder duplicate caches are warmed here so the
// precomputed counter for every common bucket (all/archived/labels/ // /duplicates view (and its review tab strip) hits a warm cache. The
// places/…) updated incrementally on every mutation. Cheap to refetch, // sidebar only observes these — cross-folder is an O(disk) scan, so it
// and gives us a stable total — `/photos` only returns per-page row // stays enabled:false and the duplicates page populates it on first visit.
// counts via `X-Count`, never a total.
//
// The key sits under the `['photos', …]` prefix so it inherits the
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
// across mutations (archive, restore, delete, heap add) — the counter
// map refreshes whenever the photo list does. Marks-derived counts
// (ratings/colors) react through the shared `['marks']` cache.
const configQuery = createQuery<PpClientConfig>(() => ({
queryKey: ['photos', 'config'],
queryFn: getConfig,
enabled: isAuthenticated()
}));
// PhotoPrism's /api/v1/config.count returns library-wide aggregates
// to any authenticated session regardless of role — the timeline
// itself IS scoped per-user, but the precomputed counters aren't.
// `isAdminUser` controls the cheap path: an admin without a
// BasePath gets the precomputed totals from /config directly. Every
// other case (non-admin, or admin scoped to a subfolder) goes
// through `countPhotos()` which appends a `path:<base>*` filter so
// the badge matches what the user can actually see.
const isAdminUser = $derived(session.user?.Role === 'admin');
const wantScoped = $derived(!isAdminUser || userBasePath() !== '');
// Builds a DSL clause that mirrors PhotoPrism's ACL scoping. An
// admin with `BasePath === ""` gets a no-op clause and the global
// query; everyone else gets a `path:` clause anchored to their
// BasePath so unrelated folders never contribute to the badge.
// Non-admins with no BasePath have nothing they can see, so we
// short-circuit to a query that returns zero (`uid:none`).
function scoped(filter: string): string {
const bp = userBasePath();
if (isAdminUser && bp === '') return filter;
if (!isAdminUser && bp === '') return 'uid:none';
return `${filter} path:"${bp}*"`.trim();
}
function scopedCountQuery(key: string, filter: string) {
return createQuery<number>(() => ({
queryKey: ['photos', 'scoped-count', key, userBasePath(), isAdminUser],
queryFn: () => countPhotos(scoped(filter)),
enabled: isAuthenticated() && wantScoped,
staleTime: 60_000
}));
}
// One query per badge. Admins with no BasePath skip this
// (enabled:false via `wantScoped`) and the configQuery numbers are
// used directly — same chrome as before that fix, no extra
// round-trip. Review and Hidden have no aggregate badge (pure
// toggles in the sidebar now, like Tags), so they don't appear here.
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
function bucketCount(
key: 'archived',
query: { data: number | undefined; isPending: boolean }
): number | undefined {
if (wantScoped) {
if (query.isPending) return undefined;
return query.data;
}
// Admin + no BasePath: use the precomputed PhotoPrism counters
// (no extra round-trip).
const c = configQuery.data?.count;
if (!c) return undefined;
return c[key];
}
// Duplicates counts for the sidebar badge. Stacks is a cheap
// PhotoPrism query so we always fetch it; cross-folder is an
// O(disk) scan, so the sidebar only *observes* its cache
// (enabled:false) and the duplicates page itself is what populates
// it on first visit. Both share queryKeys with the /duplicates
// view so cache is reused.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({ const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'], queryKey: ['duplicates'],
queryFn: listDuplicateGroups, queryFn: listDuplicateGroups,
@@ -178,95 +98,12 @@
staleTime: 5 * 60_000 staleTime: 5 * 60_000
})); }));
// Notes-view badge. Cheap (one list round-trip, no fan-out) so we
// fetch eagerly — sharing the queryKey with /notes means the page hits
// the warm cache, and the ['photos', …] prefix lets existing mutation
// invalidations keep both in sync.
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
queryKey: ['photos', 'with-notes'],
queryFn: listPhotosWithNotes,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const folderTree = $derived( const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path)) buildTree((foldersQuery.data ?? []).map((f) => f.Path))
); );
// Per-folder photo counts. PhotoPrism's /folders/originals reports // Gates admin-only entry points lower in the sidebar.
// FileCount: 0 for every folder, so the sidecar /folders/counts const isAdminUser = $derived(session.user?.Role === 'admin');
// endpoint resolves them in one round-trip (see listFolderCounts).
// Key the query off the folder-path list so it refetches when folders
// are added/renamed/deleted, and share the ['photos', …] prefix so it
// invalidates alongside the other photo caches whenever a mutation
// lands.
//
// `countsReady` gates the query until just after the sidebar's first
// paint. Even though the sidecar response is small, the per-folder
// fan-out it does to PhotoPrism still takes a few hundred ms cold;
// blocking it on idle means the folder list paints immediately and
// the count badges fade in instead of holding back the whole tree.
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
let countsReady = $state(false);
if (browser) {
const kick = () => (countsReady = true);
// requestIdleCallback isn't in Safari yet; fall back to a short
// timeout so the deferral is still bounded.
const ric = (window as Window & { requestIdleCallback?: (cb: () => void) => number })
.requestIdleCallback;
if (typeof ric === 'function') ric(kick);
else setTimeout(kick, 200);
}
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
queryFn: () => listFolderCounts(folderPaths),
enabled: isAuthenticated() && folderPaths.length > 0 && countsReady,
staleTime: 60_000
}));
const folderCounts = $derived(folderCountsQuery.data ?? {});
// Root entry shows "the user's library" using the same filter the
// timeline applies at folderPath=='/' — empty q, which PhotoPrism
// resolves to the visible listing (no archived / hidden / review).
// Earlier this used /config's `count.all`, but that aggregate
// includes those buckets and didn't match what the user can actually
// click "select all" on; the discrepancy was confusing
// (LeftSidebar said 357, the action bar said ~329).
//
// `scopedRootCountQuery` retains the sidecar fan-out for users with
// a BasePath — `listFolderCounts(['''])` resolves `''` through
// `toOriginalsPath` to the user's BasePath and recurses, so it picks
// up the same subset PhotoPrism would. Empty BasePath admins use the
// PhotoPrism count-via-X-Count path so both surfaces agree.
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'root-count', userBasePath()],
queryFn: () => listFolderCounts(['']),
enabled: isAuthenticated() && userBasePath() !== '',
staleTime: 60_000
}));
const visibleRootCountQuery = createQuery<number>(() => ({
queryKey: ['photos', 'visible-root-count', userBasePath()],
// `merged: true` so the count matches the timeline's photo entries
// (one per logical photo) rather than its file-row total. Without
// it, sidecar/companion files inflate the badge — e.g. a HEIC + JPG
// pair counts twice — and "select all" in the timeline never
// reaches the badge's number.
queryFn: () => countPhotos(scoped(''), { merged: true }),
enabled: isAuthenticated() && userBasePath() === '' && isAdminUser,
staleTime: 60_000
}));
const rootCount = $derived(
userBasePath() === ''
? isAdminUser
? (visibleRootCountQuery.data ?? 0)
: 0
: (scopedRootCountQuery.data?.[''] ?? 0)
);
// Archive nav entry uses this derived value rather than peeking at
// configQuery directly so the scoped path is invisible to the
// manageViews[] declarations.
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
const createMut = createMutation(() => ({ const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title), mutationFn: (title: string) => createHeap(title),
@@ -576,7 +413,7 @@
// tab subitems). This list carries the flat Manage entries that // tab subitems). This list carries the flat Manage entries that
// follow it. // follow it.
const manageViews: ViewItem[] = [ const manageViews: ViewItem[] = [
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge } { kind: 'section', id: 'archive', label: 'Archive', getCount: () => undefined }
]; ];
function isRouteActive(href: string): boolean { function isRouteActive(href: string): boolean {
@@ -697,11 +534,6 @@
peers, so labels share a common left edge across the sidebar. --> peers, so labels share a common left edge across the sidebar. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span> <span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if} {/if}
<!--
Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge is the most visually
prominent element on the row and was previously a dead zone.
-->
<button <button
type="button" type="button"
class="flex min-w-0 flex-1 items-center pl-1 text-left" class="flex min-w-0 flex-1 items-center pl-1 text-left"
@@ -709,15 +541,6 @@
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`} title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
> >
<span class="truncate">{rootLabel}</span> <span class="truncate">{rootLabel}</span>
{#if configQuery.data}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {rootActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{rootCount >= 1000 ? '1000+' : rootCount}
</span>
{/if}
</button> </button>
<!-- Root-row kebab. Only "New subfolder" applies — root itself <!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted can't be renamed or deleted, so those entries are omitted
@@ -755,7 +578,6 @@
onRename={onRenameFolder} onRename={onRenameFolder}
onDelete={onDeleteFolder} onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)} onCreateChild={(parent) => onCreateFolder(parent)}
counts={folderCounts}
/> />
{/if} {/if}
</div> </div>
@@ -802,16 +624,9 @@
class="flex min-w-0 flex-1 items-center pl-6 text-left" class="flex min-w-0 flex-1 items-center pl-6 text-left"
onclick={() => navigateTo('heap', heap.UID)} onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)} ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`} title={heap.Title}
> >
<span class="truncate">{heap.Title}</span> <span class="truncate">{heap.Title}</span>
<span
class="ml-auto flex h-4 min-w-[24px] 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> </button>
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block"> <div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Heap actions"> <KebabMenu label="Heap actions">
@@ -898,11 +713,9 @@
Notes lives alongside the tag categories — same indent and row Notes lives alongside the tag categories — same indent and row
chrome — but routes to /notes rather than /tags/*. Tucked at chrome — but routes to /notes rather than /tags/*. Tucked at
the top of the expandable so it's the first thing the user the top of the expandable so it's the first thing the user
sees when opening Tags. Count badge renders once the shared sees when opening Tags.
['photos', 'with-notes'] query has resolved.
--> -->
{@const notesActive = isNotesActive()} {@const notesActive = isNotesActive()}
{@const notesCount = notesQuery.data?.length}
<a <a
href="/notes" href="/notes"
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent" class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
@@ -912,15 +725,6 @@
style="padding-left: 36px;" style="padding-left: 36px;"
> >
<span class="truncate">Notes</span> <span class="truncate">Notes</span>
{#if notesCount !== undefined}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {notesActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{notesCount}
</span>
{/if}
</a> </a>
{#each TAG_CATEGORIES as cat (cat)} {#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)} {@const active = isTagCategoryActive(cat)}

View File

@@ -17,7 +17,8 @@
type PhotoMarksMap, type PhotoMarksMap,
type UpdatePhotoBody type UpdatePhotoBody
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { patchTargets } from '$lib/services/bulk'; import { patchTargets, invalidateFacets } from '$lib/services/bulk';
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups'; import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
const qc = useQueryClient(); const qc = useQueryClient();
@@ -35,10 +36,19 @@
let colorDraft = $state<string | null>(null); let colorDraft = $state<string | null>(null);
let busy = $state(false); let busy = $state(false);
async function withBusy<T>(fn: () => Promise<T>): Promise<T> { // `label` drives the per-photo tile overlay (pending → done / error) via the
// shared bulkAction store, so metadata applies show the same progress state
// as the archive/keep actions in BulkActionBar.
async function withBusy<T>(fn: () => Promise<T>, label?: string): Promise<T> {
busy = true; busy = true;
if (label) startBulk(`${label}…`, ids);
try { try {
return await fn(); const result = await fn();
if (label) doneBulk(label, ids);
return result;
} catch (e) {
if (label) failBulk(ids);
throw e;
} finally { } finally {
busy = false; busy = false;
} }
@@ -47,13 +57,16 @@
async function applyNote() { async function applyNote() {
if (busy) return; if (busy) return;
const value = noteDraft; const value = noteDraft;
await withBusy(() => const label = value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`;
patchTargets( await withBusy(
ids, () =>
{ Caption: value, CaptionSrc: 'manual' }, patchTargets(
value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`, ids,
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' }) { Caption: value, CaptionSrc: 'manual' },
) label,
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
),
label
); );
noteDraft = ''; noteDraft = '';
} }
@@ -64,16 +77,19 @@
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite // Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
// fill the local timezone field downstream. // fill the local timezone field downstream.
const iso = `${dateDraft}T00:00:00Z`; const iso = `${dateDraft}T00:00:00Z`;
await withBusy(() => const label = `Date → ${ids.length}`;
patchTargets( await withBusy(
ids, () =>
buildTakenAtPatch(iso), patchTargets(
`Date → ${ids.length}`, ids,
(p) => buildTakenAtPatch(iso),
p.TakenAt label,
? buildTakenAtPatch(p.TakenAt) (p) =>
: ({ TakenSrc: '' } as UpdatePhotoBody) p.TakenAt
) ? buildTakenAtPatch(p.TakenAt)
: ({ TakenSrc: '' } as UpdatePhotoBody)
),
label
); );
dateDraft = ''; dateDraft = '';
} }
@@ -81,6 +97,7 @@
async function applyMarks(patch: PhotoMark, label: string) { async function applyMarks(patch: PhotoMark, label: string) {
if (busy) return; if (busy) return;
const tid = toast.loading(`${label}…`); const tid = toast.loading(`${label}…`);
startBulk(`${label}…`, ids);
await withBusy(async () => { await withBusy(async () => {
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => { qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
const map = { ...(prev ?? {}) }; const map = { ...(prev ?? {}) };
@@ -95,8 +112,13 @@
}); });
try { try {
await bulkSetMarks(ids, patch); await bulkSetMarks(ids, patch);
doneBulk(label, ids);
// Refresh the Colors / Ratings facet panels — they sit on
// `['marks']` + `['photos','marks-pool']`, not the optimistic write above.
invalidateFacets();
toast.success(`${label} · ${ids.length}`, { id: tid }); toast.success(`${label} · ${ids.length}`, { id: tid });
} catch (err) { } catch (err) {
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Save failed', { id: tid }); toast.error(err instanceof Error ? err.message : 'Save failed', { id: tid });
void qc.invalidateQueries({ queryKey: ['marks'] }); void qc.invalidateQueries({ queryKey: ['marks'] });
} }
@@ -122,23 +144,26 @@
const kw = keywordDraft.trim().replace(/,/g, ''); const kw = keywordDraft.trim().replace(/,/g, '');
if (!kw) return; if (!kw) return;
keywordDraft = ''; keywordDraft = '';
await withBusy(() => const label = `Tagged "${kw}" → ${ids.length}`;
patchTargets( await withBusy(
ids, () =>
(p) => { patchTargets(
const cur = (p.Details?.Keywords ?? '') ids,
.split(',') (p) => {
.map((k) => k.trim()) const cur = (p.Details?.Keywords ?? '')
.filter(Boolean); .split(',')
if (cur.includes(kw)) return {}; .map((k) => k.trim())
const next = [...cur, kw].join(', '); .filter(Boolean);
return { Details: { Keywords: next, KeywordsSrc: 'manual' } }; if (cur.includes(kw)) return {};
}, const next = [...cur, kw].join(', ');
`Tagged "${kw}" → ${ids.length}`, return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
(p) => ({ },
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' } label,
}) (p) => ({
) Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
})
),
label
); );
} }

View File

@@ -37,6 +37,8 @@
type PhotoMarksMap, type PhotoMarksMap,
type UpdatePhotoBody type UpdatePhotoBody
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { invalidateFacets } from '$lib/services/bulk';
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte'; import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
@@ -91,12 +93,20 @@
const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo; const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
return updatePhoto(fresh, patch); return updatePhoto(fresh, patch);
}, },
onMutate: () => {
startBulk('Saving…', [photo.UID]);
},
onSuccess: (data) => { onSuccess: (data) => {
qc.setQueryData(['photo', data.UID], data); qc.setQueryData(['photo', data.UID], data);
void qc.invalidateQueries({ queryKey: ['photos'] }); void qc.invalidateQueries({ queryKey: ['photos'] });
// Keep the keyword / notes facet panels in sync with the edit.
invalidateFacets();
doneBulk('Saved', [photo.UID]);
}, },
onError: (err) => onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Save failed') failBulk([photo.UID]);
toast.error(err instanceof Error ? err.message : 'Save failed');
}
})); }));
function commit(patch: UpdatePhotoBody) { function commit(patch: UpdatePhotoBody) {
@@ -234,12 +244,17 @@
if (!optimistic.rating) delete optimistic.rating; if (!optimistic.rating) delete optimistic.rating;
if (!optimistic.color) delete optimistic.color; if (!optimistic.color) delete optimistic.color;
patchMarksCache(photo.UID, optimistic); patchMarksCache(photo.UID, optimistic);
startBulk('Saving…', [photo.UID]);
try { try {
const saved = await setMark(photo.UID, patch); const saved = await setMark(photo.UID, patch);
patchMarksCache(photo.UID, saved); patchMarksCache(photo.UID, saved);
// Refresh the Colors / Ratings facet panels off the sidecar truth.
invalidateFacets();
doneBulk('Saved', [photo.UID]);
} catch (err) { } catch (err) {
// Rollback on failure. // Rollback on failure.
patchMarksCache(photo.UID, prev); patchMarksCache(photo.UID, prev);
failBulk([photo.UID]);
toast.error(err instanceof Error ? err.message : 'Save failed'); toast.error(err instanceof Error ? err.message : 'Save failed');
} }
} }

View File

@@ -26,7 +26,14 @@
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
import { startBulk, setDetail, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte'; import {
startBulk,
setDetail,
doneBulk,
failBulk,
markRemoved,
clearRemoved
} from '$lib/stores/bulkAction.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte'; import { Layers } from 'lucide-svelte';
@@ -137,9 +144,15 @@
throw e; throw e;
} finally { } finally {
busy = false; busy = false;
void qc.invalidateQueries({ queryKey: ['photos'] }); const settled = Promise.all([
void qc.invalidateQueries({ queryKey: ['marks'] }); qc.invalidateQueries({ queryKey: ['photos'] }),
void qc.invalidateQueries({ queryKey: ['review-groups'] }); 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));
} }
} }
@@ -159,6 +172,8 @@
} else { } else {
toast.success(`Kept ${ids.length}`, { id: tid }); toast.success(`Kept ${ids.length}`, { id: tid });
} }
// Approved photos leave the review section — hide them immediately.
markRemoved(ids);
focusAfter(ids); focusAfter(ids);
clearSelection(); clearSelection();
}, { ids, label: 'Keeping', doneLabel: `Kept ${ids.length}` }); }, { ids, label: 'Keeping', doneLabel: `Kept ${ids.length}` });
@@ -181,6 +196,7 @@
await withBusy(async () => { await withBusy(async () => {
try { try {
await batchArchive(ids); await batchArchive(ids);
markRemoved(ids);
pushUndo(`Archived ${ids.length}`, async () => { pushUndo(`Archived ${ids.length}`, async () => {
await batchRestore(ids); await batchRestore(ids);
void qc.invalidateQueries({ queryKey: ['photos'] }); void qc.invalidateQueries({ queryKey: ['photos'] });
@@ -206,6 +222,7 @@
await withBusy(async () => { await withBusy(async () => {
try { try {
await batchDelete(ids); await batchDelete(ids);
markRemoved(ids);
focusAfter(ids); focusAfter(ids);
clearSelection(); clearSelection();
toast.success(`Deleted ${ids.length}`, { id: tid }); toast.success(`Deleted ${ids.length}`, { id: tid });
@@ -222,6 +239,7 @@
await withBusy(async () => { await withBusy(async () => {
try { try {
await batchRestore(ids); await batchRestore(ids);
markRemoved(ids);
pushUndo(`Restored ${ids.length}`, async () => { pushUndo(`Restored ${ids.length}`, async () => {
await batchArchive(ids); await batchArchive(ids);
void qc.invalidateQueries({ queryKey: ['photos'] }); void qc.invalidateQueries({ queryKey: ['photos'] });

View File

@@ -32,6 +32,23 @@ export function invalidatePhotos(uids: string[]): void {
} }
} }
/**
* Refresh the sidebar facet sections after a metadata mutation. The Colors /
* Ratings panels read `['marks']` + `['photos','marks-pool']`; Notes reads
* `['photos','with-notes']`; keywords / labels / people read their own keys.
* Optimistic cache writes keep the active tile in sync, but the facet panels
* sit on separate queries that otherwise stay stale until their staleTime
* expires — so call this on the success path of any marks/keyword/note apply.
*/
export function invalidateFacets(): void {
void queryClient.invalidateQueries({ queryKey: ['marks'] });
void queryClient.invalidateQueries({ queryKey: ['photos', 'marks-pool'] });
void queryClient.invalidateQueries({ queryKey: ['photos', 'with-notes'] });
void queryClient.invalidateQueries({ queryKey: ['photos', 'keywords'] });
void queryClient.invalidateQueries({ queryKey: ['labels'] });
void queryClient.invalidateQueries({ queryKey: ['subjects'] });
}
export function invalidateAllPhotoCaches(): void { export function invalidateAllPhotoCaches(): void {
void queryClient.invalidateQueries({ queryKey: ['photos'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['marks'] }); void queryClient.invalidateQueries({ queryKey: ['marks'] });
@@ -75,6 +92,7 @@ export async function patchTargets(
}); });
invalidatePhotos(ids); invalidatePhotos(ids);
invalidateFacets();
if (errors.length) { if (errors.length) {
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid }); toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid });

View File

@@ -9,6 +9,8 @@
* failBulk → tiles flash red, auto-clears after 2 s * failBulk → tiles flash red, auto-clears after 2 s
*/ */
import { SvelteSet } from 'svelte/reactivity';
interface BulkActionState { interface BulkActionState {
active: boolean; active: boolean;
label: string; label: string;
@@ -18,6 +20,23 @@ interface BulkActionState {
export const bulkAction = $state<BulkActionState>({ active: false, label: '' }); export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
export const bulkPhotoStates = $state(new Map<string, 'pending' | 'done' | 'error'>()); export const bulkPhotoStates = $state(new Map<string, 'pending' | 'done' | 'error'>());
/**
* UIDs hidden from the timeline grid the instant a removing action (archive /
* delete / restore) succeeds, so tiles vanish without waiting on the ~1s
* server-reconcile refetch. The caller clears each id once the refetch lands.
* This is a pure UI overlay — it never touches the query cache, so it can't
* corrupt the facet/drill caches the way a direct cache eviction did.
*/
export const removedIds = $state(new SvelteSet<string>());
export function markRemoved(ids: string[]): void {
for (const id of ids) removedIds.add(id);
}
export function clearRemoved(ids: string[]): void {
for (const id of ids) removedIds.delete(id);
}
let doneTimer: ReturnType<typeof setTimeout> | null = null; let doneTimer: ReturnType<typeof setTimeout> | null = null;
export function startBulk(label: string, ids: string[]): void { export function startBulk(label: string, ids: string[]): void {

View File

@@ -37,6 +37,7 @@
setFocused, setFocused,
setOrder, setOrder,
} from "$lib/stores/selection.svelte"; } from "$lib/stores/selection.svelte";
import { removedIds } from "$lib/stores/bulkAction.svelte";
import { import {
openPreview, openPreview,
setRightSidebarWidth, setRightSidebarWidth,
@@ -245,7 +246,12 @@
const dedupedAll = $derived<PpPhoto[]>( const dedupedAll = $derived<PpPhoto[]>(
dedupedPhotos(photosQuery.data?.pages), dedupedPhotos(photosQuery.data?.pages),
); );
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters)); // `removedIds` hides tiles the instant a removing action (archive / delete /
// restore) succeeds, so the grid updates without waiting on the server-
// reconcile refetch (see bulkAction store / BulkActionBar).
const photos = $derived<PpPhoto[]>(
applyFolderScope(dedupedAll, filters).filter((p) => !removedIds.has(p.UID)),
);
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] { function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
if (!pages) return []; if (!pages) return [];
const seen = new Set<string>(); const seen = new Set<string>();