YT-AI-Automation / frontend /src /components /PreflightModal.tsx
github-actions
Sync Docker Space
5f3e9f5
/**
* Runs the /preflight sequence in front of the user before a wizard run
* actually starts. Three checks + one soft-fail (PowerPoint) gated on the
* chosen output format.
*/
import { useEffect, useState } from 'react'
import { CheckCircle2, Loader2, Server, Cpu, Sparkles, Presentation, Film, XCircle, AlertTriangle } from 'lucide-react'
import { api } from '../api/client'
import type { OutputFormat, PreflightResponse } from '../api/types'
import { useFocusTrap } from '../hooks/useFocusTrap'
type CheckStatus = 'idle' | 'running' | 'pass' | 'fail' | 'warn'
interface CheckRow {
key: keyof PreflightResponse['checks']
label: string
icon: typeof Server
}
const ROWS: CheckRow[] = [
{ key: 'platform', label: 'Platform', icon: Cpu },
{ key: 'backend', label: 'Backend connection', icon: Server },
{ key: 'ai_config', label: 'AI configuration', icon: Sparkles },
{ key: 'powerpoint', label: 'PowerPoint availability', icon: Presentation },
{ key: 'video_engine', label: 'Video engine', icon: Film },
]
function statusDot(s: CheckStatus) {
if (s === 'running') return <Loader2 size={16} className="animate-spin text-slate-400" />
if (s === 'pass') return <CheckCircle2 size={16} className="text-brand-600" />
if (s === 'warn') return <AlertTriangle size={16} className="text-amber-500" />
if (s === 'fail') return <XCircle size={16} className="text-rose-500" />
return <span className="inline-block h-4 w-4 rounded-full border border-slate-300 dark:border-white/15" />
}
interface PreflightModalProps {
outputFormat: OutputFormat
onCancel: () => void
onProceed: () => void
}
const OUTPUT_LABELS: Record<OutputFormat, string> = {
html: 'HTML file',
images: 'screenshots',
pptx: 'PowerPoint deck',
video: 'MP4 video',
}
export default function PreflightModal({ outputFormat, onCancel, onProceed }: PreflightModalProps) {
const [data, setData] = useState<PreflightResponse | null>(null)
const [loadingKey, setLoadingKey] = useState<keyof PreflightResponse['checks'] | null>('platform')
const [error, setError] = useState<string | null>(null)
const dialogRef = useFocusTrap<HTMLDivElement>(true)
useEffect(() => {
let cancelled = false
;(async () => {
try {
// Walk through the visual rows so the user sees each one light up in
// sequence, even though the backend reports them all at once.
// Always request fresh — the user is gating a run behind this
// modal and needs the actual current state of the backend, not a
// 30-second-stale cached result.
const res = await api.preflight({ fresh: true })
if (cancelled) return
for (const row of ROWS) {
setLoadingKey(row.key)
await new Promise((r) => setTimeout(r, 350))
if (cancelled) return
}
setLoadingKey(null)
setData(res)
} catch (e) {
if (cancelled) return
setError(e instanceof Error ? e.message : String(e))
setLoadingKey(null)
}
})()
return () => {
cancelled = true
}
}, [])
// Escape key closes the modal.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCancel])
const needsPpt = outputFormat === 'pptx'
const needsVideo = outputFormat === 'video'
const pptOk = data?.checks.powerpoint.ok ?? false
const videoEngineOk = data?.checks.video_engine?.ok ?? pptOk
const aiOk = data?.checks.ai_config.ok ?? false
const backendOk = !!data
const blocked = !data
? false
: !backendOk || !aiOk || (needsPpt && !pptOk) || (needsVideo && !videoEngineOk)
const rowStatus = (key: keyof PreflightResponse['checks']): CheckStatus => {
if (!data) {
if (loadingKey === key) return 'running'
const idx = ROWS.findIndex((r) => r.key === loadingKey)
const myIdx = ROWS.findIndex((r) => r.key === key)
if (idx >= 0 && myIdx < idx) return 'pass'
return 'idle'
}
const c = data.checks[key]
if (!c) return 'idle'
if (c.ok) return 'pass'
// Soft-fail: PowerPoint only matters for PPTX export; video_engine
// only matters for MP4. Anything else is always hard-required.
if (key === 'powerpoint' && !needsPpt) return 'warn'
if (key === 'video_engine' && !needsVideo) return 'warn'
return 'fail'
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-slate-950/40 backdrop-blur-sm"
onClick={onCancel}
aria-hidden
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="preflight-title"
tabIndex={-1}
className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white p-6 shadow-lg dark:border-white/10 dark:bg-slate-900"
>
<h2
id="preflight-title"
className="font-display text-lg font-semibold text-slate-900 dark:text-slate-50"
>
Pre-flight checks
</h2>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Verifying the runtime can actually produce{' '}
<span className="font-medium">{OUTPUT_LABELS[outputFormat]}</span>.
</p>
<ul className="mt-5 space-y-3">
{ROWS.map((row) => {
const s = rowStatus(row.key)
const detail = data?.checks[row.key]?.detail ?? ''
const Icon = row.icon
return (
<li key={row.key} className="flex items-start gap-3">
<div className="mt-0.5 flex h-5 w-5 items-center justify-center">{statusDot(s)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Icon size={14} className="text-slate-400" />
<span className="text-sm font-medium text-slate-800 dark:text-slate-100">
{row.label}
</span>
{row.key === 'powerpoint' && !needsPpt && (
<span className="text-[10px] uppercase tracking-wider text-slate-400">
optional for {OUTPUT_LABELS[outputFormat]}
</span>
)}
</div>
{detail && (
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{detail}</p>
)}
</div>
</li>
)
})}
</ul>
{error && (
<div className="mt-4 rounded-md border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200">
Preflight request failed: {error}
</div>
)}
{data && blocked && (
<div className="mt-4 rounded-md border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200">
{!aiOk && <p>AI config is missing — edit <code>backend/config/config.py</code> and restart the backend.</p>}
{needsPpt && !pptOk && (
<p className="mt-1">
Output is <strong>{OUTPUT_LABELS[outputFormat]}</strong> but PowerPoint isn't
available. Go back to step 1 and pick <em>HTML file</em> or{' '}
<em>screenshots</em>, or install PowerPoint on a Windows host.
</p>
)}
</div>
)}
<div className="mt-6 flex items-center justify-end gap-2">
<button type="button" className="btn-secondary" onClick={onCancel}>
Cancel
</button>
<button
type="button"
className="btn-primary"
disabled={!data || blocked}
onClick={onProceed}
>
Proceed
</button>
</div>
</div>
</div>
)
}