import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type React from 'react' import { Archive, Download, ExternalLink, Eye, FileText, Film, Image as ImageIcon, Loader2, Presentation, RefreshCw, Search, Trash2, } from 'lucide-react' import { api } from '../api/client' import AssetPreviewModal from '../components/AssetPreviewModal' import Checkbox from '../components/Checkbox' import ErrorCard from '../components/ErrorCard' import EmptyState from '../components/EmptyState' import { useToast } from '../store/toast' import { useConfirm } from '../components/ConfirmDialog' type AssetKind = 'html' | 'screenshot' | 'presentation' | 'video' type SortKey = 'name-asc' | 'name-desc' type FileSizes = Record interface Preview { kind: AssetKind filename: string } function kindLabel(kind: AssetKind): string { if (kind === 'screenshot') return 'screenshots' if (kind === 'html') return 'HTML files' if (kind === 'presentation') return 'PowerPoint files' return 'videos' } function kindIcon(kind: AssetKind): React.ReactNode { if (kind === 'screenshot') return if (kind === 'html') return if (kind === 'presentation') return return } function formatBytes(bytes?: number): string { if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return 'Size unknown' if (bytes < 1024) return `${bytes} B` const units = ['KB', 'MB', 'GB'] let value = bytes / 1024 let idx = 0 while (value >= 1024 && idx < units.length - 1) { value /= 1024 idx += 1 } return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${units[idx]}` } function classifyGeneratedFile(name: string): { className: string; subject: string } { const clean = name.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ') const classMatch = clean.match(/\bclass\s*(\d{1,2})\b/i) const className = classMatch ? `Class ${classMatch[1]}` : 'Unsorted' const afterClass = classMatch ? clean.slice((classMatch.index ?? 0) + classMatch[0].length).trim() : clean const subject = afterClass.match(/^[a-zA-Z]+/)?.[0] return { className, subject: subject ? subject[0].toUpperCase() + subject.slice(1).toLowerCase() : 'General', } } function groupGeneratedFiles(files: string[]) { const map = new Map>() for (const file of files) { const { className, subject } = classifyGeneratedFile(file) if (!map.has(className)) map.set(className, new Map()) const subjects = map.get(className)! subjects.set(subject, [...(subjects.get(subject) ?? []), file]) } return [...map.entries()] .sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })) .map(([className, subjects]) => ({ className, subjects: [...subjects.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([subject, items]) => ({ subject, items })), })) } /** * How many library entries to render per "page". On a full repo a section * can contain several hundred files — rendering the whole grid at once * pushes the browser into tens of thousands of DOM nodes (4 tiles per * card × 500+ = 2k image tags + metadata) and the scroll becomes * unusable. We render in pages and auto-load the next page when the * sentinel scrolls into view. */ const LIBRARY_PAGE_SIZE = 60 export default function Library() { const [kind, setKind] = useState('screenshot') const [screenshots, setScreenshots] = useState([]) const [htmlFiles, setHtmlFiles] = useState([]) const [presentationFiles, setPresentationFiles] = useState([]) const [videoFiles, setVideoFiles] = useState([]) const [htmlSizes, setHtmlSizes] = useState({}) const [presentationSizes, setPresentationSizes] = useState({}) const [videoSizes, setVideoSizes] = useState({}) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [query, setQuery] = useState('') const [sort, setSort] = useState('name-desc') const [selected, setSelected] = useState>(new Set()) const [preview, setPreview] = useState(null) const [working, setWorking] = useState(false) const toast = useToast() const confirm = useConfirm() const load = useCallback(async () => { setLoading(true) setError(null) try { const r = await api.list() setScreenshots(r.screenshots ?? []) setHtmlFiles(r.html_files ?? []) setPresentationFiles(r.presentation_files ?? []) setVideoFiles(r.video_files ?? []) setHtmlSizes(r.html_sizes ?? {}) setPresentationSizes(r.presentation_sizes ?? {}) setVideoSizes(r.video_sizes ?? {}) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } }, []) // setTimeout(0) defers the first fetch past the effect body so the rule // `react-hooks/set-state-in-effect` is satisfied (same pattern as Processes). useEffect(() => { const t = setTimeout(() => { void load() }, 0) return () => clearTimeout(t) }, [load]) // Selecting a tab also clears any cross-tab selection — done inline below. const switchKind = (next: AssetKind) => { setKind(next) setSelected(new Set()) } const raw = kind === 'screenshot' ? screenshots : kind === 'html' ? htmlFiles : kind === 'presentation' ? presentationFiles : videoFiles const filtered = useMemo(() => { const q = query.trim().toLowerCase() const list = q ? raw.filter((f) => f.toLowerCase().includes(q)) : [...raw] list.sort((a, b) => (sort === 'name-asc' ? a.localeCompare(b) : b.localeCompare(a))) return list }, [raw, query, sort]) // Pagination — reset whenever the visible list identity changes (tab // switch, search, sort, underlying list reloaded). We defer the reset // with setTimeout(0) so the setState doesn't fire synchronously in an // effect body (matches the `load()` pattern above and satisfies // react-hooks/set-state-in-effect). const [visibleCount, setVisibleCount] = useState(LIBRARY_PAGE_SIZE) useEffect(() => { const t = setTimeout(() => setVisibleCount(LIBRARY_PAGE_SIZE), 0) return () => clearTimeout(t) }, [kind, query, sort, raw]) const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]) const hasMore = visibleCount < filtered.length const sentinelRef = useRef(null) useEffect(() => { if (!hasMore) return const el = sentinelRef.current if (!el) return const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { setVisibleCount((n) => Math.min(n + LIBRARY_PAGE_SIZE, filtered.length)) } } }, { rootMargin: '320px' }, ) observer.observe(el) return () => observer.disconnect() }, [hasMore, filtered.length]) const allSelected = filtered.length > 0 && filtered.every((f) => selected.has(f)) const toggleAll = () => { if (allSelected) setSelected(new Set()) else setSelected(new Set(filtered)) } const toggleOne = (name: string) => { setSelected((prev) => { const next = new Set(prev) if (next.has(name)) next.delete(name) else next.add(name) return next }) } const urlFor = (name: string) => kind === 'screenshot' ? api.screenshotUrl(name) : kind === 'html' ? api.htmlUrl(name) : api.downloadUrl(kind === 'presentation' ? `output/presentations/${name}` : `output/videos/${name}`) const onDownloadOne = (name: string) => { const a = document.createElement('a') a.href = urlFor(name) a.download = name.split('/').pop() ?? name document.body.appendChild(a) a.click() a.remove() } const onDownloadZip = async () => { if (kind !== 'screenshot' || selected.size === 0) return setWorking(true) try { const blob = await api.downloadZip([...selected], 'library') const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'library.zip' document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } catch (e) { toast.push({ variant: 'error', title: 'Bulk download failed', message: e instanceof Error ? e.message : String(e), }) } finally { setWorking(false) } } const onDeleteSelected = async () => { if (selected.size === 0) return const ok = await confirm({ title: `Delete ${selected.size} file${selected.size === 1 ? '' : 's'}?`, message: 'This permanently removes the files from the backend output folder.', confirmLabel: 'Delete', variant: 'danger', }) if (!ok) return setWorking(true) try { const type = kind === 'screenshot' ? 'screenshot' : kind === 'html' ? 'html' : kind === 'presentation' ? 'presentation' : 'video' await Promise.all([...selected].map((name) => api.deleteFile(type, name))) const removed = selected.size setSelected(new Set()) await load() toast.push({ variant: 'success', message: `Deleted ${removed} file${removed === 1 ? '' : 's'}.` }) } catch (e) { toast.push({ variant: 'error', title: 'Delete failed', message: e instanceof Error ? e.message : String(e), }) } finally { setWorking(false) } } return (
Outputs

Library

Every screenshot, HTML file, PowerPoint deck, and video produced by the backend. Preview, download, or clean up.

{/* Tabs — keyboard navigable per WAI-ARIA tablist pattern. */} {/* Controls row */}
setQuery(e.target.value)} placeholder={`Search ${kindLabel(kind)}...`} className="input !pl-9" />
toggleAll()} label={Select all ({filtered.length})} /> {kind === 'screenshot' && ( )}
{/* Grid / list — rendered as the tabpanel for the selected kind. */}
{error ? ( ) : loading ? (
Loading…
) : filtered.length === 0 ? ( raw.length === 0 ? ( Run a Text→Video, HTML→Video, or Image→Video job and the outputs will land here automatically. } /> ) : ( } title="No matches for your search" description="Try a shorter query, clear the filter, or switch tabs." /> ) ) : kind === 'screenshot' ? ( <>
{visible.map((name) => ( toggleOne(name)} onPreview={() => setPreview({ kind: 'screenshot', filename: name })} onDownload={() => onDownloadOne(name)} /> ))}
setVisibleCount((n) => Math.min(n + LIBRARY_PAGE_SIZE, filtered.length))} /> ) : kind === 'html' ? ( <>
{visible.map((name) => ( toggleOne(name)} onPreview={() => setPreview({ kind: 'html', filename: name })} onDownload={() => onDownloadOne(name)} /> ))}
setVisibleCount((n) => Math.min(n + LIBRARY_PAGE_SIZE, filtered.length))} /> ) : ( <> setPreview({ kind, filename: name })} onDownload={onDownloadOne} onOpenExternal={(name) => { // E3: PowerPoint is not previewable inline — opening the // download URL in a new tab lets the OS hand off to the // user's preferred editor (PowerPoint / Keynote / web). const url = urlFor(name) window.open(url, '_blank', 'noopener') }} /> setVisibleCount((n) => Math.min(n + LIBRARY_PAGE_SIZE, filtered.length))} /> )}
{preview && preview.kind !== 'presentation' && ( setPreview(null)} /> )}
) } function LibraryPaginator({ sentinelRef, hasMore, shown, total, onLoadMore, }: { sentinelRef: React.MutableRefObject hasMore: boolean shown: number total: number onLoadMore: () => void }) { if (total <= LIBRARY_PAGE_SIZE) return null return (
Showing {shown.toLocaleString()} of {total.toLocaleString()} items
{hasMore ? ( <> ) } function ScreenshotCard({ name, selected, onToggle, onPreview, onDownload, }: { name: string selected: boolean onToggle: () => void onPreview: () => void onDownload: () => void }) { return (
onToggle()} hideLabel ariaLabel={`Select ${name}`} />
{name}
) } function HtmlRow({ name, size, selected, onToggle, onPreview, onDownload, }: { name: string size?: number selected: boolean onToggle: () => void onPreview: () => void onDownload: () => void }) { return (
onToggle()} hideLabel ariaLabel={`Select ${name}`} />
) } // ─── Tabs ──────────────────────────────────────────────────────────────── // WAI-ARIA tablist pattern with roving-tabindex + arrow-key navigation, so // Left/Right, Home/End walk between "Screenshots" and "HTML files" and the // active tab is the one in the document tab order. function GroupedGeneratedFiles({ kind, files, sizes, selected, onToggle, onPreview, onDownload, onOpenExternal, }: { kind: 'presentation' | 'video' files: string[] sizes: FileSizes selected: Set onToggle: (name: string) => void onPreview: (name: string) => void onDownload: (name: string) => void onOpenExternal?: (name: string) => void }) { const groups = groupGeneratedFiles(files) return (
{groups.map((group) => (

{group.className}

{group.subjects.reduce((sum, subject) => sum + subject.items.length, 0)} file(s)
{group.subjects.map((subject) => (

{subject.subject}

{subject.items.length} item(s)
{subject.items.map((name) => ( onToggle(name)} onPreview={kind === 'video' ? () => onPreview(name) : undefined} onOpenExternal={ kind === 'presentation' && onOpenExternal ? () => onOpenExternal(name) : undefined } onDownload={() => onDownload(name)} /> ))}
))}
))}
) } function GeneratedFileRow({ kind, name, size, selected, onToggle, onPreview, onOpenExternal, onDownload, }: { kind: 'presentation' | 'video' name: string size?: number selected: boolean onToggle: () => void onPreview?: () => void onOpenExternal?: () => void onDownload: () => void }) { const Icon = kind === 'presentation' ? Presentation : Film return (
onToggle()} hideLabel ariaLabel={`Select ${name}`} />
{name} {kind === 'presentation' ? 'PowerPoint deck' : 'MP4 video'} · {formatBytes(size)}
{onPreview && ( )} {onOpenExternal && ( )}
) } function LibraryTabs({ kind, screenshots, htmlFiles, presentationFiles, videoFiles, onSwitch, }: { kind: AssetKind screenshots: number htmlFiles: number presentationFiles: number videoFiles: number onSwitch: (next: AssetKind) => void }) { const tabs: { id: AssetKind; label: string; icon: typeof FileText; count: number }[] = [ { id: 'screenshot', label: 'Screenshots', icon: ImageIcon, count: screenshots }, { id: 'html', label: 'HTML files', icon: FileText, count: htmlFiles }, { id: 'presentation', label: 'PowerPoint', icon: Presentation, count: presentationFiles }, { id: 'video', label: 'Videos', icon: Film, count: videoFiles }, ] const refs = useRef>([]) const focusAt = (idx: number) => { const n = tabs.length const target = ((idx % n) + n) % n refs.current[target]?.focus() onSwitch(tabs[target].id) } const onKey = (e: React.KeyboardEvent, idx: number) => { switch (e.key) { case 'ArrowRight': e.preventDefault() focusAt(idx + 1) break case 'ArrowLeft': e.preventDefault() focusAt(idx - 1) break case 'Home': e.preventDefault() focusAt(0) break case 'End': e.preventDefault() focusAt(tabs.length - 1) break } } return (
{tabs.map((t, i) => { const active = kind === t.id return ( ) })}
) }