diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index 8f66bb4..1ad84ae 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -13,7 +13,7 @@ import { type PhotoMarksMap, type PpAlbum } 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 { photoNameAndDir } from '$lib/types/photoprism'; import { queryClient } from '$lib/queryClient'; @@ -653,6 +653,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { e.preventDefault(); void toggleArchive('restore'); return; + case 'f': + case 'F': + if (meta || shift) return; + e.preventDefault(); + void toggleFavorite(cullTargets()); + return; case 'm': case 'M': { if (meta || shift) return; diff --git a/web/src/lib/components/preview/PreviewPane.svelte b/web/src/lib/components/preview/PreviewPane.svelte index 0787c5e..304427e 100644 --- a/web/src/lib/components/preview/PreviewPane.svelte +++ b/web/src/lib/components/preview/PreviewPane.svelte @@ -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 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(); + 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; + }
@@ -145,30 +229,61 @@ photoQuery.data.OriginalName ?? pf.Name ?? (isVideo(photoQuery.data) ? 'Video' : 'Photo')} - {#if pf.Width && pf.Height} - - - {/if} - {altText} + +
+
+ {#if pf.Width && pf.Height} + + + {/if} + 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" + /> +
+ {#if zoom > 1} + + {Math.round(zoom * 100)}% · double-click to reset + + {/if} +
{/if} {/if}
diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte index 39eb49a..4dbb93c 100644 --- a/web/src/lib/components/sidebar/RightSidebar.svelte +++ b/web/src/lib/components/sidebar/RightSidebar.svelte @@ -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 { + setSection('all-photos'); + setSearch(term); + await goto('/', { keepFocus: true, noScroll: true }); + } + async function copyExif(): Promise { + 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 @@ = n ? 'currentColor' : 'none'} /> {/each} + + @@ -641,20 +688,51 @@ ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)} > File +
{#if cameraStr}
Camera
-
{cameraStr}
+
+ +
{/if} {#if lensStr && lensStr !== cameraStr}
Lens
-
{lensStr}
+
+ +
{/if} {#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
Exposure
diff --git a/web/src/lib/components/timeline/PhotoTile.svelte b/web/src/lib/components/timeline/PhotoTile.svelte index fd36df1..7af5dde 100644 --- a/web/src/lib/components/timeline/PhotoTile.svelte +++ b/web/src/lib/components/timeline/PhotoTile.svelte @@ -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} + + diff --git a/web/src/lib/queryClient.ts b/web/src/lib/queryClient.ts index 98c0603..abc9298 100644 --- a/web/src/lib/queryClient.ts +++ b/web/src/lib/queryClient.ts @@ -9,7 +9,12 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { 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 } } }); diff --git a/web/src/lib/services/photoActions.ts b/web/src/lib/services/photoActions.ts index 13b02ac..9abb6c8 100644 --- a/web/src/lib/services/photoActions.ts +++ b/web/src/lib/services/photoActions.ts @@ -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 { 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(['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 { + 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). */ diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts index f61a52a..30a6fed 100644 --- a/web/src/lib/services/photoprism.ts +++ b/web/src/lib/services/photoprism.ts @@ -1050,6 +1050,18 @@ export async function moveFolder(rel: string, targetParent: string): Promise; } +// ── Favorites ──────────────────────────────────────────────────────────────── +// PhotoPrism's native favorite flag — unlike marks, this syncs to any +// PhotoPrism-compatible client app. + +export async function likePhoto(uid: string): Promise { + await http.post(`/photos/${encodeURIComponent(uid)}/like`); +} + +export async function unlikePhoto(uid: string): Promise { + await http.delete(`/photos/${encodeURIComponent(uid)}/like`); +} + // ── Photo marks (rating + color) ───────────────────────────────────────────── // PhotoPrism's PUT silently drops Rating and Color (they're auto-computed // internal fields). We store them in mule-sidecar instead. diff --git a/web/src/lib/stores/filters.svelte.ts b/web/src/lib/stores/filters.svelte.ts index 36d8a9a..354bd11 100644 --- a/web/src/lib/stores/filters.svelte.ts +++ b/web/src/lib/stores/filters.svelte.ts @@ -315,7 +315,13 @@ export function filtersToQ(f: FilterState = filters): string { if (f.mediaType) parts.push(`${f.mediaType}:true`); if (f.year) parts.push(`year:${f.year}`); 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(' '); } diff --git a/web/src/lib/types/photoprism.ts b/web/src/lib/types/photoprism.ts index 5ac1ffe..27db010 100644 --- a/web/src/lib/types/photoprism.ts +++ b/web/src/lib/types/photoprism.ts @@ -130,6 +130,7 @@ export interface PpPhoto { Height?: number; Rating?: number; Color?: string | number; + Favorite?: boolean; Archived?: boolean; Files?: PpFile[]; Lat?: number;