forked from Goutam/lynkeduppro-crm
840 lines
29 KiB
TypeScript
840 lines
29 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
|
||
import { sourceLabel } from '../lib/classify';
|
||
import { editFilterCss, editTransformCss } from '../lib/edits';
|
||
import { formatBytes, formatDate, formatDuration, formatTime } from '../lib/format';
|
||
import { blobToWavBase64, startRecording, wavBase64ToBlob, type Recorder } from '../lib/audioCapture';
|
||
import { Icon } from '../icons';
|
||
import { useAIProvider } from './aiContext';
|
||
import { useGallery, useGalleryStoreApi } from '../store/context';
|
||
import type { GalleryUser, GeoLocation, MediaItem } from '../types';
|
||
|
||
export function InfoPanel() {
|
||
const api = useGalleryStoreApi();
|
||
const open = useGallery((s) => s.infoOpen);
|
||
const media = useGallery((s) => s.media);
|
||
const lightboxId = useGallery((s) => s.lightboxId);
|
||
const selection = useGallery((s) => s.selection);
|
||
|
||
if (!open) return null;
|
||
|
||
// Target: the lightbox item, else the single selected item.
|
||
const targetId = lightboxId ?? (selection.size === 1 ? [...selection][0] : undefined);
|
||
const item = targetId ? (media.find((m) => m.id === targetId) ?? null) : null;
|
||
|
||
const ext = item?.name.includes('.') ? item.name.split('.').pop()!.toUpperCase() : '—';
|
||
const mp = item ? ((item.width * item.height) / 1_000_000).toFixed(1) : '0';
|
||
|
||
return (
|
||
<aside className="apg-info" aria-label="Info">
|
||
<div className="apg-info__head">
|
||
<span style={{ fontWeight: 700, fontSize: 15 }}>Info</span>
|
||
<button
|
||
type="button"
|
||
className="apg-iconbtn"
|
||
aria-label="Close info"
|
||
onClick={() => api.getState().setInfoOpen(false)}
|
||
>
|
||
<Icon name="close" size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
{!item ? (
|
||
<div className="apg-info__empty">Select a single photo to see its details.</div>
|
||
) : (
|
||
<div className="apg-info__body">
|
||
{item.kind === 'video' ? (
|
||
// Videos get a real, playable preview at the top of the panel — the same
|
||
// affordance images have, not a frozen poster frame.
|
||
<video
|
||
className="apg-info__thumb apg-info__thumb--video"
|
||
src={item.src}
|
||
poster={item.poster ?? item.thumbnail}
|
||
controls
|
||
playsInline
|
||
preload="metadata"
|
||
/>
|
||
) : (
|
||
<img className="apg-info__thumb" src={item.thumbnail ?? item.src} alt={item.name} />
|
||
)}
|
||
<div className="apg-info__name">{item.name}</div>
|
||
<div className="apg-info__sub">
|
||
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
|
||
</div>
|
||
|
||
{/* Editable caption (single line) + note (multi-line) — persisted via updateMedia. */}
|
||
<EditableField
|
||
key={`cap-${item.id}`}
|
||
label="Caption"
|
||
value={item.caption}
|
||
placeholder="Add a caption…"
|
||
onSave={(v) => api.getState().updateMedia(item.id, { caption: v })}
|
||
/>
|
||
<EditableField
|
||
key={`note-${item.id}`}
|
||
label="Note"
|
||
value={item.note}
|
||
placeholder="Add a note…"
|
||
multiline
|
||
onSave={(v) => api.getState().updateMedia(item.id, { note: v })}
|
||
/>
|
||
|
||
{item.uploadedBy ? <UploadedBy user={item.uploadedBy} /> : null}
|
||
|
||
{item.kind === 'image' && !item.analyzedAt ? (
|
||
<div className="apg-info__analyzing">
|
||
<span className="apg-info__spinner" aria-hidden />
|
||
Analyzing image…
|
||
</div>
|
||
) : null}
|
||
|
||
<Row label="Kind" value={item.kind === 'video' ? 'Video' : 'Photo'} />
|
||
<Row label="Source" value={sourceLabel(item.source)} />
|
||
<Row label="Format" value={`${item.mime} (${ext})`} />
|
||
<Row label="Dimensions" value={`${item.width} × ${item.height} · ${mp} MP`} />
|
||
<Row label="Size" value={formatBytes(item.bytes)} />
|
||
{item.duration ? <Row label="Duration" value={formatDuration(item.duration)} /> : null}
|
||
{item.exif?.Make || item.exif?.Model ? (
|
||
<Row label="Camera" value={`${item.exif.Make ?? ''} ${item.exif.Model ?? ''}`.trim()} />
|
||
) : null}
|
||
{item.exif?.FNumber ? (
|
||
<Row
|
||
label="Exposure"
|
||
value={[
|
||
item.exif.FNumber ? `ƒ${item.exif.FNumber}` : '',
|
||
item.exif.ISO ? `ISO ${item.exif.ISO}` : '',
|
||
item.exif.FocalLength ? `${item.exif.FocalLength}mm` : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ')}
|
||
/>
|
||
) : null}
|
||
{item.exif?.LensModel ? <Row label="Lens" value={String(item.exif.LensModel)} /> : null}
|
||
{item.exif?.Orientation ? (
|
||
<Row label="Orientation" value={orientationLabel(item.exif.Orientation)} />
|
||
) : null}
|
||
|
||
{item.objectLabels.length ? (
|
||
<Chips
|
||
label="Objects"
|
||
items={item.objectLabels}
|
||
onClick={(o) => {
|
||
const s = api.getState();
|
||
s.setObjectFocus(o);
|
||
s.closeLightbox();
|
||
s.setInfoOpen(false);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{item.tags.length ? (
|
||
<Chips
|
||
label="Tags"
|
||
items={item.tags}
|
||
onClick={(t) => {
|
||
const s = api.getState();
|
||
s.setTagFocus(t);
|
||
s.closeLightbox();
|
||
s.setInfoOpen(false);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{item.location ? (
|
||
<>
|
||
<Row
|
||
label="Location"
|
||
value={
|
||
item.location.formatted ??
|
||
item.location.place ??
|
||
`${item.location.lat.toFixed(4)}, ${item.location.lng.toFixed(4)}`
|
||
}
|
||
/>
|
||
<MiniMap
|
||
lat={item.location.lat}
|
||
lng={item.location.lng}
|
||
onOpen={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
||
/>
|
||
<AddressBlock
|
||
item={item}
|
||
onOpenMap={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
||
/>
|
||
<div style={{ fontSize: 11, color: 'var(--apg-text-tertiary)', marginTop: 4 }}>
|
||
Click the map or address to open it in full.
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
<Versions item={item} />
|
||
<Comments item={item} />
|
||
</div>
|
||
)}
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
/** Version history + audit log for a photo/video (v1 = original). */
|
||
function Versions({ item }: { item: MediaItem }) {
|
||
const api = useGalleryStoreApi();
|
||
const [openId, setOpenId] = useState<string | null>(null);
|
||
const versions = item.versions ?? [];
|
||
// Newest first; the last entry is the current one.
|
||
const ordered = [...versions].reverse();
|
||
const currentId = versions.length ? versions[versions.length - 1]!.id : null;
|
||
|
||
return (
|
||
<div className="apg-info__section">
|
||
<div className="apg-info__section-head">
|
||
<Icon name="clock" size={15} />
|
||
<span>Version history</span>
|
||
<span className="apg-info__count">{versions.length || 1}</span>
|
||
</div>
|
||
{versions.length === 0 ? (
|
||
<div className="apg-info__hint">
|
||
Only the original exists. Edits create new versions — the original is never overwritten.
|
||
</div>
|
||
) : (
|
||
<ol className="apg-versions">
|
||
{ordered.map((v) => {
|
||
const isOpen = openId === v.id;
|
||
const isCurrent = v.id === currentId;
|
||
const isOriginal = v.version === 1;
|
||
return (
|
||
<li key={v.id} className="apg-version">
|
||
<button
|
||
type="button"
|
||
className={`apg-version__row${isCurrent ? ' is-current' : ''}`}
|
||
onClick={() => setOpenId(isOpen ? null : v.id)}
|
||
aria-expanded={isOpen}
|
||
>
|
||
<span className="apg-version__thumb-wrap">
|
||
<img
|
||
className="apg-version__thumb"
|
||
src={v.thumbnail ?? v.src ?? item.thumbnail ?? item.src}
|
||
alt=""
|
||
style={{
|
||
filter: editFilterCss(v.edits) || undefined,
|
||
transform: editTransformCss(v.edits) || undefined,
|
||
}}
|
||
/>
|
||
</span>
|
||
<span className="apg-version__meta">
|
||
<span className="apg-version__title">
|
||
{isOriginal ? 'Original' : `Version ${v.version}`}
|
||
{isCurrent ? <span className="apg-version__badge">Current</span> : null}
|
||
</span>
|
||
<span className="apg-version__time">
|
||
{formatDate(v.createdAt)} · {formatTime(v.createdAt)}
|
||
</span>
|
||
{v.author ? (
|
||
<span className="apg-version__author">
|
||
{v.authorAvatar ? (
|
||
<img className="apg-version__author-avatar" src={v.authorAvatar} alt="" />
|
||
) : (
|
||
<span className="apg-version__author-avatar apg-version__author-avatar--initial" aria-hidden>
|
||
{v.author.slice(0, 1).toUpperCase()}
|
||
</span>
|
||
)}
|
||
<span className="apg-version__author-name">{v.author}</span>
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={14} />
|
||
</button>
|
||
{isOpen ? (
|
||
<div className="apg-version__detail">
|
||
<div className="apg-version__changes-label">What changed</div>
|
||
<ul className="apg-version__changes">
|
||
{v.changes.map((c, i) => (
|
||
<li key={i}>{c}</li>
|
||
))}
|
||
</ul>
|
||
{!isCurrent ? (
|
||
<button
|
||
type="button"
|
||
className="apg-btn apg-btn--small"
|
||
onClick={() => api.getState().restoreVersion(item.id, v.id)}
|
||
>
|
||
Restore this version
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Threaded comments on a photo/video. */
|
||
function Comments({ item }: { item: MediaItem }) {
|
||
const api = useGalleryStoreApi();
|
||
const currentUser = useGallery((s) => s.config.currentUser);
|
||
const comments = item.comments ?? [];
|
||
const [text, setText] = useState('');
|
||
// Without a host-provided user we keep the legacy free-text author field
|
||
// (remembered in localStorage). With one, identity comes from the host.
|
||
const [author, setAuthor] = useState(() => {
|
||
if (typeof window === 'undefined') return 'You';
|
||
return window.localStorage.getItem('apg:comment-author') || 'You';
|
||
});
|
||
|
||
const provider = useAIProvider();
|
||
const canVoice = Boolean(provider?.transcribeAudio);
|
||
const canDenoise = Boolean(provider?.denoiseAudio);
|
||
const [recording, setRecording] = useState(false);
|
||
const [denoise, setDenoise] = useState(false);
|
||
const [voiceStatus, setVoiceStatus] = useState<string | null>(null);
|
||
const recorderRef = useRef<Recorder | null>(null);
|
||
|
||
const startVoice = async () => {
|
||
setVoiceStatus(null);
|
||
try {
|
||
recorderRef.current = await startRecording();
|
||
setRecording(true);
|
||
} catch (e) {
|
||
setVoiceStatus(e instanceof Error ? e.message : 'Microphone unavailable.');
|
||
}
|
||
};
|
||
|
||
const stopVoice = async () => {
|
||
const rec = recorderRef.current;
|
||
recorderRef.current = null;
|
||
setRecording(false);
|
||
if (!rec || !provider?.transcribeAudio) return;
|
||
try {
|
||
const blob = await rec.stop();
|
||
let wav16: string;
|
||
if (denoise && provider.denoiseAudio) {
|
||
setVoiceStatus('Reducing noise…');
|
||
const wav48 = await blobToWavBase64(blob, 48000);
|
||
const cleaned = await provider.denoiseAudio(wav48);
|
||
wav16 = await blobToWavBase64(wavBase64ToBlob(cleaned), 16000);
|
||
} else {
|
||
wav16 = await blobToWavBase64(blob, 16000);
|
||
}
|
||
setVoiceStatus('Transcribing…');
|
||
const spoken = (await provider.transcribeAudio(wav16)).trim();
|
||
if (spoken) setText((prev) => (prev ? `${prev} ${spoken}` : spoken));
|
||
setVoiceStatus(null);
|
||
} catch (e) {
|
||
setVoiceStatus(e instanceof Error ? e.message : 'Could not transcribe audio.');
|
||
}
|
||
};
|
||
|
||
const post = () => {
|
||
const t = text.trim();
|
||
if (!t) return;
|
||
if (currentUser) {
|
||
// The store stamps authorId/author/avatar from config.currentUser.
|
||
api.getState().addComment(item.id, t);
|
||
setText('');
|
||
return;
|
||
}
|
||
const name = author.trim() || 'You';
|
||
try {
|
||
window.localStorage.setItem('apg:comment-author', name);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
api.getState().addComment(item.id, t, name);
|
||
setText('');
|
||
};
|
||
|
||
return (
|
||
<div className="apg-info__section">
|
||
<div className="apg-info__section-head">
|
||
<Icon name="chat" size={15} />
|
||
<span>Comments</span>
|
||
<span className="apg-info__count">{comments.length}</span>
|
||
</div>
|
||
|
||
{comments.length === 0 ? (
|
||
<div className="apg-info__hint">No comments yet. Start the conversation below.</div>
|
||
) : (
|
||
<ul className="apg-comments">
|
||
{comments.map((c) => {
|
||
// With a configured user, only their OWN comments are deletable
|
||
// (mirrors the server rule enforced on authorId).
|
||
const canDelete = !currentUser || c.authorId === currentUser.id;
|
||
return (
|
||
<li key={c.id} className="apg-comment">
|
||
{c.authorAvatar ? (
|
||
<img className="apg-comment__avatar-img" src={c.authorAvatar} alt="" />
|
||
) : (
|
||
<div className="apg-comment__avatar" aria-hidden>
|
||
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
|
||
</div>
|
||
)}
|
||
<div className="apg-comment__body">
|
||
<div className="apg-comment__meta">
|
||
<span className="apg-comment__author">{c.author ?? 'You'}</span>
|
||
<span className="apg-comment__time">
|
||
{formatDate(c.createdAt)} · {formatTime(c.createdAt)}
|
||
</span>
|
||
</div>
|
||
<div className="apg-comment__text">{c.text}</div>
|
||
</div>
|
||
{canDelete ? (
|
||
<button
|
||
type="button"
|
||
className="apg-comment__delete"
|
||
aria-label="Delete comment"
|
||
onClick={() => api.getState().deleteComment(item.id, c.id)}
|
||
>
|
||
<Icon name="close" size={13} />
|
||
</button>
|
||
) : null}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
|
||
<div className="apg-comment-form">
|
||
{currentUser ? (
|
||
<div className="apg-comment-form__identity">
|
||
{currentUser.avatarUrl ? (
|
||
<img className="apg-comment__avatar-img" src={currentUser.avatarUrl} alt="" />
|
||
) : (
|
||
<div className="apg-comment__avatar" aria-hidden>
|
||
{(currentUser.name || '?').slice(0, 1).toUpperCase()}
|
||
</div>
|
||
)}
|
||
<span className="apg-comment__author">{currentUser.name}</span>
|
||
</div>
|
||
) : (
|
||
<input
|
||
className="apg-comment-form__author"
|
||
value={author}
|
||
onChange={(e) => setAuthor(e.target.value)}
|
||
placeholder="Your name"
|
||
aria-label="Your name"
|
||
maxLength={40}
|
||
/>
|
||
)}
|
||
<textarea
|
||
className="apg-comment-form__input"
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post();
|
||
}}
|
||
placeholder="Add a comment…"
|
||
aria-label="Add a comment"
|
||
rows={2}
|
||
maxLength={2000}
|
||
/>
|
||
{canVoice ? (
|
||
<div className="apg-voice">
|
||
<button
|
||
type="button"
|
||
className={`apg-btn apg-btn--small apg-voice__mic${recording ? ' apg-voice__mic--rec' : ''}`}
|
||
onClick={recording ? stopVoice : startVoice}
|
||
aria-label={recording ? 'Stop recording' : 'Record a voice comment'}
|
||
title={recording ? 'Stop & transcribe' : 'Speak your comment'}
|
||
>
|
||
<Icon name={recording ? 'check' : 'mic'} size={14} />
|
||
{recording ? 'Stop' : 'Speak'}
|
||
</button>
|
||
{canDenoise ? (
|
||
<label
|
||
className="apg-voice__denoise"
|
||
title="Clean up background noise before transcribing"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={denoise}
|
||
onChange={(e) => setDenoise(e.target.checked)}
|
||
/>
|
||
Reduce noise
|
||
</label>
|
||
) : null}
|
||
<span className="apg-voice__status" aria-live="polite">
|
||
{recording ? '● Listening…' : (voiceStatus ?? '')}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="apg-btn apg-btn--small apg-comment-form__post"
|
||
onClick={post}
|
||
disabled={!text.trim()}
|
||
>
|
||
Post
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Row({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="apg-info__row">
|
||
<span className="apg-info__row-label">{label}</span>
|
||
<span className="apg-info__row-value">{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// EXIF Orientation codes 1–8 → human text (construction shots are often sideways).
|
||
const ORIENTATION_LABELS: Record<number, string> = {
|
||
1: 'Normal',
|
||
2: 'Mirrored horizontal',
|
||
3: 'Rotated 180°',
|
||
4: 'Mirrored vertical',
|
||
5: 'Mirrored + 90° CCW',
|
||
6: 'Rotated 90° CW',
|
||
7: 'Mirrored + 90° CW',
|
||
8: 'Rotated 90° CCW',
|
||
};
|
||
function orientationLabel(v: string | number): string {
|
||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||
return ORIENTATION_LABELS[n] ?? String(v);
|
||
}
|
||
|
||
function Chips({
|
||
label,
|
||
items,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
items: string[];
|
||
onClick?: (item: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="apg-info__chips">
|
||
<span className="apg-info__row-label">{label}</span>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||
{items.map((t) => (
|
||
<button
|
||
key={t}
|
||
type="button"
|
||
className="apg-chip"
|
||
onClick={onClick ? () => onClick(t) : undefined}
|
||
style={{ cursor: onClick ? 'pointer' : 'default', textTransform: 'capitalize' }}
|
||
>
|
||
{t}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Cross-instance guards so re-opening a panel (or a second panel) never double-
|
||
// fetches the same item: ids currently in flight, and ids that already failed
|
||
// (so the auto-fetch doesn't loop — the user must press Retry).
|
||
const geocodeInFlight = new Set<string>();
|
||
const geocodeFailed = new Set<string>();
|
||
|
||
/** Shape of Nominatim's `address` object (addressdetails=1) — all fields optional. */
|
||
interface NominatimAddress {
|
||
road?: string;
|
||
neighbourhood?: string;
|
||
suburb?: string;
|
||
city?: string;
|
||
town?: string;
|
||
village?: string;
|
||
county?: string;
|
||
state?: string;
|
||
postcode?: string;
|
||
country?: string;
|
||
country_code?: string;
|
||
}
|
||
|
||
/**
|
||
* Reverse-geocodes an item's GPS coords to a full postal address via free
|
||
* OpenStreetMap Nominatim, then PERSISTS the parsed fields onto the item
|
||
* (`updateMedia`) so it saves through the adapter and survives a reload. Runs
|
||
* automatically the first time a located item's Info panel opens (only when
|
||
* `location.formatted` isn't already cached); manual Retry on error.
|
||
*
|
||
* NOTE: a hardened host CSP must allow `nominatim.openstreetmap.org` in
|
||
* `connect-src` for this fetch to succeed.
|
||
*/
|
||
function AddressBlock({ item, onOpenMap }: { item: MediaItem; onOpenMap: () => void }) {
|
||
const api = useGalleryStoreApi();
|
||
const loc = item.location!;
|
||
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
|
||
|
||
const lookup = useCallback(async () => {
|
||
// One request per id at a time (etiquette + no double-fetch across panels).
|
||
if (geocodeInFlight.has(item.id)) return;
|
||
geocodeInFlight.add(item.id);
|
||
setStatus('loading');
|
||
try {
|
||
const res = await fetch(
|
||
`https://nominatim.openstreetmap.org/reverse?lat=${loc.lat}&lon=${loc.lng}&format=json&zoom=18&addressdetails=1`,
|
||
{ headers: { Accept: 'application/json' } },
|
||
);
|
||
const data = (await res.json()) as { display_name?: string; address?: NominatimAddress };
|
||
const a = data.address ?? {};
|
||
if (data.display_name) {
|
||
const parsed: Partial<GeoLocation> = {
|
||
road: a.road,
|
||
neighbourhood: a.neighbourhood,
|
||
suburb: a.suburb,
|
||
city: a.city ?? a.town ?? a.village,
|
||
county: a.county,
|
||
state: a.state,
|
||
postcode: a.postcode,
|
||
country: a.country,
|
||
countryCode: a.country_code ? a.country_code.toUpperCase() : undefined,
|
||
formatted: data.display_name,
|
||
};
|
||
// Persist through the adapter (→ be-crm) so it survives reload + isn't refetched.
|
||
api.getState().updateMedia(item.id, { location: { ...loc, ...parsed } });
|
||
geocodeFailed.delete(item.id);
|
||
setStatus('idle');
|
||
} else {
|
||
geocodeFailed.add(item.id);
|
||
setStatus('error');
|
||
}
|
||
} catch {
|
||
geocodeFailed.add(item.id);
|
||
setStatus('error');
|
||
} finally {
|
||
geocodeInFlight.delete(item.id);
|
||
}
|
||
}, [api, item.id, loc]);
|
||
|
||
// Auto-fetch once when a located item without a cached address opens. The
|
||
// `!loc.formatted` guard (and the failed-set) prevent a re-fetch loop: once
|
||
// persisted, the item re-renders WITH `formatted` and this no-ops.
|
||
useEffect(() => {
|
||
if (!loc.formatted && !geocodeFailed.has(item.id) && !geocodeInFlight.has(item.id)) {
|
||
void lookup();
|
||
}
|
||
}, [item.id, loc.formatted, lookup]);
|
||
|
||
const retry = () => {
|
||
geocodeFailed.delete(item.id);
|
||
void lookup();
|
||
};
|
||
|
||
if (loc.formatted) {
|
||
const cells: Array<[string, string | undefined]> = [
|
||
['City', loc.city],
|
||
['State', loc.state],
|
||
['Postcode', loc.postcode],
|
||
['Country', loc.country],
|
||
];
|
||
return (
|
||
<div className="apg-address">
|
||
<button type="button" className="apg-address__line" onClick={onOpenMap} title="Open in Map">
|
||
<Icon name="map" size={14} />
|
||
<span>{loc.formatted}</span>
|
||
</button>
|
||
<dl className="apg-address__grid">
|
||
{cells
|
||
.filter(([, v]) => Boolean(v))
|
||
.map(([k, v]) => (
|
||
<div key={k} className="apg-address__cell">
|
||
<dt>{k}</dt>
|
||
<dd>{v}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
<div className="apg-info__coords">
|
||
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="apg-address">
|
||
<div className="apg-info__coords">
|
||
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
|
||
</div>
|
||
{status === 'loading' ? (
|
||
<div className="apg-info__hint" aria-live="polite">
|
||
Looking up address…
|
||
</div>
|
||
) : status === 'error' ? (
|
||
<button type="button" className="apg-btn apg-btn--small" onClick={retry} style={{ marginTop: 6 }}>
|
||
Retry address
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** "Uploaded by" identity block: avatar + name + (muted) email. */
|
||
function UploadedBy({ user }: { user: GalleryUser }) {
|
||
return (
|
||
<div className="apg-info__uploader">
|
||
<span className="apg-info__uploader-label">Uploaded by</span>
|
||
<div className="apg-info__uploader-row">
|
||
{user.avatarUrl ? (
|
||
<img className="apg-info__uploader-avatar" src={user.avatarUrl} alt="" />
|
||
) : (
|
||
<div className="apg-info__uploader-avatar apg-info__uploader-avatar--initial" aria-hidden>
|
||
{(user.name || '?').slice(0, 1).toUpperCase()}
|
||
</div>
|
||
)}
|
||
<div className="apg-info__uploader-meta">
|
||
<span className="apg-info__uploader-name">{user.name}</span>
|
||
{user.email ? <span className="apg-info__uploader-email">{user.email}</span> : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Click-to-edit metadata field (caption / note). Saves on blur or Enter (Cmd/Ctrl+
|
||
* Enter for the multi-line note) via the supplied `onSave`, which persists through
|
||
* `updateMedia`. Shows an unobtrusive "edited" hint once a value is present.
|
||
*/
|
||
function EditableField({
|
||
label,
|
||
value,
|
||
placeholder,
|
||
multiline,
|
||
onSave,
|
||
}: {
|
||
label: string;
|
||
value?: string;
|
||
placeholder: string;
|
||
multiline?: boolean;
|
||
onSave: (value: string) => void;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [draft, setDraft] = useState(value ?? '');
|
||
|
||
// Re-sync the draft when the underlying value changes and we're not editing.
|
||
useEffect(() => {
|
||
if (!editing) setDraft(value ?? '');
|
||
}, [value, editing]);
|
||
|
||
const commit = () => {
|
||
setEditing(false);
|
||
const next = draft.trim();
|
||
if (next !== (value ?? '').trim()) onSave(next);
|
||
};
|
||
|
||
const hasValue = Boolean((value ?? '').trim());
|
||
|
||
if (editing) {
|
||
const shared = {
|
||
className: 'apg-editable__input',
|
||
value: draft,
|
||
autoFocus: true,
|
||
placeholder,
|
||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setDraft(e.target.value),
|
||
onBlur: commit,
|
||
};
|
||
return (
|
||
<div className="apg-editable apg-editable--editing">
|
||
<span className="apg-editable__label">{label}</span>
|
||
{multiline ? (
|
||
<textarea
|
||
{...shared}
|
||
rows={3}
|
||
maxLength={4000}
|
||
onKeyDown={(e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') commit();
|
||
else if (e.key === 'Escape') {
|
||
setDraft(value ?? '');
|
||
setEditing(false);
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<input
|
||
{...shared}
|
||
maxLength={280}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') commit();
|
||
else if (e.key === 'Escape') {
|
||
setDraft(value ?? '');
|
||
setEditing(false);
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button type="button" className="apg-editable" onClick={() => setEditing(true)}>
|
||
<span className="apg-editable__label">
|
||
{label}
|
||
{hasValue ? <span className="apg-editable__edited">edited</span> : null}
|
||
<Icon name="pencil" size={12} />
|
||
</span>
|
||
<span className={['apg-editable__value', hasValue ? '' : 'apg-editable__value--empty'].filter(Boolean).join(' ')}>
|
||
{hasValue ? value : placeholder}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** Small non-interactive Leaflet map for the location preview (click to open full Map). */
|
||
function MiniMap({ lat, lng, onOpen }: { lat: number; lng: number; onOpen?: () => void }) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<any>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
void import('leaflet').then((mod) => {
|
||
const L = (mod as any).default ?? mod;
|
||
if (cancelled || !ref.current || mapRef.current) return;
|
||
const map = L.map(ref.current, {
|
||
zoomControl: false,
|
||
attributionControl: false,
|
||
dragging: false,
|
||
scrollWheelZoom: false,
|
||
doubleClickZoom: false,
|
||
boxZoom: false,
|
||
keyboard: false,
|
||
tap: false,
|
||
}).setView([lat, lng], 11);
|
||
mapRef.current = map;
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
||
L.circleMarker([lat, lng], {
|
||
radius: 7,
|
||
color: '#fff',
|
||
weight: 2,
|
||
fillColor: '#0a84ff',
|
||
fillOpacity: 1,
|
||
}).addTo(map);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
if (mapRef.current) {
|
||
mapRef.current.remove();
|
||
mapRef.current = null;
|
||
}
|
||
};
|
||
}, [lat, lng]);
|
||
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
<div ref={ref} className="apg-info__map" />
|
||
{onOpen ? (
|
||
<button
|
||
type="button"
|
||
aria-label="Open in Map"
|
||
onClick={onOpen}
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
zIndex: 500,
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|