feat(01-04): SlideViewer con edit inline e ProgressIndicator con polling

- SlideViewer.tsx: navigazione frecce sinistra/destra tra 8 slide (Cover + 6 centrali + CTA)
  - EditableField component: click-to-edit per ogni campo (input/textarea)
  - Dot indicators + keyboard navigation (frecce sinistra/destra)
  - Caption Instagram editabile con contatore caratteri
  - Callback onEdit propaga modifiche al parent
- ProgressIndicator.tsx: polling real-time via useJobStatus(jobId)
  - Barra progresso visuale con percentuale
  - Lista post con icone stato: pending/running/success/failed
  - onComplete(jobId) chiamato quando status diventa 'completed'
  - Poll ogni 2s (condizionale nel hook, si ferma quando completed/failed)
This commit is contained in:
Michele
2026-03-08 02:26:54 +01:00
parent a2ebd72041
commit 9e5bddc312
2 changed files with 511 additions and 7 deletions

View File

@@ -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 (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30">
<AlertTriangle size={16} className="text-red-400" />
<p className="text-sm text-red-400">Errore polling: {error.message}</p>
</div>
)
}
if (!jobStatus) {
return (
<div className="flex items-center gap-2 text-stone-400 text-sm">
<Loader2 size={16} className="animate-spin" />
Connessione al server...
</div>
)
}
const { total, completed, status, results } = jobStatus
const pct = total > 0 ? Math.round((completed / total) * 100) : 0
return (
<div className="space-y-4">
{/* Header con stato */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{status === 'running' && (
<Loader2 size={16} className="animate-spin text-amber-400" />
)}
{status === 'completed' && (
<Check size={16} className="text-emerald-400" />
)}
{status === 'failed' && (
<X size={16} className="text-red-400" />
)}
<span className="text-sm font-medium text-stone-200">
{status === 'running' && `Post ${completed} / ${total} in generazione...`}
{status === 'completed' && `Generazione completata — ${completed} / ${total} post`}
{status === 'failed' && 'Generazione fallita'}
</span>
</div>
<span className="text-sm font-mono text-stone-500">{pct}%</span>
</div>
{/* Barra di progresso */}
<div className="w-full h-2 bg-stone-800 rounded-full overflow-hidden">
<div
className={[
'h-full rounded-full transition-all duration-500',
status === 'failed' ? 'bg-red-500' : 'bg-amber-500',
].join(' ')}
style={{ width: `${pct}%` }}
/>
</div>
{/* Lista post con stato individuale */}
{results.length > 0 && (
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
{/* Rende i risultati ricevuti finora */}
{results.map((r: PostResult) => (
<PostStatusRow key={r.slot_index} result={r} />
))}
{/* Slot ancora in attesa (non ancora nei risultati) */}
{Array.from({ length: total - results.length }).map((_, i) => (
<div
key={`pending-${i}`}
className="flex items-center gap-2 px-2 py-1.5 rounded text-xs text-stone-600"
>
<div className="w-3 h-3 rounded-full border border-stone-700 flex-shrink-0" />
Post {results.length + i + 1} in attesa
</div>
))}
</div>
)}
{/* Messaggio quando nessun risultato ancora */}
{results.length === 0 && status === 'running' && (
<p className="text-xs text-stone-600 text-center py-2">
Generazione in corso i risultati appariranno qui...
</p>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Singola riga stato post
// ---------------------------------------------------------------------------
function PostStatusRow({ result }: { result: PostResult }) {
const { slot_index, status, post, error } = result
const icon = {
success: <Check size={12} className="text-emerald-400" />,
failed: <X size={12} className="text-red-400" />,
pending: <Loader2 size={12} className="animate-spin text-stone-500" />,
}[status]
const title = post?.cover_title ?? (error ? 'Errore' : `Post ${slot_index + 1}`)
return (
<div className="flex items-start gap-2 px-2 py-1.5 rounded text-xs">
<span className="flex-shrink-0 mt-0.5">{icon}</span>
<span
className={
status === 'success'
? 'text-stone-300'
: status === 'failed'
? 'text-red-400'
: 'text-stone-600'
}
>
Post {slot_index + 1}
{title && title !== `Post ${slot_index + 1}` && (
<> <span className="text-stone-400">{title}</span></>
)}
{error && <span className="text-red-500"> ({error.slice(0, 60)}...)</span>}
</span>
</div>
)
}

View File

@@ -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<HTMLInputElement | HTMLTextAreaElement>(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 (
<div className="text-sm text-stone-400 italic">
Slide viewer {result.post.slides.length} slide
<textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
rows={4}
className={[baseClasses, editClasses, className].join(' ')}
placeholder={placeholder}
/>
)
}
return (
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={[baseClasses, editClasses, className].join(' ')}
placeholder={placeholder}
/>
)
}
return (
<span
role="button"
tabIndex={0}
onClick={() => setEditing(true)}
onKeyDown={(e) => e.key === 'Enter' && setEditing(true)}
title="Click per modificare"
className={[
'cursor-text hover:bg-stone-700/30 rounded px-1 -mx-1 transition-colors group relative',
className,
].join(' ')}
>
{value || <span className="text-stone-600">{placeholder}</span>}
<Edit3
size={10}
className="inline-block ml-1 text-stone-600 opacity-0 group-hover:opacity-100 transition-opacity"
/>
</span>
)
}
// ---------------------------------------------------------------------------
// SlideViewer
// ---------------------------------------------------------------------------
export function SlideViewer({ result, onEdit }: SlideViewerProps) {
const [currentSlide, setCurrentSlide] = useState(0)
// Copia locale del post per edit inline
const [post, setPost] = useState<GeneratedPost>(() => ({ ...result.post! }))
// Sync se il post esterno cambia (es. dopo rigenerazione)
useEffect(() => {
if (result.post) {
setPost({ ...result.post })
}
}, [result.post])
if (!result.post) return null
// Helper per aggiornare il post e propagare
function updatePost(updater: (prev: GeneratedPost) => GeneratedPost) {
setPost((prev) => {
const next = updater(prev)
// Propaga le modifiche al parent
onEdit({ ...result, post: next })
return next
})
}
// Aggiorna campo cover
function updateCover(field: keyof GeneratedPost, value: string) {
updatePost((p) => ({ ...p, [field]: value }))
}
// Aggiorna campo slide centrale (indice 0-based tra le 6 slide)
function updateSlide(slideIdx: number, field: keyof SlideContent, value: string) {
updatePost((p) => {
const slides = [...p.slides]
slides[slideIdx] = { ...slides[slideIdx], [field]: value }
return { ...p, slides }
})
}
// Navigazione
const goNext = useCallback(() => setCurrentSlide((n) => Math.min(n + 1, TOTAL_SLIDES - 1)), [])
const goPrev = useCallback(() => setCurrentSlide((n) => Math.max(n - 1, 0)), [])
// Keyboard navigation
useEffect(() => {
function handler(e: KeyboardEvent) {
if (e.key === 'ArrowRight') goNext()
if (e.key === 'ArrowLeft') goPrev()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [goNext, goPrev])
// ---------------------------------------------------------------------------
// Render slide corrente
// ---------------------------------------------------------------------------
function renderSlideContent() {
// Slide 0 = Cover
if (currentSlide === 0) {
return (
<div className="space-y-3">
<div>
<p className="text-xs text-stone-500 mb-1">Titolo cover</p>
<p className="text-base font-bold text-stone-100">
<EditableField
value={post.cover_title}
onChange={(v) => updateCover('cover_title', v)}
placeholder="Titolo cover"
className="text-base font-bold"
/>
</p>
</div>
<div>
<p className="text-xs text-stone-500 mb-1">Sottotitolo</p>
<p className="text-sm text-stone-300">
<EditableField
value={post.cover_subtitle}
onChange={(v) => updateCover('cover_subtitle', v)}
placeholder="Sottotitolo cover"
className="text-sm"
/>
</p>
</div>
<div className="flex items-center gap-2 text-xs text-stone-500">
<Image size={12} />
<EditableField
value={post.cover_image_keyword}
onChange={(v) => updateCover('cover_image_keyword', v)}
placeholder="keyword immagine"
className="text-xs"
/>
</div>
</div>
)
}
// Slide 7 = CTA
if (currentSlide === 7) {
return (
<div className="space-y-3">
<div>
<p className="text-xs text-stone-500 mb-1">CTA principale</p>
<p className="text-base font-bold text-amber-300">
<EditableField
value={post.cta_text}
onChange={(v) => updateCover('cta_text', v)}
placeholder="Testo CTA"
className="text-base font-bold text-amber-300"
/>
</p>
</div>
<div>
<p className="text-xs text-stone-500 mb-1">Testo supporto</p>
<p className="text-sm text-stone-300">
<EditableField
value={post.cta_subtext}
onChange={(v) => updateCover('cta_subtext', v)}
placeholder="Testo di supporto CTA"
multiline
className="text-sm"
/>
</p>
</div>
<div className="flex items-center gap-2 text-xs text-stone-500">
<Image size={12} />
<EditableField
value={post.cta_image_keyword}
onChange={(v) => updateCover('cta_image_keyword', v)}
placeholder="keyword immagine CTA"
className="text-xs"
/>
</div>
</div>
)
}
// Slide 1-6 = slide centrali (indice 0-5 in post.slides)
const slideIdx = currentSlide - 1
const slide = post.slides[slideIdx]
if (!slide) return null
return (
<div className="space-y-3">
<div>
<p className="text-xs text-stone-500 mb-1">Headline</p>
<p className="text-base font-semibold text-stone-100">
<EditableField
value={slide.headline}
onChange={(v) => updateSlide(slideIdx, 'headline', v)}
placeholder="Headline slide"
className="text-base font-semibold"
/>
</p>
</div>
<div>
<p className="text-xs text-stone-500 mb-1">Body</p>
<p className="text-sm text-stone-300 leading-relaxed">
<EditableField
value={slide.body}
onChange={(v) => updateSlide(slideIdx, 'body', v)}
placeholder="Testo corpo slide"
multiline
className="text-sm leading-relaxed"
/>
</p>
</div>
<div className="flex items-center gap-2 text-xs text-stone-500">
<Image size={12} />
<EditableField
value={slide.image_keyword}
onChange={(v) => updateSlide(slideIdx, 'image_keyword', v)}
placeholder="keyword immagine"
className="text-xs"
/>
</div>
</div>
)
}
// Label per la slide corrente
function slideLabel() {
if (currentSlide === 0) return 'Cover'
if (currentSlide === 7) return 'CTA'
return `Slide ${currentSlide + 1} / ${TOTAL_SLIDES}`
}
return (
<div className="space-y-4">
{/* Slide viewer con navigazione */}
<div className="flex items-center gap-2">
{/* Freccia sinistra */}
<button
onClick={goPrev}
disabled={currentSlide === 0}
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center border border-stone-700 text-stone-400 hover:text-stone-200 hover:border-stone-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} />
</button>
{/* Area slide */}
<div className="flex-1 min-h-[140px] rounded-lg bg-stone-900 border border-stone-700 px-4 py-3">
{/* Indicatore slide */}
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-stone-500">{slideLabel()}</span>
<div className="flex gap-1">
{Array.from({ length: TOTAL_SLIDES }).map((_, i) => (
<button
key={i}
onClick={() => setCurrentSlide(i)}
className={[
'w-1.5 h-1.5 rounded-full transition-colors',
i === currentSlide ? 'bg-amber-400' : 'bg-stone-700 hover:bg-stone-600',
].join(' ')}
/>
))}
</div>
</div>
{renderSlideContent()}
</div>
{/* Freccia destra */}
<button
onClick={goNext}
disabled={currentSlide === TOTAL_SLIDES - 1}
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center border border-stone-700 text-stone-400 hover:text-stone-200 hover:border-stone-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
{/* Caption Instagram */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-stone-500">Caption Instagram</p>
<textarea
value={post.caption_instagram}
onChange={(e) =>
updatePost((p) => ({ ...p, caption_instagram: e.target.value }))
}
rows={5}
placeholder="Caption Instagram..."
className="w-full px-3 py-2 rounded-lg bg-stone-900 border border-stone-700 text-stone-300 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-amber-500/50 focus:border-amber-500/50"
/>
<p className="text-xs text-stone-600">
{post.caption_instagram.length}/2200 caratteri
</p>
</div>
</div>
)
}