Spaces:
Running
Running
| 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<string, number> | |
| 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 <ImageIcon size={20} /> | |
| if (kind === 'html') return <FileText size={20} /> | |
| if (kind === 'presentation') return <Presentation size={20} /> | |
| return <Film size={20} /> | |
| } | |
| 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<string, Map<string, string[]>>() | |
| 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<AssetKind>('screenshot') | |
| const [screenshots, setScreenshots] = useState<string[]>([]) | |
| const [htmlFiles, setHtmlFiles] = useState<string[]>([]) | |
| const [presentationFiles, setPresentationFiles] = useState<string[]>([]) | |
| const [videoFiles, setVideoFiles] = useState<string[]>([]) | |
| const [htmlSizes, setHtmlSizes] = useState<FileSizes>({}) | |
| const [presentationSizes, setPresentationSizes] = useState<FileSizes>({}) | |
| const [videoSizes, setVideoSizes] = useState<FileSizes>({}) | |
| const [loading, setLoading] = useState(false) | |
| const [error, setError] = useState<string | null>(null) | |
| const [query, setQuery] = useState('') | |
| const [sort, setSort] = useState<SortKey>('name-desc') | |
| const [selected, setSelected] = useState<Set<string>>(new Set()) | |
| const [preview, setPreview] = useState<Preview | null>(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<HTMLDivElement | null>(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 ( | |
| <div className="container-page space-y-6"> | |
| <div className="flex flex-wrap items-end justify-between gap-4"> | |
| <div> | |
| <div className="eyebrow"> | |
| <span className="h-1 w-1 rounded-full bg-brand-500" /> | |
| Outputs | |
| </div> | |
| <h1 className="h-page mt-2">Library</h1> | |
| <p className="mt-2 text-sm text-muted"> | |
| Every screenshot, HTML file, PowerPoint deck, and video produced | |
| by the backend. Preview, download, or clean up. | |
| </p> | |
| </div> | |
| <button type="button" className="btn-secondary" onClick={load} disabled={loading}> | |
| <RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> Refresh | |
| </button> | |
| </div> | |
| {/* Tabs — keyboard navigable per WAI-ARIA tablist pattern. */} | |
| <LibraryTabs | |
| kind={kind} | |
| screenshots={screenshots.length} | |
| htmlFiles={htmlFiles.length} | |
| presentationFiles={presentationFiles.length} | |
| videoFiles={videoFiles.length} | |
| onSwitch={switchKind} | |
| /> | |
| {/* Controls row */} | |
| <div className="card flex flex-wrap items-center gap-3"> | |
| <div className="relative min-w-[220px] flex-1"> | |
| <label htmlFor="library-search" className="sr-only"> | |
| Search {kindLabel(kind)} | |
| </label> | |
| <Search | |
| size={14} | |
| className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" | |
| /> | |
| <input | |
| id="library-search" | |
| type="search" | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| placeholder={`Search ${kindLabel(kind)}...`} | |
| className="input !pl-9" | |
| /> | |
| </div> | |
| <label htmlFor="library-sort" className="sr-only"> | |
| Sort order | |
| </label> | |
| <select | |
| id="library-sort" | |
| value={sort} | |
| onChange={(e) => setSort(e.target.value as SortKey)} | |
| className="select max-w-[220px]" | |
| > | |
| <option value="name-desc">Sort: Name (Z → A)</option> | |
| <option value="name-asc">Sort: Name (A → Z)</option> | |
| </select> | |
| <div className="ml-auto flex items-center gap-2"> | |
| <Checkbox | |
| checked={allSelected} | |
| onChange={() => toggleAll()} | |
| label={<span className="text-xs text-muted">Select all ({filtered.length})</span>} | |
| /> | |
| {kind === 'screenshot' && ( | |
| <button | |
| type="button" | |
| className="btn-secondary" | |
| disabled={selected.size === 0 || working} | |
| onClick={onDownloadZip} | |
| title="Zip selected screenshots" | |
| > | |
| <Archive size={14} /> Download ZIP | |
| </button> | |
| )} | |
| <button | |
| type="button" | |
| className="btn-danger" | |
| disabled={selected.size === 0 || working} | |
| onClick={onDeleteSelected} | |
| > | |
| <Trash2 size={14} /> Delete | |
| </button> | |
| </div> | |
| </div> | |
| {/* Grid / list — rendered as the tabpanel for the selected kind. */} | |
| <div | |
| role="tabpanel" | |
| id={`library-panel-${kind}`} | |
| aria-labelledby={`library-tab-${kind}`} | |
| tabIndex={0} | |
| > | |
| {error ? ( | |
| <ErrorCard title="Couldn't load library" message={error} onRetry={load} /> | |
| ) : loading ? ( | |
| <div className="card flex items-center justify-center gap-2 py-10 text-sm text-slate-500"> | |
| <Loader2 size={16} className="animate-spin" /> Loading… | |
| </div> | |
| ) : filtered.length === 0 ? ( | |
| raw.length === 0 ? ( | |
| <EmptyState | |
| icon={kindIcon(kind)} | |
| title={`No ${kindLabel(kind)} yet`} | |
| description={ | |
| <> | |
| Run a Text→Video, HTML→Video, or Image→Video job and the | |
| outputs will land here automatically. | |
| </> | |
| } | |
| /> | |
| ) : ( | |
| <EmptyState | |
| variant="muted" | |
| icon={<Search size={20} />} | |
| title="No matches for your search" | |
| description="Try a shorter query, clear the filter, or switch tabs." | |
| /> | |
| ) | |
| ) : kind === 'screenshot' ? ( | |
| <> | |
| <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> | |
| {visible.map((name) => ( | |
| <ScreenshotCard | |
| key={name} | |
| name={name} | |
| selected={selected.has(name)} | |
| onToggle={() => toggleOne(name)} | |
| onPreview={() => setPreview({ kind: 'screenshot', filename: name })} | |
| onDownload={() => onDownloadOne(name)} | |
| /> | |
| ))} | |
| </div> | |
| <LibraryPaginator | |
| sentinelRef={sentinelRef} | |
| hasMore={hasMore} | |
| shown={visible.length} | |
| total={filtered.length} | |
| onLoadMore={() => setVisibleCount((n) => Math.min(n + LIBRARY_PAGE_SIZE, filtered.length))} | |
| /> | |
| </> | |
| ) : kind === 'html' ? ( | |
| <> | |
| <div className="card divide-y divide-slate-100 dark:divide-white/5"> | |
| {visible.map((name) => ( | |
| <HtmlRow | |
| key={name} | |
| name={name} | |
| size={htmlSizes[name]} | |
| selected={selected.has(name)} | |
| onToggle={() => toggleOne(name)} | |
| onPreview={() => setPreview({ kind: 'html', filename: name })} | |
| onDownload={() => onDownloadOne(name)} | |
| /> | |
| ))} | |
| </div> | |
| <LibraryPaginator | |
| sentinelRef={sentinelRef} | |
| hasMore={hasMore} | |
| shown={visible.length} | |
| total={filtered.length} | |
| onLoadMore={() => setVisibleCount((n) => Math.min(n + LIBRARY_PAGE_SIZE, filtered.length))} | |
| /> | |
| </> | |
| ) : ( | |
| <> | |
| <GroupedGeneratedFiles | |
| kind={kind} | |
| files={visible} | |
| sizes={kind === 'presentation' ? presentationSizes : videoSizes} | |
| selected={selected} | |
| onToggle={toggleOne} | |
| onPreview={(name) => 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') | |
| }} | |
| /> | |
| <LibraryPaginator | |
| sentinelRef={sentinelRef} | |
| hasMore={hasMore} | |
| shown={visible.length} | |
| total={filtered.length} | |
| onLoadMore={() => setVisibleCount((n) => Math.min(n + LIBRARY_PAGE_SIZE, filtered.length))} | |
| /> | |
| </> | |
| )} | |
| </div> | |
| {preview && preview.kind !== 'presentation' && ( | |
| <AssetPreviewModal | |
| kind={preview.kind === 'screenshot' ? 'image' : preview.kind === 'html' ? 'html' : 'video'} | |
| src={urlFor(preview.filename)} | |
| title={preview.filename.split('/').pop() ?? preview.filename} | |
| subtitle={ | |
| preview.kind === 'html' | |
| ? 'HTML file' | |
| : preview.kind === 'video' | |
| ? 'MP4 video' | |
| : preview.filename | |
| } | |
| onClose={() => setPreview(null)} | |
| /> | |
| )} | |
| </div> | |
| ) | |
| } | |
| function LibraryPaginator({ | |
| sentinelRef, | |
| hasMore, | |
| shown, | |
| total, | |
| onLoadMore, | |
| }: { | |
| sentinelRef: React.MutableRefObject<HTMLDivElement | null> | |
| hasMore: boolean | |
| shown: number | |
| total: number | |
| onLoadMore: () => void | |
| }) { | |
| if (total <= LIBRARY_PAGE_SIZE) return null | |
| return ( | |
| <div className="mt-4 flex flex-col items-center gap-2 text-xs text-slate-500 dark:text-slate-400"> | |
| <div aria-live="polite"> | |
| Showing {shown.toLocaleString()} of {total.toLocaleString()} items | |
| </div> | |
| {hasMore ? ( | |
| <> | |
| <button type="button" className="btn-secondary btn-sm" onClick={onLoadMore}> | |
| Load more | |
| </button> | |
| <div ref={sentinelRef} aria-hidden="true" className="h-1 w-full" /> | |
| </> | |
| ) : null} | |
| </div> | |
| ) | |
| } | |
| function ScreenshotCard({ | |
| name, | |
| selected, | |
| onToggle, | |
| onPreview, | |
| onDownload, | |
| }: { | |
| name: string | |
| selected: boolean | |
| onToggle: () => void | |
| onPreview: () => void | |
| onDownload: () => void | |
| }) { | |
| return ( | |
| <div | |
| className={ | |
| 'group relative overflow-hidden rounded-xl border bg-white shadow-glass transition-shadow hover:shadow-glass-lg dark:bg-white/[0.03] ' + | |
| (selected | |
| ? 'border-brand-400 ring-2 ring-brand-200 dark:border-brand-500/60 dark:ring-brand-500/30' | |
| : 'border-slate-200 dark:border-white/10') | |
| } | |
| > | |
| <span className="absolute left-2 top-2 z-10 flex h-6 w-6 items-center justify-center rounded-md bg-white/90 shadow-sm backdrop-blur-sm dark:bg-slate-900/80"> | |
| <Checkbox | |
| checked={selected} | |
| onChange={() => onToggle()} | |
| hideLabel | |
| ariaLabel={`Select ${name}`} | |
| /> | |
| </span> | |
| <button | |
| type="button" | |
| onClick={onPreview} | |
| className="block aspect-[16/10] w-full overflow-hidden bg-slate-100 dark:bg-slate-900/40" | |
| > | |
| <img | |
| src={api.screenshotUrl(name)} | |
| alt={name} | |
| loading="lazy" | |
| className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]" | |
| /> | |
| </button> | |
| <div className="flex items-center gap-2 px-3 py-2"> | |
| <div className="min-w-0 flex-1 truncate text-xs text-slate-700 dark:text-slate-200" title={name}> | |
| {name} | |
| </div> | |
| <button | |
| type="button" | |
| onClick={onPreview} | |
| className="btn-ghost !px-1.5 !py-1" | |
| aria-label="Preview" | |
| > | |
| <Eye size={14} /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={onDownload} | |
| className="btn-ghost !px-1.5 !py-1" | |
| aria-label="Download" | |
| > | |
| <Download size={14} /> | |
| </button> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function HtmlRow({ | |
| name, | |
| size, | |
| selected, | |
| onToggle, | |
| onPreview, | |
| onDownload, | |
| }: { | |
| name: string | |
| size?: number | |
| selected: boolean | |
| onToggle: () => void | |
| onPreview: () => void | |
| onDownload: () => void | |
| }) { | |
| return ( | |
| <div className="flex items-center gap-3 py-3"> | |
| <Checkbox | |
| checked={selected} | |
| onChange={() => onToggle()} | |
| hideLabel | |
| ariaLabel={`Select ${name}`} | |
| /> | |
| <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-500 dark:bg-white/[0.05] dark:text-slate-300"> | |
| <FileText size={16} /> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={onPreview} | |
| className="min-w-0 flex-1 text-left text-sm font-medium text-slate-800 hover:text-brand-700 dark:text-slate-100 dark:hover:text-brand-300" | |
| > | |
| <span className="block truncate">{name}</span> | |
| <span className="text-xs font-normal text-slate-500 dark:text-slate-400">{formatBytes(size)}</span> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={onPreview} | |
| className="btn-ghost !px-2 !py-1" | |
| aria-label="Preview" | |
| > | |
| <Eye size={14} /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={onDownload} | |
| className="btn-ghost !px-2 !py-1" | |
| aria-label="Download" | |
| > | |
| <Download size={14} /> | |
| </button> | |
| </div> | |
| ) | |
| } | |
| // ─── 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<string> | |
| onToggle: (name: string) => void | |
| onPreview: (name: string) => void | |
| onDownload: (name: string) => void | |
| onOpenExternal?: (name: string) => void | |
| }) { | |
| const groups = groupGeneratedFiles(files) | |
| return ( | |
| <div className="space-y-4"> | |
| {groups.map((group) => ( | |
| <section key={group.className} className="rounded-lg border border-slate-200 bg-[rgb(var(--bg-surface))] p-4 dark:border-white/10"> | |
| <div className="mb-3 flex items-center justify-between gap-3"> | |
| <h2 className="text-sm font-semibold text-[rgb(var(--text-strong))]">{group.className}</h2> | |
| <span className="text-xs text-slate-500 dark:text-slate-400"> | |
| {group.subjects.reduce((sum, subject) => sum + subject.items.length, 0)} file(s) | |
| </span> | |
| </div> | |
| <div className="space-y-3"> | |
| {group.subjects.map((subject) => ( | |
| <div key={`${group.className}-${subject.subject}`} className="rounded-md border border-slate-200 bg-slate-50/60 dark:border-white/10 dark:bg-white/[0.03]"> | |
| <div className="flex items-center justify-between gap-3 border-b border-slate-200 px-3 py-2 dark:border-white/10"> | |
| <h3 className="text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-300">{subject.subject}</h3> | |
| <span className="text-[11px] text-slate-500 dark:text-slate-400">{subject.items.length} item(s)</span> | |
| </div> | |
| <div className="divide-y divide-slate-200 px-3 dark:divide-white/10"> | |
| {subject.items.map((name) => ( | |
| <GeneratedFileRow | |
| key={name} | |
| kind={kind} | |
| name={name} | |
| size={sizes[name]} | |
| selected={selected.has(name)} | |
| onToggle={() => onToggle(name)} | |
| onPreview={kind === 'video' ? () => onPreview(name) : undefined} | |
| onOpenExternal={ | |
| kind === 'presentation' && onOpenExternal | |
| ? () => onOpenExternal(name) | |
| : undefined | |
| } | |
| onDownload={() => onDownload(name)} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </section> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| 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 ( | |
| <div className="flex items-center gap-3 py-3"> | |
| <Checkbox | |
| checked={selected} | |
| onChange={() => onToggle()} | |
| hideLabel | |
| ariaLabel={`Select ${name}`} | |
| /> | |
| <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-500 dark:bg-white/[0.05] dark:text-slate-300"> | |
| <Icon size={16} /> | |
| </div> | |
| <div className="min-w-0 flex-1 text-sm font-medium text-slate-800 dark:text-slate-100"> | |
| <span className="block truncate">{name}</span> | |
| <span className="text-xs font-normal text-slate-500 dark:text-slate-400"> | |
| {kind === 'presentation' ? 'PowerPoint deck' : 'MP4 video'} · {formatBytes(size)} | |
| </span> | |
| </div> | |
| {onPreview && ( | |
| <button type="button" onClick={onPreview} className="btn-ghost !px-2 !py-1" aria-label="Preview"> | |
| <Eye size={14} /> | |
| </button> | |
| )} | |
| {onOpenExternal && ( | |
| <button | |
| type="button" | |
| onClick={onOpenExternal} | |
| className="btn-ghost !px-2 !py-1" | |
| aria-label="Open externally" | |
| title="Open in PowerPoint / web viewer" | |
| > | |
| <ExternalLink size={14} /> | |
| </button> | |
| )} | |
| <button type="button" onClick={onDownload} className="btn-ghost !px-2 !py-1" aria-label="Download"> | |
| <Download size={14} /> | |
| </button> | |
| </div> | |
| ) | |
| } | |
| 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<Array<HTMLButtonElement | null>>([]) | |
| 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 ( | |
| <div | |
| role="tablist" | |
| aria-label="Library kind" | |
| className="flex flex-wrap items-center gap-2 border-b border-slate-200 dark:border-white/10" | |
| > | |
| {tabs.map((t, i) => { | |
| const active = kind === t.id | |
| return ( | |
| <button | |
| key={t.id} | |
| ref={(el) => { | |
| refs.current[i] = el | |
| }} | |
| type="button" | |
| role="tab" | |
| id={`library-tab-${t.id}`} | |
| aria-selected={active} | |
| aria-controls={`library-panel-${t.id}`} | |
| tabIndex={active ? 0 : -1} | |
| onClick={() => onSwitch(t.id)} | |
| onKeyDown={(e) => onKey(e, i)} | |
| className={ | |
| 'flex items-center gap-2 border-b-2 px-3 py-2 text-sm font-medium transition-colors ' + | |
| (active | |
| ? 'border-brand-500 text-brand-700 dark:text-brand-200' | |
| : 'border-transparent text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100') | |
| } | |
| > | |
| <t.icon size={14} /> | |
| {t.label} | |
| <span className="ml-1 rounded-full bg-slate-100 px-1.5 text-[11px] font-medium text-slate-600 dark:bg-white/[0.05] dark:text-slate-300"> | |
| {t.count} | |
| </span> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| } | |