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:
@@ -13,7 +13,7 @@ import {
|
|||||||
type PhotoMarksMap,
|
type PhotoMarksMap,
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
import { acceptDateAndKeep, cachedPhoto, toggleFavorite } from '$lib/services/photoActions';
|
||||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
import { photoNameAndDir } from '$lib/types/photoprism';
|
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
@@ -653,6 +653,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void toggleArchive('restore');
|
void toggleArchive('restore');
|
||||||
return;
|
return;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleFavorite(cullTargets());
|
||||||
|
return;
|
||||||
case 'm':
|
case 'm':
|
||||||
case 'M': {
|
case 'M': {
|
||||||
if (meta || shift) return;
|
if (meta || shift) return;
|
||||||
|
|||||||
@@ -101,6 +101,90 @@
|
|||||||
setFocused(next);
|
setFocused(next);
|
||||||
setAnchor(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>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
||||||
@@ -145,30 +229,61 @@
|
|||||||
photoQuery.data.OriginalName ??
|
photoQuery.data.OriginalName ??
|
||||||
pf.Name ??
|
pf.Name ??
|
||||||
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
||||||
{#if pf.Width && pf.Height}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
<div
|
||||||
the tile_*'s square center-crop against the sharp image's
|
bind:this={zoomHost}
|
||||||
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
use:wheelZoom
|
||||||
lands in the exact same bounding box as the sharp <img>
|
ondblclick={onDblClickZoom}
|
||||||
beside it (object-contain semantics, but expressible on a
|
onpointerdown={onPointerDown}
|
||||||
positioned element). Paints from the HTTP cache the moment
|
onpointermove={onPointerMove}
|
||||||
the modal opens. -->
|
onpointerup={onPointerUp}
|
||||||
<img
|
onpointercancel={onPointerUp}
|
||||||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
class="relative flex h-full w-full items-center justify-center overflow-hidden {zoom > 1
|
||||||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
? panning
|
||||||
alt=""
|
? 'cursor-grabbing'
|
||||||
aria-hidden="true"
|
: 'cursor-grab'
|
||||||
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
: 'cursor-zoom-in'}"
|
||||||
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
>
|
||||||
/>
|
<div
|
||||||
{/if}
|
class="relative flex h-full w-full items-center justify-center"
|
||||||
<img
|
class:transition-transform={!panning}
|
||||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
class:duration-150={!panning}
|
||||||
alt={altText}
|
style="transform: translate({tx}px, {ty}px) scale({zoom});"
|
||||||
fetchpriority="high"
|
>
|
||||||
decoding="async"
|
{#if pf.Width && pf.Height}
|
||||||
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
<!-- 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}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
Aperture,
|
Aperture,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Copy,
|
||||||
File,
|
File,
|
||||||
Folder,
|
Folder,
|
||||||
Globe,
|
Globe,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Heart,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -37,12 +39,14 @@
|
|||||||
type UpdatePhotoBody
|
type UpdatePhotoBody
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { invalidateFacets } from '$lib/services/bulk';
|
import { invalidateFacets } from '$lib/services/bulk';
|
||||||
|
import { toggleFavorite } from '$lib/services/photoActions';
|
||||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
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';
|
||||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
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 { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||||
import { countryName } from '$lib/utils/countries';
|
import { countryName } from '$lib/utils/countries';
|
||||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
@@ -307,6 +311,36 @@
|
|||||||
const joined = `${make} ${model}`.trim();
|
const joined = `${make} ${model}`.trim();
|
||||||
return joined && joined !== 'Unknown' ? joined : '';
|
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 } {
|
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
|
||||||
return {
|
return {
|
||||||
iso: p.Iso ? `ISO ${p.Iso}` : '',
|
iso: p.Iso ? `ISO ${p.Iso}` : '',
|
||||||
@@ -501,6 +535,19 @@
|
|||||||
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
|
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -641,20 +688,51 @@
|
|||||||
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
|
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
|
||||||
>
|
>
|
||||||
<summary
|
<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">
|
<span class="inline-flex items-center gap-1">
|
||||||
<ImageIcon class="h-3 w-3" /> File
|
<ImageIcon class="h-3 w-3" /> File
|
||||||
</span>
|
</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>
|
</summary>
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
||||||
{#if cameraStr}
|
{#if cameraStr}
|
||||||
<dt class="text-muted-foreground">Camera</dt>
|
<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}
|
||||||
{#if lensStr && lensStr !== cameraStr}
|
{#if lensStr && lensStr !== cameraStr}
|
||||||
<dt class="text-muted-foreground">Lens</dt>
|
<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}
|
||||||
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
|
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
|
||||||
<dt class="text-muted-foreground">Exposure</dt>
|
<dt class="text-muted-foreground">Exposure</dt>
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
import { view } from "$lib/stores/view.svelte";
|
import { view } from "$lib/stores/view.svelte";
|
||||||
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||||
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
||||||
|
import { toggleFavorite } from "$lib/services/photoActions";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { Loader2, Check, X } from "lucide-svelte";
|
import { Loader2, Check, Heart, X } from "lucide-svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photo: PpPhoto;
|
photo: PpPhoto;
|
||||||
@@ -191,4 +192,28 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ export const queryClient = new QueryClient({
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: 1
|
retry: 1,
|
||||||
|
// The indexer WebSocket (stores/indexer.svelte.ts) already
|
||||||
|
// invalidates ['photos'] and friends on live changes, so a
|
||||||
|
// window-focus refetch only adds a redundant full-timeline
|
||||||
|
// re-render (visible flash) every time the tab regains focus.
|
||||||
|
refetchOnWindowFocus: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
batchArchive,
|
batchArchive,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
buildTakenAtPatch,
|
buildTakenAtPatch,
|
||||||
|
likePhoto,
|
||||||
|
unlikePhoto,
|
||||||
updatePhoto
|
updatePhoto
|
||||||
} from './photoprism';
|
} from './photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
@@ -121,6 +123,70 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
|||||||
toast.success(`Kept ${uids.length}`, { id: tid });
|
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).
|
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1050,6 +1050,18 @@ export async function moveFolder(rel: string, targetParent: string): Promise<Fol
|
|||||||
}) as Promise<FolderMoveResult>;
|
}) as Promise<FolderMoveResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Favorites ────────────────────────────────────────────────────────────────
|
||||||
|
// PhotoPrism's native favorite flag — unlike marks, this syncs to any
|
||||||
|
// PhotoPrism-compatible client app.
|
||||||
|
|
||||||
|
export async function likePhoto(uid: string): Promise<void> {
|
||||||
|
await http.post(`/photos/${encodeURIComponent(uid)}/like`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlikePhoto(uid: string): Promise<void> {
|
||||||
|
await http.delete(`/photos/${encodeURIComponent(uid)}/like`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
||||||
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
||||||
// internal fields). We store them in mule-sidecar instead.
|
// internal fields). We store them in mule-sidecar instead.
|
||||||
|
|||||||
@@ -315,7 +315,13 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
if (f.mediaType) parts.push(`${f.mediaType}:true`);
|
if (f.mediaType) parts.push(`${f.mediaType}:true`);
|
||||||
if (f.year) parts.push(`year:${f.year}`);
|
if (f.year) parts.push(`year:${f.year}`);
|
||||||
if (f.favorite) parts.push('favorite:true');
|
if (f.favorite) parts.push('favorite:true');
|
||||||
if (f.search) parts.push(quoteIfNeeded(f.search));
|
// `f.search` is the raw-DSL escape hatch (toolbar cheat-sheet examples
|
||||||
|
// like `label:dog`, `taken:2024`) as well as plain free text. Only
|
||||||
|
// quote it when it has no `:` — a colon means the user (or a
|
||||||
|
// jump-to-search link) already wrote a structured term, and wrapping
|
||||||
|
// the whole thing in quotes would turn `camera:iPhone` into a literal
|
||||||
|
// phrase search for the text "camera:iPhone" instead of the operator.
|
||||||
|
if (f.search) parts.push(f.search.includes(':') ? f.search : quoteIfNeeded(f.search));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export interface PpPhoto {
|
|||||||
Height?: number;
|
Height?: number;
|
||||||
Rating?: number;
|
Rating?: number;
|
||||||
Color?: string | number;
|
Color?: string | number;
|
||||||
|
Favorite?: boolean;
|
||||||
Archived?: boolean;
|
Archived?: boolean;
|
||||||
Files?: PpFile[];
|
Files?: PpFile[];
|
||||||
Lat?: number;
|
Lat?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user