Files
postgenerator/frontend/src/pages/OutputReview.tsx
Michele f154f1b2f6 feat(04-02): thumbnail PostCard e hint Unsplash in OutputReview
- PostCard: thumbnail 80x56px della cover image quando keyword e' URL
  - Rilevamento con startsWith('http')
  - object-cover, loading=lazy, onError nasconde se URL non valido
  - Posizionato dopo cover_title e prima dei metadati secondari
- OutputReview: hint discreto Unsplash sotto il box info edit inline
  - Visibile solo se unsplash_api_key_configured === false
  - Link a /impostazioni con stile amber discreto
  - Scompare automaticamente dopo configurazione Unsplash
  - Usa Link da react-router-dom (pattern codebase)
2026-03-09 08:16:55 +01:00

216 lines
8.3 KiB
TypeScript

/**
* Pagina Output Review.
*
* Carica i risultati di un job completato e mostra:
* - Griglia di PostCard con badge PN e Schwartz
* - SlideViewer inline espandibile con edit inline
* - Pulsante Download CSV (POST /api/export/{jobId}/csv con edits)
*
* Lo stato dei post (con le modifiche inline) è mantenuto localmente
* e inviato al backend via POST al momento del download CSV.
*/
import { Download, Loader2, RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useDownloadEditedCsv, useJobResults, useSettingsStatus } from '../api/hooks'
import { PostCard } from '../components/PostCard'
import type { PostResult } from '../types'
export function OutputReview() {
const { jobId } = useParams<{ jobId: string }>()
const { data: jobData, isLoading, error } = useJobResults(jobId ?? null)
const downloadMutation = useDownloadEditedCsv()
const { data: settingsStatus } = useSettingsStatus()
// Stato locale dei post — viene aggiornato da edit inline e rigenerazione
const [localResults, setLocalResults] = useState<PostResult[]>([])
// Set di indici dei post che sono stati rigenerati
const [regeneratedSlots, setRegeneratedSlots] = useState<Set<number>>(new Set())
// Inizializza i risultati e merge con CalendarSlot dal calendario
useEffect(() => {
if (jobData?.results) {
const slots = jobData.calendar?.slots
const merged = jobData.results.map((r) => ({
...r,
slot: slots?.find((s) => s.indice === r.slot_index) ?? r.slot,
}))
setLocalResults(merged)
}
}, [jobData])
function handleEdit(updated: PostResult) {
setLocalResults((prev) =>
prev.map((r) => (r.slot_index === updated.slot_index ? updated : r))
)
}
function handleRegenerated(updated: PostResult) {
setLocalResults((prev) =>
prev.map((r) => (r.slot_index === updated.slot_index ? updated : r))
)
setRegeneratedSlots((prev) => new Set(prev).add(updated.slot_index))
}
async function handleDownloadCsv() {
if (!jobId || !jobData) return
await downloadMutation.mutateAsync({
jobId,
results: localResults,
campagna: jobData.campagna,
})
}
// ---------------------------------------------------------------------------
// Loading / Error states
// ---------------------------------------------------------------------------
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<Loader2 className="animate-spin text-stone-500" size={24} />
</div>
)
}
if (error || !jobData) {
return (
<div className="max-w-2xl mx-auto px-6 py-10">
<div className="px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
{error?.message ?? 'Risultati non trovati. Il job potrebbe non essere completato.'}
</div>
</div>
)
}
const successCount = localResults.filter((r) => r.status === 'success').length
const failedCount = localResults.filter((r) => r.status === 'failed').length
const regeneratedCount = regeneratedSlots.size
// Conteggio post modificati manualmente (diversi dall'originale, esclusi i rigenerati)
const editedCount = localResults.filter((r) => {
if (r.status !== 'success' || !r.post) return false
const original = jobData?.results?.find((o) => o.slot_index === r.slot_index)
if (!original?.post) return false
return JSON.stringify(r.post) !== JSON.stringify(original.post)
}).length
const manuallyEditedCount = Math.max(0, editedCount - regeneratedCount)
// ---------------------------------------------------------------------------
// UI principale
// ---------------------------------------------------------------------------
return (
<div className="max-w-4xl mx-auto px-6 py-10 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-stone-100">Output Review</h1>
<p className="mt-1 text-stone-400 text-sm">{jobData.campagna}</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2">
<span className="text-xs text-stone-300 font-medium">{localResults.length} post</span>
<span className="text-xs text-emerald-400">{successCount} generati</span>
{failedCount > 0 && (
<span className="text-xs text-red-400">{failedCount} falliti</span>
)}
{regeneratedCount > 0 && (
<span className="text-xs text-amber-400 flex items-center gap-1">
<RefreshCw size={10} />
{regeneratedCount} rigenerati
</span>
)}
{manuallyEditedCount > 0 && (
<span className="text-xs text-blue-400">
{manuallyEditedCount} modificati
</span>
)}
<span className="text-xs text-stone-600">job: {jobId}</span>
</div>
</div>
{/* Download CSV */}
<button
onClick={handleDownloadCsv}
disabled={downloadMutation.isPending || successCount === 0}
className="flex-shrink-0 flex items-center gap-2 px-4 py-2.5 rounded-lg bg-amber-500 text-stone-950 text-sm font-semibold hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{downloadMutation.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{downloadMutation.isPending ? 'Download...' : 'Download CSV'}
</button>
</div>
{/* Errore download */}
{downloadMutation.error && (
<div className="px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
Errore download: {downloadMutation.error.message}
</div>
)}
{/* Info edit inline */}
<div className="px-4 py-2.5 rounded-lg bg-stone-800/50 border border-stone-700 text-xs text-stone-500">
Clicca su una card per espandere le slide. I campi di testo sono editabili inline.
Usa il pulsante <RefreshCw size={10} className="inline" /> per rigenerare singoli post con topic diversi.
Le modifiche saranno incluse nel CSV scaricato.
</div>
{/* Hint Unsplash — visibile solo se non configurato */}
{settingsStatus && !settingsStatus.unsplash_api_key_configured && (
<div className="px-4 py-2 rounded-lg bg-stone-800/30 border border-stone-700/50 text-xs text-stone-600 flex items-center gap-2 flex-wrap">
<span>Le colonne immagine contengono keyword testuali.</span>
<Link
to="/impostazioni"
className="text-amber-500/70 hover:text-amber-400 underline underline-offset-2 transition-colors"
>
Configura Unsplash
</Link>
<span>per URL immagini reali nel CSV.</span>
</div>
)}
{/* Griglia post */}
<div className="space-y-3">
{localResults.length === 0 ? (
<p className="text-sm text-stone-500 text-center py-8">
Nessun risultato disponibile.
</p>
) : (
localResults.map((result) => (
<PostCard
key={result.slot_index}
result={result}
obiettivoCampagna={jobData.campagna}
brandName={null}
tono={null}
onRegenerated={handleRegenerated}
onEdit={handleEdit}
isRegenerated={regeneratedSlots.has(result.slot_index)}
/>
))
)}
</div>
{/* Download CSV footer */}
{successCount > 0 && (
<div className="flex justify-center pt-4">
<button
onClick={handleDownloadCsv}
disabled={downloadMutation.isPending}
className="flex items-center gap-2 px-6 py-3 rounded-lg bg-amber-500 text-stone-950 text-sm font-semibold hover:bg-amber-400 disabled:opacity-50 transition-colors"
>
{downloadMutation.isPending ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Download size={15} />
)}
{downloadMutation.isPending ? 'Download CSV...' : `Scarica CSV (${successCount} post)`}
</button>
</div>
)}
</div>
)
}