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"
|
"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.
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'] });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
Reference in New Issue
Block a user