YT-AI-Automation / frontend /src /components /ScreenshotGallery.tsx
github-actions
Sync Docker Space
5f3e9f5
import { Download, Eye, ImageOff, Package } from 'lucide-react'
import { useState } from 'react'
import { api } from '../api/client'
import { useToast } from '../store/toast'
import HtmlPreviewModal from './HtmlPreviewModal'
interface Props {
/**
* Display-only; not used to construct URLs (files already contain any
* batch-folder prefix). Kept in the props so callers don't need to be
* edited.
*/
screenshotFolder?: string
files: string[]
title?: string
}
export default function ScreenshotGallery(props: Props) {
const { files, title = 'Screenshots' } = props
// props.screenshotFolder is intentionally unused — see Props docs.
const [preview, setPreview] = useState<string | null>(null)
const [zipping, setZipping] = useState(false)
const toast = useToast()
if (files.length === 0) {
return (
<div
className="card flex flex-col items-center justify-center gap-2 py-10 text-center"
role="status"
>
<div
aria-hidden="true"
className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400 dark:bg-white/5 dark:text-slate-500"
>
<ImageOff size={20} />
</div>
<div className="text-sm font-medium text-slate-700 dark:text-slate-200">
No screenshots yet
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
Start a run and finished screenshots will appear here.
</div>
</div>
)
}
const downloadZip = async () => {
setZipping(true)
try {
// `files` are already paths relative to OUTPUT_FOLDER (e.g. "batch 3/5(1).png"
// or "5(1).png"). The backend /download-zip handler resolves them under
// OUTPUT_FOLDER itself, so we MUST NOT prepend screenshotFolder again —
// doing so produced "batch 3/batch 3/5(1).png" and empty ZIPs.
const paths = files
const blob = await api.downloadZip(paths, 'screenshots')
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'screenshots.zip'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (err) {
toast.push({
variant: 'error',
title: 'Download failed',
message: err instanceof Error ? err.message : String(err),
})
} finally {
setZipping(false)
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
{title} ({files.length})
</h3>
<button className="btn-secondary" onClick={downloadZip} disabled={zipping}>
<Package size={16} /> {zipping ? 'Zipping…' : 'Download all (ZIP)'}
</button>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{files.map((filename) => {
const url = api.screenshotUrl(filename)
return (
<div
key={filename}
className="glass group overflow-hidden !p-0"
>
{/* `object-contain` so we never crop content; the checkered */}
{/* background makes letterboxed bars look intentional. */}
<div
className="relative aspect-video overflow-hidden"
style={{
backgroundColor: 'rgb(var(--bg-muted))',
backgroundImage:
'linear-gradient(45deg, rgba(148,163,184,0.12) 25%, transparent 25%), linear-gradient(-45deg, rgba(148,163,184,0.12) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(148,163,184,0.12) 75%), linear-gradient(-45deg, transparent 75%, rgba(148,163,184,0.12) 75%)',
backgroundSize: '16px 16px',
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0',
}}
>
<img
src={url}
alt={filename}
loading="lazy"
className="h-full w-full cursor-zoom-in object-contain"
onClick={() => setPreview(url)}
/>
</div>
<div className="flex items-center justify-between gap-2 border-t border-slate-200 px-3 py-2 dark:border-white/10">
<span
className="truncate text-xs text-slate-600 dark:text-slate-300"
title={filename}
>
{filename.split('/').pop()}
</span>
<div className="flex gap-1">
<button
className="rounded p-1 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={() => setPreview(url)}
title="Preview"
>
<Eye size={14} />
</button>
<a
className="rounded p-1 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
href={url}
download
title="Download"
>
<Download size={14} />
</a>
</div>
</div>
</div>
)
})}
</div>
{preview && (
<HtmlPreviewModal
kind="image"
src={preview}
title={preview.split('/').pop() ?? 'Screenshot'}
onClose={() => setPreview(null)}
/>
)}
</div>
)
}