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,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>
|
||||
|
||||
Reference in New Issue
Block a user