diff --git a/sidecar/handlers_marks.go b/sidecar/handlers_marks.go index 6f98189..442ffef 100644 --- a/sidecar/handlers_marks.go +++ b/sidecar/handlers_marks.go @@ -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. diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index 8624f81..5def018 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -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(() => ({ - 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:*` 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(() => ({ - 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(() => ({ 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(() => ({ - 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>(() => ({ - 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>(() => ({ - queryKey: ['photos', 'root-count', userBasePath()], - queryFn: () => listFolderCounts(['']), - enabled: isAuthenticated() && userBasePath() !== '', - staleTime: 60_000 - })); - const visibleRootCountQuery = createQuery(() => ({ - 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. --> {/if} - {@const notesActive = isNotesActive()} - {@const notesCount = notesQuery.data?.length} Notes - {#if notesCount !== undefined} - - {notesCount} - - {/if} {#each TAG_CATEGORIES as cat (cat)} {@const active = isTagCategoryActive(cat)} diff --git a/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte index 1ee993e..64766a0 100644 --- a/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte +++ b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte @@ -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(null); let busy = $state(false); - async function withBusy(fn: () => Promise): Promise { + // `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(fn: () => Promise, label?: string): Promise { 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(['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 ); } diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte index fa1a805..61c5e88 100644 --- a/web/src/lib/components/sidebar/RightSidebar.svelte +++ b/web/src/lib/components/sidebar/RightSidebar.svelte @@ -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(['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'); } } diff --git a/web/src/lib/components/timeline/BulkActionBar.svelte b/web/src/lib/components/timeline/BulkActionBar.svelte index bb4ec38..cfee8c4 100644 --- a/web/src/lib/components/timeline/BulkActionBar.svelte +++ b/web/src/lib/components/timeline/BulkActionBar.svelte @@ -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'] }); diff --git a/web/src/lib/services/bulk.ts b/web/src/lib/services/bulk.ts index f6cd9fa..8898283 100644 --- a/web/src/lib/services/bulk.ts +++ b/web/src/lib/services/bulk.ts @@ -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 }); diff --git a/web/src/lib/stores/bulkAction.svelte.ts b/web/src/lib/stores/bulkAction.svelte.ts index eaab830..fde76a1 100644 --- a/web/src/lib/stores/bulkAction.svelte.ts +++ b/web/src/lib/stores/bulkAction.svelte.ts @@ -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({ active: false, label: '' }); export const bulkPhotoStates = $state(new Map()); +/** + * 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()); + +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 | null = null; export function startBulk(label: string, ids: string[]): void { diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 35a88d1..b20b4d4 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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( dedupedPhotos(photosQuery.data?.pages), ); - const photos = $derived(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( + applyFolderScope(dedupedAll, filters).filter((p) => !removedIds.has(p.UID)), + ); function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] { if (!pages) return []; const seen = new Set();