Files
mule-image/web/src/lib/components/sidebar/RightSidebar.svelte
Claudio ca9f6e6bd2 Review view: cause-grouped UI with actions + suggestions
Builds a dedicated /review route that mirrors /duplicates' chrome:
- /review/+page.svelte mounts Toolbar + ReviewView + RightSidebar
- CauseGroupCard.svelte renders one card per cause with Approve all
  and Archive all bulk actions plus a per-cause suggestion line
- CauseBadges.svelte shows every matching cause as chips on each tile
- services/adapters/review.ts fetches review:true and groups photos
  by primary cause; current taxonomy is low_resolution >
  stripped_exif > implausible_year > non_image_type > quality_other
  (low_resolution ranks first because it's the most actionable signal)

Sidebar gains an opt-in showRelated prop that adds three
RelatedStrip panels (same folder / camera / year) for the
'decide these together' workflow.

LeftSidebar's Review entry switches from a section filter to a
route link so /review picks up the click.

PpPhoto gains the missing Resolution field PhotoPrism actually
returns on list responses.
2026-05-18 09:03:30 +00:00

698 lines
24 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 { 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 { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import RelatedStrip from './RelatedStrip.svelte';
interface Props {
/** When true, append related-photo strips (folder/camera/year) below
* Keywords. Used by the /review route; left off on the timeline. */
showRelated?: boolean;
photo: PpPhoto;
}
let { photo, showRelated = false }: 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>
<!-- Related strips (only the /review route opts in). The three
scopes match the three decisions the user usually makes here:
"did all these come from the same shoot?" (folder), "same
camera, EXIF-stripped together?" (camera), "right year?"
(year). Strips with zero hits collapse silently. -->
{#if showRelated}
<div class="space-y-2 border-t border-border pt-2">
<RelatedStrip
title="Same folder"
q={`path:"${photo.Path ?? ''}"`}
excludeUid={photo.UID}
/>
{#if photo.CameraID && photo.CameraID !== 1}
<RelatedStrip
title="Same camera"
q={`camera:${photo.CameraID}`}
excludeUid={photo.UID}
/>
{/if}
{#if photo.Year}
<RelatedStrip
title="Same year"
q={`year:${photo.Year}`}
excludeUid={photo.UID}
/>
{/if}
</div>
{/if}
<!-- 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. 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>
<!-- IPTC credits. Static default (closed); user's choice persists. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('credits', false)}
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
>
<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 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="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>