- 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>
767 lines
27 KiB
Svelte
767 lines
27 KiB
Svelte
<!--
|
||
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>
|