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

@@ -17,7 +17,8 @@
type PhotoMarksMap,
type UpdatePhotoBody
} 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';
const qc = useQueryClient();
@@ -35,10 +36,19 @@
let colorDraft = $state<string | null>(null);
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;
if (label) startBulk(`${label}…`, ids);
try {
return await fn();
const result = await fn();
if (label) doneBulk(label, ids);
return result;
} catch (e) {
if (label) failBulk(ids);
throw e;
} finally {
busy = false;
}
@@ -47,13 +57,16 @@
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' })
)
const label = value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
{ Caption: value, CaptionSrc: 'manual' },
label,
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
),
label
);
noteDraft = '';
}
@@ -64,16 +77,19 @@
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
// fill the local timezone field downstream.
const iso = `${dateDraft}T00:00:00Z`;
await withBusy(() =>
patchTargets(
ids,
buildTakenAtPatch(iso),
`Date → ${ids.length}`,
(p) =>
p.TakenAt
? buildTakenAtPatch(p.TakenAt)
: ({ TakenSrc: '' } as UpdatePhotoBody)
)
const label = `Date → ${ids.length}`;
await withBusy(
() =>
patchTargets(
ids,
buildTakenAtPatch(iso),
label,
(p) =>
p.TakenAt
? buildTakenAtPatch(p.TakenAt)
: ({ TakenSrc: '' } as UpdatePhotoBody)
),
label
);
dateDraft = '';
}
@@ -81,6 +97,7 @@
async function applyMarks(patch: PhotoMark, label: string) {
if (busy) return;
const tid = toast.loading(`${label}…`);
startBulk(`${label}…`, ids);
await withBusy(async () => {
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
const map = { ...(prev ?? {}) };
@@ -95,8 +112,13 @@
});
try {
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 });
} catch (err) {
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Save failed', { id: tid });
void qc.invalidateQueries({ queryKey: ['marks'] });
}
@@ -122,23 +144,26 @@
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' }
})
)
const label = `Tagged "${kw}" → ${ids.length}`;
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' } };
},
label,
(p) => ({
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
})
),
label
);
}