diff --git a/frontend/src/components/ProgressIndicator.tsx b/frontend/src/components/ProgressIndicator.tsx new file mode 100644 index 0000000..34f3bc5 --- /dev/null +++ b/frontend/src/components/ProgressIndicator.tsx @@ -0,0 +1,153 @@ +/** + * ProgressIndicator — mostra il progresso real-time di un job di generazione. + * + * Usa useJobStatus(jobId) con polling ogni 2 secondi finché status='running'. + * Il polling si ferma automaticamente quando status diventa 'completed' o 'failed'. + * + * Quando il job completa, chiama onComplete(jobId) per navigare a OutputReview. + */ + +import { AlertTriangle, Check, Loader2, X } from 'lucide-react' +import { useEffect, useRef } from 'react' +import { useJobStatus } from '../api/hooks' +import type { PostResult } from '../types' + +interface ProgressIndicatorProps { + jobId: string + onComplete: (jobId: string) => void +} + +export function ProgressIndicator({ jobId, onComplete }: ProgressIndicatorProps) { + const { data: jobStatus, error } = useJobStatus(jobId) + const onCompleteRef = useRef(onComplete) + onCompleteRef.current = onComplete + + // Quando il job diventa completed, chiama onComplete + useEffect(() => { + if (jobStatus?.status === 'completed') { + onCompleteRef.current(jobId) + } + }, [jobId, jobStatus?.status]) + + if (error) { + return ( +
+ +

Errore polling: {error.message}

+
+ ) + } + + if (!jobStatus) { + return ( +
+ + Connessione al server... +
+ ) + } + + const { total, completed, status, results } = jobStatus + const pct = total > 0 ? Math.round((completed / total) * 100) : 0 + + return ( +
+ {/* Header con stato */} +
+
+ {status === 'running' && ( + + )} + {status === 'completed' && ( + + )} + {status === 'failed' && ( + + )} + + {status === 'running' && `Post ${completed} / ${total} in generazione...`} + {status === 'completed' && `Generazione completata — ${completed} / ${total} post`} + {status === 'failed' && 'Generazione fallita'} + +
+ {pct}% +
+ + {/* Barra di progresso */} +
+
+
+ + {/* Lista post con stato individuale */} + {results.length > 0 && ( +
+ {/* Rende i risultati ricevuti finora */} + {results.map((r: PostResult) => ( + + ))} + + {/* Slot ancora in attesa (non ancora nei risultati) */} + {Array.from({ length: total - results.length }).map((_, i) => ( +
+
+ Post {results.length + i + 1} — in attesa +
+ ))} +
+ )} + + {/* Messaggio quando nessun risultato ancora */} + {results.length === 0 && status === 'running' && ( +

+ Generazione in corso — i risultati appariranno qui... +

+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Singola riga stato post +// --------------------------------------------------------------------------- + +function PostStatusRow({ result }: { result: PostResult }) { + const { slot_index, status, post, error } = result + + const icon = { + success: , + failed: , + pending: , + }[status] + + const title = post?.cover_title ?? (error ? 'Errore' : `Post ${slot_index + 1}`) + + return ( +
+ {icon} + + Post {slot_index + 1} + {title && title !== `Post ${slot_index + 1}` && ( + <> — {title} + )} + {error && ({error.slice(0, 60)}...)} + +
+ ) +} diff --git a/frontend/src/components/SlideViewer.tsx b/frontend/src/components/SlideViewer.tsx index 258b8a1..3ca02f1 100644 --- a/frontend/src/components/SlideViewer.tsx +++ b/frontend/src/components/SlideViewer.tsx @@ -1,20 +1,371 @@ /** - * SlideViewer — stub per Task 2a. - * Completato in Task 2b. + * SlideViewer — visualizzazione slide-by-slide con navigazione frecce e edit inline. + * + * Struttura carosello: + * Slide 0 = Cover (cover_title, cover_subtitle, cover_image_keyword) + * Slide 1-6 = Slide centrali s2-s7 (headline, body, image_keyword) + * Slide 7 = CTA (cta_text, cta_subtext, cta_image_keyword) + * + Caption Instagram editabile + * + * Ogni campo testo è editabile inline: click trasforma in input/textarea. + * Le modifiche aggiornano lo stato locale e propagano via callback onEdit. + * Keyboard navigation: frecce sinistra/destra. */ -import type { PostResult } from '../types' +import { ChevronLeft, ChevronRight, Edit3, Image } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { GeneratedPost, PostResult, SlideContent } from '../types' + +// Numero totale di slide: Cover + 6 centrali + CTA = 8 +const TOTAL_SLIDES = 8 interface SlideViewerProps { result: PostResult onEdit: (updated: PostResult) => void } -export function SlideViewer({ result, onEdit: _onEdit }: SlideViewerProps) { - if (!result.post) return null +// --------------------------------------------------------------------------- +// Componente campo editabile inline +// --------------------------------------------------------------------------- + +interface EditableFieldProps { + value: string + multiline?: boolean + className?: string + placeholder?: string + onChange: (v: string) => void +} + +function EditableField({ value, multiline = false, className = '', placeholder, onChange }: EditableFieldProps) { + const [editing, setEditing] = useState(false) + const inputRef = useRef(null) + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus() + if ('select' in inputRef.current) { + inputRef.current.select() + } + } + }, [editing]) + + function handleBlur() { + setEditing(false) + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (!multiline && e.key === 'Enter') { + setEditing(false) + } + if (e.key === 'Escape') { + setEditing(false) + } + } + + const baseClasses = 'w-full bg-transparent outline-none border-0 resize-none' + const editClasses = 'ring-1 ring-amber-500/50 rounded px-1.5 py-1 bg-stone-700/50' + + if (editing) { + if (multiline) { + return ( +