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

@@ -101,6 +101,90 @@
setFocused(next);
setAnchor(next);
}
// ── Zoom & pan ───────────────────────────────────────────────────────
// Wheel zooms around the cursor, double-click toggles 1↔2.5, drag pans
// while zoomed. Transform lives on a wrapper so the LQIP layer and the
// sharp image scale together. Resets on photo change. Past 1.25× the
// sharp <img> switches to fit_2048 so zoomed pixels stay crisp.
const MAX_ZOOM = 6;
let zoom = $state(1);
let tx = $state(0);
let ty = $state(0);
let zoomHost = $state<HTMLElement | undefined>();
let panning = $state(false);
let lastX = 0;
let lastY = 0;
$effect(() => {
void uid;
zoom = 1;
tx = 0;
ty = 0;
});
function applyZoom(next: number, clientX: number, clientY: number) {
if (!zoomHost) return;
const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
if (clamped === zoom) return;
// Keep the point under the cursor fixed: translate offsets are in
// post-scale pixels around the container centre.
const rect = zoomHost.getBoundingClientRect();
const cx = clientX - rect.left - rect.width / 2;
const cy = clientY - rect.top - rect.height / 2;
const s = clamped / zoom;
tx = cx + (tx - cx) * s;
ty = cy + (ty - cy) * s;
zoom = clamped;
if (zoom === 1) {
tx = 0;
ty = 0;
}
}
function onWheel(e: WheelEvent) {
e.preventDefault();
applyZoom(zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
}
/** Svelte marks wheel handlers passive; zooming needs preventDefault,
* so the listener is attached manually as non-passive. */
function wheelZoom(node: HTMLElement) {
node.addEventListener('wheel', onWheel, { passive: false });
return {
destroy() {
node.removeEventListener('wheel', onWheel);
}
};
}
function onDblClickZoom(e: MouseEvent) {
if (zoom > 1) {
zoom = 1;
tx = 0;
ty = 0;
} else {
applyZoom(2.5, e.clientX, e.clientY);
}
}
function onPointerDown(e: PointerEvent) {
if (zoom === 1) return;
panning = true;
lastX = e.clientX;
lastY = e.clientY;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!panning) return;
tx += e.clientX - lastX;
ty += e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
}
function onPointerUp() {
panning = false;
}
</script>
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
@@ -145,30 +229,61 @@
photoQuery.data.OriginalName ??
pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
the tile_*'s square center-crop against the sharp image's
true aspect. Sized via aspect-ratio + max-* + m-auto so it
lands in the exact same bounding box as the sharp <img>
beside it (object-contain semantics, but expressible on a
positioned element). Paints from the HTTP cache the moment
the modal opens. -->
<img
src={thumbSrc(pf.Hash, view.thumbnailSize)}
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
style="aspect-ratio: {pf.Width} / {pf.Height};"
/>
{/if}
<img
src={thumbUrl(pf.Hash, 'fit_1280')}
alt={altText}
fetchpriority="high"
decoding="async"
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={zoomHost}
use:wheelZoom
ondblclick={onDblClickZoom}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
class="relative flex h-full w-full items-center justify-center overflow-hidden {zoom > 1
? panning
? 'cursor-grabbing'
: 'cursor-grab'
: 'cursor-zoom-in'}"
>
<div
class="relative flex h-full w-full items-center justify-center"
class:transition-transform={!panning}
class:duration-150={!panning}
style="transform: translate({tx}px, {ty}px) scale({zoom});"
>
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
the tile_*'s square center-crop against the sharp image's
true aspect. Sized via aspect-ratio + max-* + m-auto so it
lands in the exact same bounding box as the sharp <img>
beside it (object-contain semantics, but expressible on a
positioned element). Paints from the HTTP cache the moment
the modal opens. -->
<img
src={thumbSrc(pf.Hash, view.thumbnailSize)}
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
style="aspect-ratio: {pf.Width} / {pf.Height};"
/>
{/if}
<img
src={thumbUrl(pf.Hash, zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
alt={altText}
fetchpriority="high"
decoding="async"
draggable="false"
class="relative max-h-full max-w-full select-none rounded-md object-contain shadow-2xl"
/>
</div>
{#if zoom > 1}
<span
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-background/80 px-2 py-0.5 text-[11px] text-foreground"
>
{Math.round(zoom * 100)}% · double-click to reset
</span>
{/if}
</div>
{/if}
{/if}
</div>

View File

@@ -13,10 +13,12 @@
Aperture,
ArrowUpRight,
Calendar,
Copy,
File,
Folder,
Globe,
HardDrive,
Heart,
ImageIcon,
Loader2,
MapPin,
@@ -37,12 +39,14 @@
type UpdatePhotoBody
} from '$lib/services/photoprism';
import { invalidateFacets } from '$lib/services/bulk';
import { toggleFavorite } from '$lib/services/photoActions';
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';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { navigateToFolder, navigateToTag } from '$lib/stores/filters.svelte';
import { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte';
import { goto } from '$app/navigation';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
@@ -307,6 +311,36 @@
const joined = `${make} ${model}`.trim();
return joined && joined !== 'Unknown' ? joined : '';
}
/** Quote for PhotoPrism's q= DSL — mirrors filters.svelte's quoteIfNeeded,
* duplicated here since that helper isn't exported. */
function quoteTerm(v: string): string {
return /^[A-Za-z0-9_-]+$/.test(v) ? v : `"${v.replace(/"/g, '\\"')}"`;
}
/** Jump to the timeline filtered by a raw DSL term (camera:/lens:) — the
* q-DSL escape hatch from the toolbar search box, triggered by click
* instead of typing. */
async function jumpToSearch(term: string): Promise<void> {
setSection('all-photos');
setSearch(term);
await goto('/', { keepFocus: true, noScroll: true });
}
async function copyExif(): Promise<void> {
const lines = [
cameraStr && `Camera: ${cameraStr}`,
lensStr && lensStr !== cameraStr && `Lens: ${lensStr}`,
exposureParts.fnum && `Aperture: ${exposureParts.fnum}`,
exposureParts.exp && `Shutter: ${exposureParts.exp}`,
exposureParts.iso && exposureParts.iso,
exposureParts.focal && `Focal length: ${exposureParts.focal}`,
photo.TakenAt && `Taken: ${photo.TakenAt}`
].filter(Boolean);
if (lines.length === 0) {
toast.message('No EXIF to copy');
return;
}
await navigator.clipboard.writeText(lines.join('\n'));
toast.success('EXIF copied');
}
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
return {
iso: p.Iso ? `ISO ${p.Iso}` : '',
@@ -501,6 +535,19 @@
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
</button>
{/each}
<!-- PhotoPrism's native favorite — syncs to mobile gallery apps. -->
<button
type="button"
class="ml-2 p-0.5 transition-colors {photo.Favorite
? 'text-red-500'
: 'text-muted-foreground hover:text-foreground'}"
onclick={() => void toggleFavorite([photo.UID])}
title={photo.Favorite ? 'Remove from favorites (f)' : 'Add to favorites (f)'}
aria-pressed={photo.Favorite ?? false}
aria-label="Favorite"
>
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
</button>
</div>
</div>
@@ -641,20 +688,51 @@
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
class="flex cursor-pointer items-center justify-between px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<span class="inline-flex items-center gap-1">
<ImageIcon class="h-3 w-3" /> File
</span>
<button
type="button"
class="normal-case text-muted-foreground hover:text-foreground"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
void copyExif();
}}
title="Copy EXIF summary"
aria-label="Copy EXIF summary"
>
<Copy class="h-3 w-3" />
</button>
</summary>
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
{#if cameraStr}
<dt class="text-muted-foreground">Camera</dt>
<dd class="text-foreground/80">{cameraStr}</dd>
<dd class="min-w-0">
<button
type="button"
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
onclick={() => void jumpToSearch(`camera:${quoteTerm(cameraStr)}`)}
title={`View other photos taken with ${cameraStr}`}
>
{cameraStr}
</button>
</dd>
{/if}
{#if lensStr && lensStr !== cameraStr}
<dt class="text-muted-foreground">Lens</dt>
<dd class="text-foreground/80">{lensStr}</dd>
<dd class="min-w-0">
<button
type="button"
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
onclick={() => void jumpToSearch(`lens:${quoteTerm(lensStr)}`)}
title={`View other photos taken with ${lensStr}`}
>
{lensStr}
</button>
</dd>
{/if}
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
<dt class="text-muted-foreground">Exposure</dt>

View File

@@ -17,8 +17,9 @@
import { view } from "$lib/stores/view.svelte";
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
import { toggleFavorite } from "$lib/services/photoActions";
import { fade } from "svelte/transition";
import { Loader2, Check, X } from "lucide-svelte";
import { Loader2, Check, Heart, X } from "lucide-svelte";
interface Props {
photo: PpPhoto;
@@ -191,4 +192,28 @@
>
{/if}
</button>
<!--
Favorite heart — a *sibling* of the tile button (nested buttons are
invalid HTML and break click semantics). Filled + always visible when
favorited; otherwise fades in on hover. Mirrors the `f` shortcut.
-->
<button
type="button"
class="absolute bottom-1.5 right-1.5 z-10 rounded-full bg-background/70 p-1 backdrop-blur transition-opacity {photo.Favorite
? 'opacity-100'
: 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100'}"
onclick={(e) => {
e.stopPropagation();
void toggleFavorite([photo.UID]);
}}
ondblclick={(e) => e.stopPropagation()}
title={photo.Favorite ? "Remove from favorites (f)" : "Add to favorites (f)"}
aria-pressed={photo.Favorite ?? false}
aria-label="Favorite"
>
<Heart
class="h-3.5 w-3.5 {photo.Favorite ? 'text-red-500' : 'text-foreground/80'}"
fill={photo.Favorite ? "currentColor" : "none"}
/>
</button>
</div>