feat(web): native favorites, lightbox zoom, EXIF jump links + perf fix

- Favorites use PhotoPrism's own like/unlike endpoint (not a mule-only
  mark) so they sync to third-party gallery apps, with heart controls
  in the tile, sidebar, and an `f` shortcut.
- Lightbox: wheel-zoom around cursor, double-click to 2.5x, drag-to-pan,
  auto-upgrades to the fit_2048 tile past 1.25x zoom.
- Sidebar: copy-EXIF button, clickable Camera/Lens values that jump to
  a filtered timeline (camera:/lens: DSL), matching the existing
  Country link.
- Fix filtersToQ() quoting the entire search string whenever it
  contained a colon, which silently turned any raw DSL operator
  (camera:, taken:2024, etc.) into a literal phrase search — discovered
  while verifying the new jump-links against production.
- Disable TanStack Query's refetchOnWindowFocus: the indexer WebSocket
  already invalidates photo queries on real changes, so the focus
  refetch was just a redundant full-timeline re-render on tab-switch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 21:56:49 +02:00
parent 9ba8d625bc
commit 0f65bfb94a
9 changed files with 346 additions and 32 deletions

View File

@@ -19,6 +19,8 @@ import {
batchArchive,
batchRestore,
buildTakenAtPatch,
likePhoto,
unlikePhoto,
updatePhoto
} from './photoprism';
import { queryClient } from '$lib/queryClient';
@@ -121,6 +123,70 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
toast.success(`Kept ${uids.length}`, { id: tid });
}
/** Patch `Favorite` on every cached copy of the uids (timeline pages,
* per-photo detail) so hearts flip instantly without a refetch. */
function patchFavoriteCaches(uids: string[], value: boolean): void {
const target = new Set(uids);
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
for (const [key, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
queryClient.setQueryData(
key,
(data as PpPhoto[]).map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
);
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
queryClient.setQueryData(key, {
...(data as object),
pages: pages.map((pg) =>
pg.map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
)
});
}
for (const uid of uids) {
const p = queryClient.getQueryData<PpPhoto>(['photo', uid]);
if (p) queryClient.setQueryData(['photo', uid], { ...p, Favorite: value });
}
}
/**
* Toggle PhotoPrism's native favorite flag on a set of photos. Target
* state comes from the first uid (mixed selections converge). Optimistic
* cache flip with rollback; undo re-toggles.
*/
export async function toggleFavorite(uids: string[]): Promise<void> {
if (uids.length === 0) {
toast.message('Nothing to favorite', {
description: 'Click a photo or select some first'
});
return;
}
const value = !(cachedPhoto(uids[0])?.Favorite ?? false);
patchFavoriteCaches(uids, value);
const { errors } = await batchEdit(uids, (id) => (value ? likePhoto(id) : unlikePhoto(id)));
if (errors.length) {
patchFavoriteCaches(uids, !value);
toast.error(`Favorite failed on ${errors.length}`, { description: errors[0].message });
return;
}
toast.success(
value
? uids.length === 1
? 'Added to favorites'
: `Favorited ${uids.length}`
: uids.length === 1
? 'Removed from favorites'
: `Unfavorited ${uids.length}`
);
pushUndo(value ? `Favorited ${uids.length}` : `Unfavorited ${uids.length}`, async () => {
patchFavoriteCaches(uids, !value);
await batchEdit(uids, (id) => (value ? unlikePhoto(id) : likePhoto(id)));
});
}
/**
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
*/