feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
614
web/src/lib/components/sidebar/RightSidebar.svelte
Normal file
614
web/src/lib/components/sidebar/RightSidebar.svelte
Normal file
@@ -0,0 +1,614 @@
|
||||
<!--
|
||||
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,
|
||||
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 });
|
||||
}
|
||||
|
||||
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
||||
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
||||
];
|
||||
|
||||
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'}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
Reference in New Issue
Block a user