<!--
	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 { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
	import { toast } from 'svelte-sonner';
	import {
		Aperture,
		Calendar,
		Camera,
		ExternalLink,
		Heart,
		ImageIcon,
		Loader2,
		Lock,
		MapPin,
		Star,
		Tag,
		Timer,
		X
	} from 'lucide-svelte';
	import {
		buildTakenAtPatch,
		getAllMarks,
		likePhoto,
		renameOnDisk,
		setMark,
		unlikePhoto,
		updatePhoto,
		type PhotoMark,
		type PhotoMarksMap,
		type UpdatePhotoBody
	} from '$lib/services/photoprism';
	import { isAuthenticated } from '$lib/stores/session.svelte';
	import { push as pushUndo } from '$lib/stores/undo.svelte';
	import { thumbUrl } from '$lib/stores/session.svelte';
	import { primaryFile, type PpPhoto } from '$lib/types/photoprism';

	interface Props {
		photo: PpPhoto;
	}
	let { photo }: Props = $props();

	const qc = useQueryClient();

	let filename = $state('');
	let caption = $state('');
	let takenAt = $state('');
	let lat = $state('');
	let lng = $state('');
	let country = $state('');
	let keywords = $state<string[]>([]);
	let keywordDraft = $state('');
	let subject = $state('');
	let artist = $state('');
	let copyright = $state('');
	let license = $state('');
	let notes = $state('');
	let renaming = $state(false);

	$effect(() => {
		const pf = primaryFile(photo);
		filename = pf.Name ?? '';
		caption = photo.Caption ?? '';
		takenAt = (photo.TakenAt ?? '').slice(0, 16);
		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);
		subject = det.Subject ?? '';
		artist = det.Artist ?? '';
		copyright = det.Copyright ?? '';
		license = det.License ?? '';
		notes = det.Notes ?? '';
	});

	const patchMutation = createMutation(() => ({
		mutationFn: (patch: UpdatePhotoBody) => {
			const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
			return updatePhoto(fresh, patch);
		},
		onSuccess: (data) => {
			qc.setQueryData(['photo', data.UID], data);
			void qc.invalidateQueries({ queryKey: ['photos'] });
		},
		onError: (err) =>
			toast.error(err instanceof Error ? err.message : 'Save failed')
	}));

	const favoriteMutation = createMutation(() => ({
		mutationFn: async (next: boolean) => {
			if (next) await likePhoto(photo.UID);
			else await unlikePhoto(photo.UID);
			return next;
		},
		onSuccess: (next) => {
			void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
			void qc.invalidateQueries({ queryKey: ['photos'] });
			pushUndo(next ? 'Favorited' : 'Unfavorited', async () => {
				if (next) await unlikePhoto(photo.UID);
				else await likePhoto(photo.UID);
				void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
				void qc.invalidateQueries({ queryKey: ['photos'] });
			});
		}
	}));

	function commit(patch: UpdatePhotoBody) {
		patchMutation.mutate(patch);
	}

	async function commitFilename() {
		const pf = primaryFile(photo);
		const next = filename.trim();
		if (!next || next === pf.Name) 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');
			filename = pf.Name ?? '';
		} finally {
			renaming = false;
		}
	}

	function commitCaption() {
		if (caption === (photo.Caption ?? '')) return;
		commit({ Caption: caption, CaptionSrc: 'manual' });
	}
	function commitTakenAt() {
		if (!takenAt) return;
		const iso = `${takenAt}:00Z`;
		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' | 'Subject' | 'Artist' | 'Copyright' | 'License' | 'Notes';
	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(', '));
	}

	function togglePrivate() {
		const prev = photo.Private ?? false;
		commit({ Private: !prev });
		pushUndo(prev ? 'Made public' : 'Made private', () => {
			commit({ Private: prev });
		});
	}

	// 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);
		try {
			const saved = await setMark(photo.UID, patch);
			patchMarksCache(photo.UID, saved);
		} catch (err) {
			// Rollback on failure.
			patchMarksCache(photo.UID, prev);
			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. Same four-swatch palette as mule-image. */
	function setColor(next: string) {
		const value = currentColor === next ? '' : next;
		if (value === currentColor) return;
		void applyMark({ color: value });
	}

	// Tooltips follow the Lightroom culling convention so the swatches
	// read as actions, not just colors. Red = reject, Yellow = pick,
	// Green = keep, Orange = review-later.
	const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
		{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
		{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
		{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
		{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
	];

	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 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()
				: ''
	);
	const mapsHref = $derived(
		photo.Lat && photo.Lng
			? `https://www.openstreetmap.org/?mlat=${photo.Lat}&mlon=${photo.Lng}&zoom=15`
			: ''
	);

	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 : '';
	}
	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">
	<!-- Header strip — thumb + filename + favorite + private -->
	<div class="flex items-center gap-2">
		<img
			src={thumbUrl(pf.Hash, 'tile_100')}
			alt=""
			class="h-10 w-10 shrink-0 rounded object-cover"
		/>
		<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={filename}
			disabled={renaming}
			onblur={commitFilename}
			onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
			title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
		/>
		<!-- Inline spinner next to the filename so the user sees the rename
		     in flight without having to scan to the bottom of the sidebar. -->
		{#if renaming}
			<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
		{/if}
		<button
			class="rounded p-1 hover:bg-accent disabled:opacity-50"
			class:text-red-500={photo.Favorite}
			class:text-muted-foreground={!photo.Favorite}
			disabled={favoriteMutation.isPending}
			onclick={() => favoriteMutation.mutate(!photo.Favorite)}
			title={photo.Favorite ? 'Remove favorite (F)' : 'Add favorite (F)'}
		>
			<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
		</button>
		<button
			class="rounded p-1 hover:bg-accent disabled:opacity-50"
			class:text-foreground={photo.Private}
			class:text-muted-foreground={!photo.Private}
			disabled={patchMutation.isPending}
			onclick={togglePrivate}
			title={photo.Private ? 'Private' : 'Public'}
		>
			<Lock class="h-3.5 w-3.5" />
		</button>
	</div>

	<!-- Compact info rows -->
	<dl class="space-y-1">
		<!-- Taken at -->
		<div class="flex items-center gap-2">
			<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
			<input
				type="datetime-local"
				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"
				bind:value={takenAt}
				onblur={commitTakenAt}
			/>
		</div>

		<!-- Location -->
		<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 text-muted-foreground">
				{placeLabel || 'No location'}
			</span>
			{#if mapsHref}
				<a
					href={mapsHref}
					target="_blank"
					rel="noopener"
					class="text-muted-foreground hover:text-foreground"
					title="Open in OpenStreetMap"
				>
					<ExternalLink class="h-3 w-3" />
				</a>
			{/if}
		</div>

		<!-- Camera / lens — only render if something to show -->
		{#if cameraStr || lensStr || exposureParts.iso || exposureParts.fnum}
			<div class="flex items-start gap-2">
				<Camera class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
				<div class="min-w-0 flex-1 space-y-0.5 text-muted-foreground">
					{#if cameraStr}<div class="truncate">{cameraStr}</div>{/if}
					{#if lensStr && lensStr !== cameraStr}<div class="truncate">{lensStr}</div>{/if}
					{#if exposureParts.iso || exposureParts.fnum || exposureParts.focal || exposureParts.exp}
						<div class="flex flex-wrap gap-x-2 text-[10px]">
							{#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}
						</div>
					{/if}
				</div>
			</div>
		{/if}
	</dl>

	<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
	     mule-image's nomenclature). -->
	<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>

	<!-- Score + color label — two separate sections. Click a star/swatch to
	     set, click the active one to clear. Sits next to Keywords because
	     these are the per-photo culling marks the user reaches for in the
	     same workflow. Stored on the mule-sidecar; PhotoPrism's PUT can't
	     persist them. -->
	<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}
		</div>
	</div>

	<div class="space-y-1">
		<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
		<div class="flex items-center gap-1" role="group" aria-label="Color label">
			{#each COLOR_SWATCHES as c (c.key)}
				<button
					type="button"
					class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}"
					class:ring-foreground={currentColor === c.key}
					class:ring-transparent={currentColor !== c.key}
					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="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground">
			<Tag class="h-3 w-3" /> 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="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
			>
				<Tag class="h-3 w-3" /> 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}

	<!-- GPS detail (collapsed by default) -->
	<details class="rounded border border-border" open={Boolean(photo.Lat || photo.Lng)}>
		<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>

	<!-- IPTC credits (collapsed unless something set) -->
	<details
		class="rounded border border-border"
		open={Boolean(subject || artist || copyright || license || notes)}
	>
		<summary
			class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
		>
			Credits & notes
		</summary>
		<div class="space-y-1 p-2 pt-1">
			<label class="flex items-center gap-1">
				<span class="w-16 text-[10px] text-muted-foreground">Subject</span>
				<input
					type="text"
					class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
					bind:value={subject}
					onblur={() => commitDetails('Subject', subject)}
				/>
			</label>
			<label class="flex items-center gap-1">
				<span class="w-16 text-[10px] text-muted-foreground">Artist</span>
				<input
					type="text"
					class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
					bind:value={artist}
					onblur={() => commitDetails('Artist', artist)}
				/>
			</label>
			<label class="flex items-center gap-1">
				<span class="w-16 text-[10px] text-muted-foreground">Copyright</span>
				<input
					type="text"
					class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
					bind:value={copyright}
					onblur={() => commitDetails('Copyright', copyright)}
				/>
			</label>
			<label class="flex items-center gap-1">
				<span class="w-16 text-[10px] text-muted-foreground">License</span>
				<input
					type="text"
					class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
					bind:value={license}
					onblur={() => commitDetails('License', license)}
				/>
			</label>
			<label class="flex items-start gap-1">
				<span class="w-16 pt-0.5 text-[10px] text-muted-foreground">Private notes</span>
				<textarea
					rows="2"
					class="min-w-0 flex-1 resize-y rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
					bind:value={notes}
					onblur={() => commitDetails('Notes', notes)}
				></textarea>
			</label>
		</div>
	</details>

	<!-- File (collapsed by default) -->
	<details class="rounded border border-border">
		<summary
			class="cursor-pointer 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>
		</summary>
		<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
			<dt class="text-muted-foreground">Size</dt>
			<dd class="text-foreground/80">{dims} · {sizeStr}</dd>
			<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>
