import React, { useState, useEffect, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { getHistory, getGenerationPersons, addGenerationPerson, removeGenerationPerson, createHistoryShare, revokeHistoryShare, savePresetFromHistory, listPersons } from '../api/client' import { useAuth } from '../hooks/useAuth' import { X } from 'lucide-react' interface HistoryRow { id: number prompt_id: string source: string user_label?: string overrides: Record seed?: number file_paths?: string[] created_at: string share_token?: string | null share_is_public?: boolean | null share_expires_at?: string | null share_max_views?: number | null share_view_count?: number | null detected_persons?: string[] } /** Debounce a value by `delay` ms. */ function useDebounce(value: T, delay: number): T { const [debounced, setDebounced] = useState(value) useEffect(() => { const t = setTimeout(() => setDebounced(value), delay) return () => clearTimeout(t) }, [value, delay]) return debounced } /** Chip-based multi-person tag input with a filterable combobox dropdown. */ function PersonTagInput({ selected, onChange, allPersons, }: { selected: string[] onChange: (persons: string[]) => void allPersons: Array<{ id: number; name: string }> }) { const [inputVal, setInputVal] = useState('') const [showDropdown, setShowDropdown] = useState(false) const available = allPersons.filter( p => !selected.includes(p.name) && (!inputVal || p.name.toLowerCase().includes(inputVal.toLowerCase())) ) const add = (name: string) => { if (!selected.includes(name)) onChange([...selected, name]) setInputVal('') setShowDropdown(false) } const remove = (name: string) => onChange(selected.filter(p => p !== name)) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && available.length > 0) add(available[0].name) if (e.key === 'Backspace' && !inputVal && selected.length > 0) { remove(selected[selected.length - 1]) } } return (
{selected.length > 0 && (
{selected.map(p => ( {p} ))}
)} {allPersons.length > selected.length && (
setInputVal(e.target.value)} onFocus={() => setShowDropdown(true)} onBlur={() => setTimeout(() => setShowDropdown(false), 150)} onKeyDown={handleKeyDown} placeholder={selected.length === 0 ? 'Filter by person…' : 'Add person…'} className="glass-input w-full text-sm" /> {showDropdown && available.length > 0 && (
{available.map(p => ( ))}
)}
)}
) } /** Return only the selected persons that appear in the row's detected_persons array. */ function getComboKey(row: HistoryRow, selected: string[]): string { const matched = selected.filter(p => row.detected_persons?.includes(p)) return matched.join('|') } interface SectionGroup { key: string; combo: string[]; rows: HistoryRow[] } /** Group rows by their exclusive combination of selected persons (ascending combo size). */ function groupByCombo(rows: HistoryRow[], selected: string[]): SectionGroup[] { const groups = new Map() for (const row of rows) { const key = getComboKey(row, selected) if (!key) continue // matched none of the selected persons if (!groups.has(key)) groups.set(key, []) groups.get(key)!.push(row) } return Array.from(groups.entries()) .map(([key, sRows]) => ({ key, combo: key.split('|'), rows: sRows })) .sort((a, b) => a.combo.length !== b.combo.length ? a.combo.length - b.combo.length : a.key.localeCompare(b.key) ) } export default function HistoryPage() { const [lightbox, setLightbox] = useState(null) const [expandedId, setExpandedId] = useState(null) const [searchInput, setSearchInput] = useState('') const [selectedPersons, setSelectedPersons] = useState([]) const { user, isAdmin } = useAuth() const debouncedQ = useDebounce(searchInput.trim(), 300) const { data: personsData } = useQuery({ queryKey: ['faces', 'persons'], queryFn: listPersons }) const persons = personsData?.persons ?? [] const { data, isLoading } = useQuery({ queryKey: ['history', debouncedQ, selectedPersons], queryFn: () => getHistory(debouncedQ || undefined, selectedPersons.length ? selectedPersons : undefined), refetchInterval: 10_000, }) const rows: HistoryRow[] = ((data?.history ?? []) as unknown as HistoryRow[]) const grouped: SectionGroup[] | null = selectedPersons.length >= 2 ? groupByCombo(rows, selectedPersons) : null const isSearching = searchInput.trim() !== debouncedQ if (isLoading && !searchInput) return
Loading history…
return (

History

{/* Search + person filter */}
setSearchInput(e.target.value)} placeholder="Search by prompt, checkpoint, seed…" className="glass-input w-full pr-8" /> {searchInput && ( )}
{persons.length > 0 && ( )}
{isSearching &&

Searching…

} {isLoading ? (
Loading…
) : rows.length === 0 ? (

{debouncedQ ? `No results for "${debouncedQ}".` : 'No generation history yet.'}

) : grouped ? ( /* Grouped view: selectedPersons.length >= 2 */
{grouped.length === 0 ? (

No results match the selected persons.

) : ( grouped.map(section => (

{section.combo.join(' & ')} {section.rows.length} result(s)

)) )}
) : ( /* Flat view */ )} {/* Lightbox */} {lightbox && (
setLightbox(null)} > preview
)}
) } function HistoryList({ rows, expandedId, setExpandedId, onLightbox, currentUserLabel, isAdmin, }: { rows: HistoryRow[] expandedId: string | null setExpandedId: (id: string | null) => void onLightbox: (url: string) => void currentUserLabel: string | null isAdmin: boolean }) { return ( <> {/* Desktop table */}
{rows.map(row => ( setExpandedId(expandedId === row.prompt_id ? null : row.prompt_id)} > {expandedId === row.prompt_id && ( )} ))}
Time Source User Seed Files
{new Date(row.created_at).toLocaleString()} {row.source} {row.user_label ?? '—'} {row.seed ?? '—'} {(row.file_paths ?? []).length} file(s)
{/* Mobile card list */}
{rows.map(row => { const isExpanded = expandedId === row.prompt_id const dt = new Date(row.created_at) const timeStr = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) const dateStr = dt.toLocaleDateString([], { month: 'short', day: 'numeric' }) return (
{isExpanded && (
)}
) })}
) } function formatExpiry(s: string | null | undefined): string { if (!s) return 'Never' const d = new Date(s) return d < new Date() ? 'Expired' : d.toLocaleString() } function ExpandedRow({ row, onLightbox, currentUserLabel, isAdmin, }: { row: HistoryRow onLightbox: (url: string) => void currentUserLabel: string | null isAdmin: boolean }) { const qc = useQueryClient() const [copied, setCopied] = useState(false) const [showSavePreset, setShowSavePreset] = useState(false) const [presetName, setPresetName] = useState('') const [presetDesc, setPresetDesc] = useState('') const [presetMsg, setPresetMsg] = useState<{ ok: boolean; text: string } | null>(null) const presetNameRef = useRef(null) const [addPersonInput, setAddPersonInput] = useState('') const [showPersonDrop, setShowPersonDrop] = useState(false) const [showSharePanel, setShowSharePanel] = useState(false) const [shareIsPublic, setShareIsPublic] = useState(false) const [shareExpiryHours, setShareExpiryHours] = useState(null) const [shareMaxViews, setShareMaxViews] = useState(null) const { data: imagesData, isLoading } = useQuery({ queryKey: ['history', row.prompt_id, 'images'], queryFn: async () => { const res = await fetch(`/api/history/${row.prompt_id}/images`, { credentials: 'include' }) if (!res.ok) throw new Error(`${res.status}`) return res.json() as Promise<{ images: Array<{ filename: string; data: string | null; mime_type: string }> }> }, }) const { data: personsData } = useQuery({ queryKey: ['history', row.prompt_id, 'persons'], queryFn: () => getGenerationPersons(row.prompt_id), }) const rowPersons = personsData?.persons ?? [] const { data: allPersonsData } = useQuery({ queryKey: ['faces', 'persons'], queryFn: listPersons, enabled: isAdmin, }) const allPersons = allPersonsData?.persons ?? [] const addPersonMut = useMutation({ mutationFn: (name: string) => addGenerationPerson(row.prompt_id, name), onSuccess: () => { qc.invalidateQueries({ queryKey: ['history', row.prompt_id, 'persons'] }) qc.invalidateQueries({ queryKey: ['history'] }) setAddPersonInput('') }, }) const removePersonMut = useMutation({ mutationFn: (personId: number) => removeGenerationPerson(row.prompt_id, personId), onSuccess: () => { qc.invalidateQueries({ queryKey: ['history', row.prompt_id, 'persons'] }) qc.invalidateQueries({ queryKey: ['history'] }) }, }) const personDropOptions = allPersons.filter( p => !rowPersons.some(rp => rp.id === p.id) && (!addPersonInput || p.name.toLowerCase().includes(addPersonInput.toLowerCase())) ) const shareMut = useMutation({ mutationFn: () => createHistoryShare(row.prompt_id, { is_public: shareIsPublic, expires_in_hours: shareExpiryHours ?? undefined, max_views: shareMaxViews ?? undefined, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['history'] }) setShowSharePanel(false) }, }) const revokeMut = useMutation({ mutationFn: () => revokeHistoryShare(row.prompt_id), onSuccess: () => qc.invalidateQueries({ queryKey: ['history'] }), }) const savePresetMut = useMutation({ mutationFn: () => savePresetFromHistory(row.prompt_id, presetName.trim(), presetDesc.trim() || undefined), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ['presets'] }) setPresetMsg({ ok: true, text: `Saved as preset "${res.name}"` }) setPresetName('') setPresetDesc('') }, onError: (err) => { setPresetMsg({ ok: false, text: err instanceof Error ? err.message : String(err) }) }, }) const shareUrl = row.share_token ? `${window.location.origin}/share/${row.share_token}` : null const isOwner = currentUserLabel !== null && row.user_label === currentUserLabel const handleCopy = () => { if (!shareUrl) return navigator.clipboard.writeText(shareUrl).then(() => { setCopied(true) setTimeout(() => setCopied(false), 2000) }) } return (
Overrides
          {JSON.stringify(row.overrides, null, 2)}
        
{isLoading ? (

Loading images…

) : (imagesData?.images ?? []).length > 0 ? (
{(imagesData?.images ?? []).map((img, i) => { const isVideo = img.mime_type.startsWith('video/') if (isVideo) { const videoSrc = `/api/history/${row.prompt_id}/file/${encodeURIComponent(img.filename)}` return
) : (

Files not available (may have been moved or deleted).

)} {(isAdmin || rowPersons.length > 0) && (
People: {rowPersons.map(p => ( {p.name} {isAdmin && ( )} ))} {rowPersons.length === 0 && !isAdmin && None detected}
{isAdmin && (
setAddPersonInput(e.target.value)} onFocus={() => setShowPersonDrop(true)} onBlur={() => setTimeout(() => setShowPersonDrop(false), 150)} onKeyDown={e => { if (e.key === 'Enter' && personDropOptions.length > 0) { addPersonMut.mutate(personDropOptions[0].name) setShowPersonDrop(false) } }} placeholder="Tag a person…" className="glass-input w-full text-xs py-1" disabled={addPersonMut.isPending} /> {showPersonDrop && personDropOptions.length > 0 && (
{personDropOptions.map(p => ( ))}
)}
)}
)} {isOwner && (
{shareUrl ? (

Share link

{row.share_is_public ? 'Public' : 'Private'}
{shareUrl}
Expires: {formatExpiry(row.share_expires_at)} {row.share_max_views != null ? ( Views: {row.share_view_count ?? 0} / {row.share_max_views} ) : (row.share_view_count ?? 0) > 0 ? ( Views: {row.share_view_count} ) : null}
) : showSharePanel ? (

Create share link

Visibility:
Expires after: setShareExpiryHours(e.target.value ? parseFloat(e.target.value) : null)} placeholder="hours" className="glass-input w-20 py-0.5 text-xs" /> hrs {shareExpiryHours !== null && ( )}
Max views: setShareMaxViews(e.target.value ? parseInt(e.target.value, 10) : null)} placeholder="unlimited" className="glass-input w-24 py-0.5 text-xs" /> {shareMaxViews !== null && ( )}
{shareIsPublic && !shareExpiryHours && !shareMaxViews && (

⚠ Public shares require at least one expiry condition.

)} {shareMut.isError && (

{shareMut.error instanceof Error ? shareMut.error.message : 'Failed to create share'}

)}
) : ( )} {!showSavePreset ? ( ) : (

Save overrides as preset

Note: workflow template is not saved — load it separately before using this preset.

setPresetName(e.target.value)} placeholder="Preset name" className="glass-input flex-1 min-w-0 py-1 text-xs" /> setPresetDesc(e.target.value)} placeholder="Description (optional)" className="glass-input flex-1 min-w-0 py-1 text-xs" />
{presetMsg && (

{presetMsg.text}

)}
)}
)}
) }