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:
@@ -10,13 +10,18 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// validColors is the four-color palette mule-image always shipped. The
|
||||
// empty string is the explicit "clear color" sentinel.
|
||||
// validColors is the color palette the web client offers (COLOR_SWATCHES in
|
||||
// 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{}{
|
||||
"red": {},
|
||||
"orange": {},
|
||||
"yellow": {},
|
||||
"green": {},
|
||||
"teal": {},
|
||||
"blue": {},
|
||||
"purple": {},
|
||||
"pink": {},
|
||||
}
|
||||
|
||||
// markPatch is the request body for all three mutating mark endpoints.
|
||||
|
||||
@@ -7,27 +7,21 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
aggregateKeywords,
|
||||
countPhotos,
|
||||
createFolder,
|
||||
createHeap,
|
||||
deleteFolder,
|
||||
deleteHeap,
|
||||
duplicateHeap,
|
||||
getConfig,
|
||||
heapDownloadUrl,
|
||||
listFolderCounts,
|
||||
listFolders,
|
||||
listHeaps,
|
||||
listPhotosWithNotes,
|
||||
logout,
|
||||
renameFolder,
|
||||
renameHeap,
|
||||
scanCrossFolderDuplicates,
|
||||
triggerDownload,
|
||||
type CrossFolderScanResult,
|
||||
type PhotoWithNote,
|
||||
type PpAlbum,
|
||||
type PpClientConfig,
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
import {
|
||||
@@ -87,84 +81,10 @@
|
||||
gcTime: 0
|
||||
}));
|
||||
|
||||
// View counts come from PhotoPrism's `/config` response, which carries a
|
||||
// precomputed counter for every common bucket (all/archived/labels/
|
||||
// places/…) updated incrementally on every mutation. Cheap to refetch,
|
||||
// and gives us a stable total — `/photos` only returns per-page row
|
||||
// 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.
|
||||
// Stacks + cross-folder duplicate caches are warmed here so the
|
||||
// /duplicates view (and its review tab strip) hits a warm cache. The
|
||||
// sidebar only observes these — cross-folder is an O(disk) scan, so it
|
||||
// stays enabled:false and the duplicates page populates it on first visit.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
@@ -178,95 +98,12 @@
|
||||
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(
|
||||
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
||||
);
|
||||
|
||||
// Per-folder photo counts. PhotoPrism's /folders/originals reports
|
||||
// FileCount: 0 for every folder, so the sidecar /folders/counts
|
||||
// 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));
|
||||
// Gates admin-only entry points lower in the sidebar.
|
||||
const isAdminUser = $derived(session.user?.Role === 'admin');
|
||||
|
||||
const createMut = createMutation(() => ({
|
||||
mutationFn: (title: string) => createHeap(title),
|
||||
@@ -576,7 +413,7 @@
|
||||
// tab subitems). This list carries the flat Manage entries that
|
||||
// follow it.
|
||||
const manageViews: ViewItem[] = [
|
||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
|
||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => undefined }
|
||||
];
|
||||
|
||||
function isRouteActive(href: string): boolean {
|
||||
@@ -697,11 +534,6 @@
|
||||
peers, so labels share a common left edge across the sidebar. -->
|
||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||
{/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
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
||||
@@ -709,15 +541,6 @@
|
||||
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||||
>
|
||||
<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>
|
||||
<!-- Root-row kebab. Only "New subfolder" applies — root itself
|
||||
can't be renamed or deleted, so those entries are omitted
|
||||
@@ -755,7 +578,6 @@
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||
counts={folderCounts}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -802,16 +624,9 @@
|
||||
class="flex min-w-0 flex-1 items-center pl-6 text-left"
|
||||
onclick={() => navigateTo('heap', heap.UID)}
|
||||
ondblclick={() => onRenameHeap(heap)}
|
||||
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||
title={heap.Title}
|
||||
>
|
||||
<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>
|
||||
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||
<KebabMenu label="Heap actions">
|
||||
@@ -898,11 +713,9 @@
|
||||
Notes lives alongside the tag categories — same indent and row
|
||||
chrome — but routes to /notes rather than /tags/*. Tucked at
|
||||
the top of the expandable so it's the first thing the user
|
||||
sees when opening Tags. Count badge renders once the shared
|
||||
['photos', 'with-notes'] query has resolved.
|
||||
sees when opening Tags.
|
||||
-->
|
||||
{@const notesActive = isNotesActive()}
|
||||
{@const notesCount = notesQuery.data?.length}
|
||||
<a
|
||||
href="/notes"
|
||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
@@ -912,15 +725,6 @@
|
||||
style="padding-left: 36px;"
|
||||
>
|
||||
<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>
|
||||
{#each TAG_CATEGORIES as cat (cat)}
|
||||
{@const active = isTagCategoryActive(cat)}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
type PhotoMarksMap,
|
||||
type UpdatePhotoBody
|
||||
} 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 { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
@@ -91,12 +93,20 @@
|
||||
const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
|
||||
return updatePhoto(fresh, patch);
|
||||
},
|
||||
onMutate: () => {
|
||||
startBulk('Saving…', [photo.UID]);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(['photo', data.UID], data);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
// Keep the keyword / notes facet panels in sync with the edit.
|
||||
invalidateFacets();
|
||||
doneBulk('Saved', [photo.UID]);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed')
|
||||
onError: (err) => {
|
||||
failBulk([photo.UID]);
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
}
|
||||
}));
|
||||
|
||||
function commit(patch: UpdatePhotoBody) {
|
||||
@@ -234,12 +244,17 @@
|
||||
if (!optimistic.rating) delete optimistic.rating;
|
||||
if (!optimistic.color) delete optimistic.color;
|
||||
patchMarksCache(photo.UID, optimistic);
|
||||
startBulk('Saving…', [photo.UID]);
|
||||
try {
|
||||
const saved = await setMark(photo.UID, patch);
|
||||
patchMarksCache(photo.UID, saved);
|
||||
// Refresh the Colors / Ratings facet panels off the sidecar truth.
|
||||
invalidateFacets();
|
||||
doneBulk('Saved', [photo.UID]);
|
||||
} catch (err) {
|
||||
// Rollback on failure.
|
||||
patchMarksCache(photo.UID, prev);
|
||||
failBulk([photo.UID]);
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,14 @@
|
||||
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 } 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 { Layers } from 'lucide-svelte';
|
||||
|
||||
@@ -137,9 +144,15 @@
|
||||
throw e;
|
||||
} finally {
|
||||
busy = false;
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
void qc.invalidateQueries({ queryKey: ['marks'] });
|
||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +172,8 @@
|
||||
} 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}` });
|
||||
@@ -181,6 +196,7 @@
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchArchive(ids);
|
||||
markRemoved(ids);
|
||||
pushUndo(`Archived ${ids.length}`, async () => {
|
||||
await batchRestore(ids);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
@@ -206,6 +222,7 @@
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchDelete(ids);
|
||||
markRemoved(ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||
@@ -222,6 +239,7 @@
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchRestore(ids);
|
||||
markRemoved(ids);
|
||||
pushUndo(`Restored ${ids.length}`, async () => {
|
||||
await batchArchive(ids);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
|
||||
@@ -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 {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
@@ -75,6 +92,7 @@ export async function patchTargets(
|
||||
});
|
||||
|
||||
invalidatePhotos(ids);
|
||||
invalidateFacets();
|
||||
|
||||
if (errors.length) {
|
||||
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid });
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* failBulk → tiles flash red, auto-clears after 2 s
|
||||
*/
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface BulkActionState {
|
||||
active: boolean;
|
||||
label: string;
|
||||
@@ -18,6 +20,23 @@ interface BulkActionState {
|
||||
export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
|
||||
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;
|
||||
|
||||
export function startBulk(label: string, ids: string[]): void {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
setFocused,
|
||||
setOrder,
|
||||
} from "$lib/stores/selection.svelte";
|
||||
import { removedIds } from "$lib/stores/bulkAction.svelte";
|
||||
import {
|
||||
openPreview,
|
||||
setRightSidebarWidth,
|
||||
@@ -245,7 +246,12 @@
|
||||
const dedupedAll = $derived<PpPhoto[]>(
|
||||
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[] {
|
||||
if (!pages) return [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
Reference in New Issue
Block a user