'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 ( ); } /** Version history + audit log for a photo/video (v1 = original). */ function Versions({ item }: { item: MediaItem }) { const api = useGalleryStoreApi(); const [openId, setOpenId] = useState(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 (
Version history {versions.length || 1}
{versions.length === 0 ? (
Only the original exists. Edits create new versions — the original is never overwritten.
) : (
    {ordered.map((v) => { const isOpen = openId === v.id; const isCurrent = v.id === currentId; const isOriginal = v.version === 1; return (
  1. {isOpen ? (
    What changed
      {v.changes.map((c, i) => (
    • {c}
    • ))}
    {!isCurrent ? ( ) : null}
    ) : null}
  2. ); })}
)}
); } /** 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(null); const recorderRef = useRef(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 (
Comments {comments.length}
{comments.length === 0 ? (
No comments yet. Start the conversation below.
) : (
    {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 (
  • {c.authorAvatar ? ( ) : (
    {(c.author ?? 'You').slice(0, 1).toUpperCase()}
    )}
    {c.author ?? 'You'} {formatDate(c.createdAt)} · {formatTime(c.createdAt)}
    {c.text}
    {canDelete ? ( ) : null}
  • ); })}
)}
{currentUser ? (
{currentUser.avatarUrl ? ( ) : (
{(currentUser.name || '?').slice(0, 1).toUpperCase()}
)} {currentUser.name}
) : ( setAuthor(e.target.value)} placeholder="Your name" aria-label="Your name" maxLength={40} /> )}