Files
mule-image/web/src/lib/components/sidebar/RightSidebar.svelte
dtoro 0f65bfb94a 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>
2026-07-03 21:56:49 +02:00

767 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Metadata sidebar — compact, icon-led layout drawing from Apple Photos
(slim row stack, mini-map link), Lightroom (collapsible IPTC + EXIF
sections), and Immich (icon + value pairs). Editable fields are inline:
click → type → blur to save. Mutations deep-merge through PhotoPrism's
PUT (Details fields need the full body).
-->
<script lang="ts">
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
Aperture,
ArrowUpRight,
Calendar,
Copy,
File,
Folder,
Globe,
HardDrive,
Heart,
ImageIcon,
Loader2,
MapPin,
Star,
Tag,
Timer,
X
} from 'lucide-svelte';
import {
buildTakenAtPatch,
getAllMarks,
isValidISODate,
renameOnDisk,
setMark,
updatePhoto,
type PhotoMark,
type PhotoMarksMap,
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, 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';
interface Props {
photo: PpPhoto;
}
let { photo }: Props = $props();
const qc = useQueryClient();
let basename = $state('');
let caption = $state('');
let takenAt = $state('');
let lat = $state('');
let lng = $state('');
let country = $state('');
let keywords = $state<string[]>([]);
let keywordDraft = $state('');
let renaming = $state(false);
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
* prefix and basename. Sidecar's rename endpoint only accepts a bare
* basename — it preserves the directory on disk — so we mirror that
* contract in the UI by exposing just the basename for edit. */
function splitName(full: string): { dir: string; base: string } {
const i = full.lastIndexOf('/');
return i < 0 ? { dir: '', base: full } : { dir: full.slice(0, i), base: full.slice(i + 1) };
}
$effect(() => {
const pf = primaryFile(photo);
basename = splitName(pf.Name ?? '').base;
caption = photo.Caption ?? '';
takenAt = (photo.TakenAt ?? '').slice(0, 10);
lat = photo.Lat ? String(photo.Lat) : '';
lng = photo.Lng ? String(photo.Lng) : '';
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
const det = photo.Details ?? {};
keywords = (det.Keywords ?? '')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
});
const patchMutation = createMutation(() => ({
mutationFn: (patch: UpdatePhotoBody) => {
const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
return updatePhoto(fresh, patch);
},
onMutate: () => {
startBulk('Saving…', [photo.UID]);
},
onSuccess: (data) => {
qc.setQueryData(['photo', data.UID], data);
void qc.invalidateQueries({ queryKey: ['photos'] });
// Keep the keyword / notes facet panels in sync with the edit.
invalidateFacets();
doneBulk('Saved', [photo.UID]);
},
onError: (err) => {
failBulk([photo.UID]);
toast.error(err instanceof Error ? err.message : 'Save failed');
}
}));
function commit(patch: UpdatePhotoBody) {
patchMutation.mutate(patch);
}
async function commitFilename() {
const pf = primaryFile(photo);
const { base: currentBase } = splitName(pf.Name ?? '');
const next = basename.trim();
if (!next || next === currentBase) return;
renaming = true;
try {
const result = await renameOnDisk(photo.UID, next);
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
void qc.invalidateQueries({ queryKey: ['photos'] });
toast.success(`Renamed → ${result.newName}`);
pushUndo(`Renamed to ${result.newName}`, async () => {
await renameOnDisk(photo.UID, result.oldName);
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
void qc.invalidateQueries({ queryKey: ['photos'] });
});
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Rename failed');
basename = currentBase;
} finally {
renaming = false;
}
}
function commitCaption() {
if (caption === (photo.Caption ?? '')) return;
commit({ Caption: caption, CaptionSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
// are the photos with definitionally-untrusted dates, and showing the
// row anywhere else would compete with the existing TakenAt.
// PhotoPrism stores a TakenAt for stripped-EXIF photos too (filename
// guess or file mtime), so a per-photo "needs date" heuristic would
// silently hide the suggestion — the tab is the more reliable signal.
const onExifStrippedTab = $derived(
page.url.pathname === '/review' &&
page.url.searchParams.get('tab') === 'stripped_exif'
);
const dateSuggestion = $derived.by(() => {
const { fileName, path } = photoNameAndDir(photo);
return suggestDateFromPath({
fileName,
originalName: photo.OriginalName,
path
});
});
const showDateSuggestion = $derived(
onExifStrippedTab && !!dateSuggestion && dateSuggestion.iso !== takenAt
);
function applyDateSuggestion() {
if (!dateSuggestion) return;
takenAt = dateSuggestion.iso;
commitTakenAt();
}
function commitTakenAt() {
if (!takenAt) return;
if (!isValidISODate(takenAt)) {
// Revert to the photo's stored date so the field doesn't sit in
// a broken state once focus leaves it.
takenAt = (photo.TakenAt ?? '').slice(0, 10);
return;
}
// Keep the original time-of-day so date-only edits don't clobber the
// hour. Fall back to midnight UTC when the photo has no prior TakenAt.
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
const iso = `${takenAt}${tail}`;
if (iso === photo.TakenAt) return;
commit(buildTakenAtPatch(iso));
}
function commitGps() {
const nlat = parseFloat(lat);
const nlng = parseFloat(lng);
const patch: UpdatePhotoBody = {};
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
if (Object.keys(patch).length) commit(patch);
}
function commitCountry() {
const next = country.toLowerCase().slice(0, 2);
const prev = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
if (next === prev) return;
commit({ Country: next || 'zz', CountrySrc: 'manual' });
}
type DetailsKey = 'Keywords';
function commitDetails(field: DetailsKey, value: string) {
const prev = (photo.Details ?? {})[field] ?? '';
if (value === prev) return;
commit({ Details: { [field]: value, [`${field}Src`]: 'manual' } });
}
function addKeyword() {
const next = keywordDraft.trim().replace(/,/g, '');
keywordDraft = '';
if (!next || keywords.includes(next)) return;
keywords = [...keywords, next];
commitDetails('Keywords', keywords.join(', '));
}
function removeKeyword(k: string) {
keywords = keywords.filter((x) => x !== k);
commitDetails('Keywords', keywords.join(', '));
}
// Marks (rating + color) live on the mule-sidecar — PhotoPrism's PUT
// silently drops these fields. One query holds the whole map; mutations
// patch the cache optimistically and PUT to the sidecar.
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
function patchMarksCache(uid: string, next: PhotoMark | null) {
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
const map = { ...(prev ?? {}) };
if (!next || (next.rating == null && !next.color)) delete map[uid];
else map[uid] = next;
return map;
});
}
async function applyMark(patch: PhotoMark) {
const prevMap = qc.getQueryData<PhotoMarksMap>(['marks']) ?? {};
const prev = prevMap[photo.UID] ?? {};
const optimistic: PhotoMark = { ...prev, ...patch };
// Strip zero/empty so the cache matches what the sidecar persists.
if (!optimistic.rating) delete optimistic.rating;
if (!optimistic.color) delete optimistic.color;
patchMarksCache(photo.UID, optimistic);
startBulk('Saving…', [photo.UID]);
try {
const saved = await setMark(photo.UID, patch);
patchMarksCache(photo.UID, saved);
// Refresh the Colors / Ratings facet panels off the sidecar truth.
invalidateFacets();
doneBulk('Saved', [photo.UID]);
} catch (err) {
// Rollback on failure.
patchMarksCache(photo.UID, prev);
failBulk([photo.UID]);
toast.error(err instanceof Error ? err.message : 'Save failed');
}
}
/** Click-to-toggle: clicking the same star clears, clicking a higher star
* sets to that value. Mirrors mule-image's single-photo rating row. */
function setRating(next: number) {
const value = currentRating === next ? 0 : next;
if (value === currentRating) return;
void applyMark({ rating: value });
}
/** Click-to-toggle: clicking the current color clears it; clicking a
* different swatch swaps. */
function setColor(next: string) {
const value = currentColor === next ? '' : next;
if (value === currentColor) return;
void applyMark({ color: value });
}
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
const currentRating = $derived(photoMark.rating ?? 0);
const currentColor = $derived(photoMark.color ?? '');
const pf = $derived(primaryFile(photo));
const dirPath = $derived(splitName(pf.Name ?? '').dir);
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
const sizeStr = $derived(
pf.Size
? pf.Size > 1_000_000
? `${(pf.Size / 1_000_000).toFixed(1)} MB`
: `${(pf.Size / 1024).toFixed(0)} KB`
: '—'
);
const cameraStr = $derived(formatCameraLens(photo.Camera));
const lensStr = $derived(formatCameraLens(photo.Lens));
const exposureParts = $derived(formatExposureParts(photo));
const placeLabel = $derived(
photo.Place?.PlaceLabel && photo.Place.PlaceLabel !== 'Unknown'
? photo.Place.PlaceLabel
: photo.Country && photo.Country !== 'zz'
? photo.Country.toUpperCase()
: ''
);
function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
if (!c) return '';
const make = c.Make ?? '';
const model = c.Model ?? c.Name ?? '';
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}` : '',
fnum: p.FNumber ? `f/${p.FNumber}` : '',
focal: p.FocalLength ? `${p.FocalLength}mm` : '',
exp: p.Exposure ?? ''
};
}
</script>
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
<!-- Compact info rows. Filename / folder lead the stack as icon-led
rows so they read in the same rhythm as the date / place / camera
rows below. The sidecar's rename endpoint only accepts a bare
basename and preserves the directory on disk, so the basename row
is editable while the folder row stays read-only. -->
<dl class="space-y-1">
<!-- Filename (editable) -->
<div class="flex items-center gap-2">
<File class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
type="text"
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
bind:value={basename}
disabled={renaming}
onblur={commitFilename}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
/>
{#if renaming}
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
{/if}
</div>
<!-- Taken at -->
<div class="flex items-center gap-2">
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
type="text"
inputmode="numeric"
placeholder="YYYY-MM-DD"
pattern="\d{4}-\d{2}-\d{2}"
aria-invalid={!takenAtValid}
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
bind:value={takenAt}
onblur={commitTakenAt}
/>
</div>
<!-- Date suggestion derived from filename / folder signals. Only
shown on the EXIF Stripped review tab; amber styling marks
it as unconfirmed. `(estimated day)` hint appears when the
day was synthesised because only Y-M was available — same
row, just so the user knows that part is fabricated. Apply
writes the value into the date input above and commits as
a manual TakenAt edit. -->
{#if showDateSuggestion && dateSuggestion}
<div
class="flex items-center gap-2 rounded border border-amber-300/70 bg-amber-50/40 px-1.5 py-1 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
>
<Folder class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">
Suggested from path: <span class="font-medium">{dateSuggestion.iso}</span>
{#if dateSuggestion.source === 'path-ym-default-day'}
<span class="text-amber-600/80 dark:text-amber-400/70">(estimated day)</span>
{/if}
</span>
<button
type="button"
class="shrink-0 rounded border border-amber-400/60 bg-amber-100/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-400/30 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30"
onclick={applyDateSuggestion}
>
Apply
</button>
</div>
{/if}
<!-- Folder (read-only label + open-in-timeline icon). The `px-1 py-0.5`
mirrors the input padding on filename / date so the read-only
text starts at the same x-offset as the editable rows above —
otherwise spans hug the icon while inputs sit 4px in. Root-level
files render as `/` so the row never disappears. The arrow-up-
right icon navigates to the timeline filtered by this folder
with the photo pre-focused. -->
<div class="flex items-center gap-2">
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={folderLabel}>
{folderLabel}
</span>
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() =>
void navigateToFolder(dirPath || '/', {
focusUid: photo.UID,
focusTakenAt: photo.TakenAt ?? null
})}
title="Open folder in timeline"
aria-label="Open folder in timeline"
>
<ArrowUpRight class="h-3 w-3" />
</button>
</div>
<!-- Dimensions -->
<div class="flex items-center gap-2">
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{dims}
</span>
</div>
<!-- File size -->
<div class="flex items-center gap-2">
<HardDrive class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{sizeStr}
</span>
</div>
<!-- Location (read-only label + jump-to-country icon). Hidden when
the photo has no resolved country. -->
<div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{placeLabel || 'No location'}
</span>
{#if photo.Country && photo.Country !== 'zz'}
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() => void navigateToTag('countries', photo.Country ?? null)}
title={`View other photos from ${countryName(photo.Country)}`}
aria-label={`View other photos from ${countryName(photo.Country)}`}
>
<Globe class="h-3 w-3" />
</button>
{/if}
</div>
</dl>
<!-- Tags — note, score, color label, keywords, and auto-labels grouped
under one collapsible section. Note (PhotoPrism's Caption field,
labelled here to match mule-image's nomenclature) sits at the top
of the group since it's the most-edited per-photo field. Score +
color are stored on the mule-sidecar (PhotoPrism's PUT can't
persist them); keywords live on Details; auto-labels come from
PhotoPrism's TF classifier and are read-only. Open by default
since these are the culling marks the user reaches for first. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('tags', true)}
ontoggle={(e) => setMetadataSection('tags', e.currentTarget.open)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<span class="inline-flex items-center gap-1">
<Tag class="h-3 w-3" /> Tags
</span>
</summary>
<div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
rows="2"
placeholder="Add a note…"
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={caption}
onblur={commitCaption}
></textarea>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
{#each [1, 2, 3, 4, 5] as n (n)}
<button
type="button"
class="p-0.5 transition-colors disabled:opacity-50"
class:text-yellow-400={currentRating >= n}
class:text-muted-foreground={currentRating < n}
onclick={() => setRating(n)}
title={`Rate ${n}`}
aria-label={`Rate ${n}`}
>
<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>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)}
{@const picked = currentColor === c.key}
<button
type="button"
class="h-4 w-4 rounded-full border-2 transition-all {c.border} {picked
? c.bg
: 'bg-transparent'}"
aria-pressed={picked}
onclick={() => setColor(c.key)}
title={c.title}
aria-label={`Color ${c.key}`}
></button>
{/each}
</div>
</div>
<!-- Keywords as chips -->
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Keywords</div>
<div class="flex flex-wrap gap-1">
{#each keywords as kw (kw)}
<span
class="inline-flex items-center gap-0.5 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px]"
>
{kw}
<button
class="text-muted-foreground hover:text-destructive"
onclick={() => removeKeyword(kw)}
aria-label={`Remove ${kw}`}
>
<X class="h-2.5 w-2.5" />
</button>
</span>
{/each}
<input
type="text"
placeholder="+ tag"
class="w-16 rounded border border-input bg-background px-1.5 py-0.5 text-[10px] shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={keywordDraft}
onblur={addKeyword}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addKeyword();
}
}}
/>
</div>
</div>
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
only: editing labels requires re-indexing on PhotoPrism's
side. The dashed border + lower contrast distinguishes them
from the user-editable Keywords chips above. -->
{#if (photo.Labels ?? []).length > 0}
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">
Labels (auto)
</div>
<div class="flex flex-wrap gap-1">
{#each photo.Labels ?? [] as lbl (lbl.UID ?? lbl.Label?.Slug)}
{@const slug = lbl.Label?.Slug}
{@const name = lbl.Label?.Name ?? slug ?? '(unknown)'}
<a
href={slug ? `/?q=${encodeURIComponent(`label:${slug}`)}` : '#'}
class="inline-flex items-center rounded-full border border-dashed border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground hover:text-foreground"
title={`Source: ${lbl.Source ?? 'classifier'}`}
>
{name}
</a>
{/each}
</div>
</div>
{/if}
</div>
</details>
<!-- GPS detail. Static default (closed); user's expand/collapse
choice persists across photo switches via the view store.
Avoid data-driven defaults here — they make the `open` attr
change between photos, which fires a programmatic `toggle`
event and would silently overwrite the user's preference. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('gps', false)}
ontoggle={(e) => setMetadataSection('gps', e.currentTarget.open)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
GPS
</summary>
<div class="grid grid-cols-3 gap-1 p-2 pt-1">
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Lat</span>
<input
type="number"
step="0.0001"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={lat}
onblur={commitGps}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Lng</span>
<input
type="number"
step="0.0001"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={lng}
onblur={commitGps}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Country</span>
<input
type="text"
maxlength="2"
placeholder="us"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={country}
onblur={commitCountry}
/>
</label>
</div>
</details>
<!-- File metadata. Closed by default; persists once opened. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('file', false)}
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
>
<summary
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="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="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>
<dd class="flex flex-wrap gap-x-2 text-foreground/80">
{#if exposureParts.fnum}
<span class="flex items-center gap-0.5">
<Aperture class="h-2.5 w-2.5" /> {exposureParts.fnum}
</span>
{/if}
{#if exposureParts.exp}
<span class="flex items-center gap-0.5">
<Timer class="h-2.5 w-2.5" /> {exposureParts.exp}
</span>
{/if}
{#if exposureParts.iso}<span>{exposureParts.iso}</span>{/if}
{#if exposureParts.focal}<span>{exposureParts.focal}</span>{/if}
</dd>
{/if}
<dt class="text-muted-foreground">Type</dt>
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
<dt class="text-muted-foreground">Hash</dt>
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}</dd>
<dt class="text-muted-foreground">Indexed</dt>
<dd class="text-foreground/80">{(photo.IndexedAt ?? '').slice(0, 10) || '—'}</dd>
</dl>
</details>
{#if patchMutation.isPending || renaming}
<div class="text-[10px] text-muted-foreground">Saving</div>
{/if}
</aside>