'use client'; /** * Admin → Proofing: client links, their selections and the export. * * One card per link. Creating a link picks a client name and an album — any * Immich album, published or not — plus optional expiry and download rights. * A card expands into the selected photos and the export buttons. */ import { useCallback, useEffect, useState } from 'react'; import PageHeader from './PageHeader'; import * as Icons from './Icons'; import { reportIfSessionExpired } from './sessionExpiry'; import type { AdminProofPick, AdminProofSession } from '@/lib/admin/proofing-view'; interface AlbumOption { id: string; albumName: string; assetCount: number; } type Download = AdminProofSession['download']; const STATE_LABEL: Record = { open: 'Open', submitted: 'Submitted', expired: 'Expired', }; const DOWNLOAD_LABEL: Record = { none: 'No downloads', selection: 'Selection as ZIP', album: 'Whole album as ZIP', }; function formatDate(iso: string | null): string { if (!iso) return '—'; const date = iso.length === 10 ? new Date(`${iso}T12:00:00`) : new Date(iso); return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } async function api(url: string, init?: RequestInit): Promise { const res = await fetch(url, init); if (reportIfSessionExpired(res)) throw new Error('Session expired'); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as { error?: string }).error || `HTTP ${res.status}`); return data as T; } function CreateForm({ onCreated }: { onCreated: () => void }) { const [albums, setAlbums] = useState(null); const [clientName, setClientName] = useState(''); const [albumId, setAlbumId] = useState(''); const [alias, setAlias] = useState(''); const [expiresOn, setExpiresOn] = useState(''); const [download, setDownload] = useState('none'); const [limit, setLimit] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { api<{ albums: AlbumOption[] }>('/api/admin/albums') .then((data) => setAlbums([...data.albums].sort((a, b) => a.albumName.localeCompare(b.albumName))), ) .catch((err: Error) => setError(err.message)); }, []); const submit = async (e: React.FormEvent) => { e.preventDefault(); setBusy(true); setError(null); try { await api('/api/admin/proofing', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientName, alias: alias.trim() || null, albumId, expiresOn: expiresOn || null, download, downloadLimit: download !== 'none' && limit ? Number(limit) : null, }), }); setClientName(''); setAlias(''); setAlbumId(''); setExpiresOn(''); setDownload('none'); setLimit(''); onCreated(); } catch (err) { setError((err as Error).message); } finally { setBusy(false); } }; return (

New client link

setClientName(e.target.value)} placeholder="Anna & Ben" maxLength={120} required />
setAlias( e.target.value .replace(/\s+/g, '-') .replace(/[^A-Za-z0-9-]/g, ''), ) } placeholder="Client" maxLength={64} />
setExpiresOn(e.target.value)} />
{download !== 'none' && (
setLimit(e.target.value)} placeholder="Unlimited" />
)}
{error &&

{error}

}
); } function SessionCard({ session, expanded, onToggle, onChanged, }: { session: AdminProofSession; expanded: boolean; onToggle: () => void; onChanged: () => void; }) { const [picks, setPicks] = useState(null); const [copied, setCopied] = useState<'link' | 'lightroom' | null>(null); const [error, setError] = useState(null); const linkUrl = typeof window !== 'undefined' ? `${window.location.origin}${session.link}` : ''; useEffect(() => { if (!expanded) return; api<{ picks: AdminProofPick[] }>(`/api/admin/proofing/${session.id}`) .then((data) => setPicks(data.picks)) .catch((err: Error) => setError(err.message)); }, [expanded, session.id, session.selected, session.submittedAt]); const copy = (kind: 'link' | 'lightroom', text: string) => { if (!navigator.clipboard) { window.prompt('Copy:', text); return; } navigator.clipboard.writeText(text).then( () => { setCopied(kind); setTimeout(() => setCopied(null), 2000); }, () => window.prompt('Copy:', text), ); }; const patch = async (body: Record) => { setError(null); try { await api(`/api/admin/proofing/${session.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); onChanged(); } catch (err) { setError((err as Error).message); } }; const remove = async () => { if (!window.confirm(`Delete the link for ${session.clientName}? It stops working at once.`)) { return; } try { await api(`/api/admin/proofing/${session.id}`, { method: 'DELETE' }); onChanged(); } catch (err) { setError((err as Error).message); } }; const copyLightroom = async () => { try { const res = await fetch(`/api/admin/proofing/${session.id}/export?format=lightroom`); if (reportIfSessionExpired(res)) return; if (!res.ok) throw new Error(`HTTP ${res.status}`); copy('lightroom', await res.text()); } catch (err) { setError((err as Error).message); } }; const downloadsText = session.download === 'none' ? DOWNLOAD_LABEL.none : `${DOWNLOAD_LABEL[session.download]} · ${session.downloadsUsed}${ session.downloadLimit !== null ? ` / ${session.downloadLimit}` : '' } used`; return (

{session.clientName} {STATE_LABEL[session.state]}

{session.albumName ?? 'Album not found in Immich'} · {session.selected} {session.albumAssetCount !== null ? ` of ${session.albumAssetCount}` : ''} selected · created {formatDate(session.createdAt)} {session.expiresOn ? ` · valid until ${formatDate(session.expiresOn)}` : ''}

{downloadsText} {session.submittedAt ? ` · submitted ${formatDate(session.submittedAt)}` : ''}

Open
{error &&

{error}

} {expanded && (
CSV TXT {session.submittedAt && ( )} {session.download !== 'none' && session.downloadsUsed > 0 && ( )}
{picks === null ? (
) : picks.length === 0 ? (

No photos selected yet.

) : (
    {picks.map((pick) => (
  • {/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnails, served by our own proxy */} #{pick.position} · {pick.fileName}
  • ))}
)}
)}
); } export default function ProofingView() { const [sessions, setSessions] = useState(null); const [webhookConfigured, setWebhookConfigured] = useState(true); const [error, setError] = useState(null); const [expanded, setExpanded] = useState(null); const load = useCallback(async () => { try { const data = await api<{ sessions: AdminProofSession[]; webhookConfigured: boolean }>( '/api/admin/proofing', ); setSessions(data.sessions); setWebhookConfigured(data.webhookConfigured); setError(null); } catch (err) { setError((err as Error).message); } }, []); useEffect(() => { // The webhook's admin link lands here with ?session=: open that card. const wanted = new URLSearchParams(window.location.search).get('session'); // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time read of the URL on mount if (wanted) setExpanded(wanted); void load(); }, [load]); return (
void load()}> Refresh } /> {!webhookConfigured && (

Tip: set PROOFING_WEBHOOK_URL to be notified (Discord, Slack, Gotify, n8n, …) when a client submits.

)} void load()} /> {error &&

{error}

} {sessions === null && !error ? (
) : sessions && sessions.length === 0 ? (

No client links yet.

) : ( sessions?.map((session) => ( setExpanded((cur) => (cur === session.id ? null : session.id))} onChanged={() => void load()} /> )) )}
); }