saas-veille / components /BatchGuideModal.tsx
PrestaGhis's picture
Update components/BatchGuideModal.tsx
1f43772 verified
Raw
History Blame Contribute Delete
20.6 kB
'use client'
import React, { useState, useEffect, useRef } from 'react'
import {
X, Loader2, CheckCircle2, AlertTriangle,
Download, Sparkles, FileText, RefreshCw
} from 'lucide-react'
import {
Document, Packer, Paragraph, TextRun, HeadingLevel,
AlignmentType, BorderStyle, ShadingType
} from 'docx'
// ── Types ─────────────────────────────────────────────────────────────────────
export interface BatchItem {
id: string
title: string // titre de l'article source
keyword: string // titres[0] du brief ou title
url?: string // URL de l'article d'origine
source_name?: string // nom de la source / publication
brief_data: {
angle?: string
importance?: string
fenetre?: string
mots_cles?: string[]
titres?: string[]
format?: string
} | null
}
interface GuideResult extends BatchItem {
step: 'pending' | 'briefing' | 'guiding' | 'done' | 'error'
brief_data_resolved?: BatchItem['brief_data']
guide_plan?: string
errorMsg?: string
}
interface Props {
items: BatchItem[]
clientId: string | null
onClose: () => void
}
// ── Markdown → HTML (léger, pour l'export) ────────────────────────────────────
function mdToHtml(md: string): string {
if (!md) return ''
const lines = md.split('\n')
const out: string[] = []
let ulBuf: string[] = []
let olBuf: string[] = []
const flushUl = () => {
if (ulBuf.length) { out.push(`<ul>${ulBuf.map(i => `<li>${i}</li>`).join('')}</ul>`); ulBuf = [] }
}
const flushOl = () => {
if (olBuf.length) { out.push(`<ol>${olBuf.map(i => `<li>${i}</li>`).join('')}</ol>`); olBuf = [] }
}
const inline = (t: string) =>
t.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
.replace(/_([^_]+)_/g, '<em>$1</em>')
for (const raw of lines) {
const line = raw.trim()
if (/^#{1,6}\s/.test(line)) {
flushUl(); flushOl()
const lvl = Math.min(Math.max(line.match(/^(#{1,6})\s/)![1].length, 2), 4)
out.push(`<h${lvl}>${inline(line.replace(/^#{1,6}\s+/, ''))}</h${lvl}>`)
} else if (/^[-*+]\s+/.test(line)) {
flushOl(); ulBuf.push(inline(line.replace(/^[-*+]\s+/, '')))
} else if (/^\d+\.\s+/.test(line)) {
flushUl(); olBuf.push(inline(line.replace(/^\d+\.\s+/, '')))
} else if (line === '') {
flushUl(); flushOl(); out.push('<p><br></p>')
} else {
flushUl(); flushOl(); out.push(`<p>${inline(line)}</p>`)
}
}
flushUl(); flushOl()
return out.join('\n').replace(/(<p><br><\/p>\s*){2,}/g, '<p><br></p>')
}
// ── Parseur markdown → paragraphes docx ──────────────────────────────────────
function mdToDocxParagraphs(md: string): Paragraph[] {
if (!md) return []
const paragraphs: Paragraph[] = []
// Parse les runs inline (gras, italique)
function parseRuns(text: string): TextRun[] {
const runs: TextRun[] = []
// Regex pour **gras**, *italique*, ***gras+italique***
const regex = /\*\*\*([^*]+)\*\*\*|\*\*([^*]+)\*\*|\*([^*]+)\*/g
let last = 0, match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
if (match.index > last) runs.push(new TextRun({ text: text.slice(last, match.index) }))
if (match[1]) runs.push(new TextRun({ text: match[1], bold: true, italics: true }))
else if (match[2]) runs.push(new TextRun({ text: match[2], bold: true }))
else if (match[3]) runs.push(new TextRun({ text: match[3], italics: true }))
last = match.index + match[0].length
}
if (last < text.length) runs.push(new TextRun({ text: text.slice(last) }))
return runs.length > 0 ? runs : [new TextRun({ text })]
}
const lines = md.split('\n')
for (const raw of lines) {
const line = raw.trim()
if (!line) {
paragraphs.push(new Paragraph({ text: '' }))
continue
}
// Titres
const hMatch = line.match(/^(#{1,4})\s+(.+)$/)
if (hMatch) {
const lvl = hMatch[1].length
const headingMap: Record<number, HeadingLevel> = {
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4,
}
paragraphs.push(new Paragraph({
text: hMatch[2],
heading: headingMap[Math.min(lvl, 4)],
}))
continue
}
// Liste non ordonnée
if (/^[-*+]\s+/.test(line)) {
paragraphs.push(new Paragraph({
children: parseRuns(line.replace(/^[-*+]\s+/, '')),
bullet: { level: 0 },
}))
continue
}
// Liste ordonnée
if (/^\d+\.\s+/.test(line)) {
paragraphs.push(new Paragraph({
children: parseRuns(line.replace(/^\d+\.\s+/, '')),
numbering: { reference: 'default-numbering', level: 0 },
}))
continue
}
// Paragraphe normal
paragraphs.push(new Paragraph({ children: parseRuns(line) }))
}
return paragraphs
}
// ── Génération du fichier .docx ───────────────────────────────────────────────
// ── Helpers titres sans style Word natif (évite l'auto-capitalisation Title Case) ──
function h1Manual(text: string): Paragraph {
return new Paragraph({
children: [new TextRun({ text, bold: true, size: 36, color: '2E74B5' })],
spacing: { before: 360, after: 160 },
})
}
function h2Manual(text: string): Paragraph {
return new Paragraph({
children: [new TextRun({ text, bold: true, size: 26, color: '2E74B5' })],
spacing: { before: 280, after: 100 },
})
}
async function buildDocx(results: GuideResult[]): Promise<Blob> {
const date = new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
const children: Paragraph[] = []
const doneResults = results.filter(r => r.guide_plan)
// ── Page de titre ──────────────────────────────────────────────
children.push(
new Paragraph({ text: 'Système Argos', heading: HeadingLevel.TITLE }),
h1Manual('Guides de rédaction'),
new Paragraph({ text: `Généré le ${date} · ${doneResults.length} guide(s)`, style: 'aside' }),
new Paragraph({ text: '' }),
)
for (const r of doneResults) {
const bData = r.brief_data_resolved || r.brief_data
// ── 1. Titre d'origine ─────────────────────────────────────
children.push(
new Paragraph({ text: '' }),
h1Manual(r.title),
)
// ── 2. Source ──────────────────────────────────────────────
if (r.source_name || r.url) {
const sourceRuns: TextRun[] = [new TextRun({ text: 'Source : ', bold: true })]
if (r.source_name) sourceRuns.push(new TextRun({ text: r.source_name }))
if (r.source_name && r.url) sourceRuns.push(new TextRun({ text: ' — ' }))
if (r.url) sourceRuns.push(new TextRun({ text: r.url, style: 'Hyperlink' }))
children.push(new Paragraph({ children: sourceRuns }))
}
children.push(new Paragraph({ text: '' }))
// ── 3. Suggestions de titres alternatifs ───────────────────
if (Array.isArray(bData?.titres) && bData.titres.length > 0) {
children.push(h2Manual('Titres alternatifs suggérés'))
bData.titres.forEach((titre, i) => {
children.push(new Paragraph({
children: [
new TextRun({ text: `#${i + 1} `, bold: true, color: '7C3AED' }),
new TextRun({ text: titre, bold: true }),
],
indent: { left: 360 },
}))
})
children.push(new Paragraph({ text: '' }))
}
// ── 4. Brief stratégique ───────────────────────────────────
if (bData && (bData.angle || bData.importance || bData.fenetre || bData.mots_cles?.length || bData.format)) {
children.push(h2Manual('Brief stratégique'))
const briefFields: Array<[string, string | undefined]> = [
['Angle', bData.angle],
['Enjeu', bData.importance],
['Fenêtre', bData.fenetre],
['Format', bData.format],
]
for (const [label, value] of briefFields) {
if (!value) continue
children.push(new Paragraph({
children: [
new TextRun({ text: `${label} : `, bold: true }),
new TextRun({ text: value }),
]
}))
}
if (Array.isArray(bData.mots_cles) && bData.mots_cles.length) {
children.push(new Paragraph({
children: [
new TextRun({ text: 'Mots-clés : ', bold: true }),
new TextRun({ text: bData.mots_cles.join(', '), italics: true }),
]
}))
}
children.push(new Paragraph({ text: '' }))
}
// ── 5. Plan de Rédaction ───────────────────────────────────
children.push(h2Manual('Plan de rédaction'))
children.push(...mdToDocxParagraphs(r.guide_plan!))
children.push(new Paragraph({ text: '' }))
}
const doc = new Document({
numbering: {
config: [{
reference: 'default-numbering',
levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.LEFT }],
}],
},
sections: [{ properties: {}, children }],
})
return await Packer.toBlob(doc)
}
// ── Composant principal ───────────────────────────────────────────────────────
export default function BatchGuideModal({ items, clientId, onClose }: Props) {
const [results, setResults] = useState<GuideResult[]>(
items.map(item => ({ ...item, step: 'pending' }))
)
const [isRunning, setIsRunning] = useState(false)
const [isDone, setIsDone] = useState(false)
const hasStarted = useRef(false)
const doneCount = results.filter(r => r.step === 'done').length
const errorCount = results.filter(r => r.step === 'error').length
const totalCount = results.length
// ── Lance la génération au montage ─────────────────────────────
useEffect(() => {
if (hasStarted.current) return
hasStarted.current = true
runBatch()
}, [])
const updateResult = (id: string, patch: Partial<GuideResult>) =>
setResults(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r))
async function runBatch() {
setIsRunning(true)
const successTitles: string[] = []
for (const item of items) {
// ── Étape 1 : générer le brief si absent ─────────────────
let briefData = item.brief_data
if (!briefData) {
updateResult(item.id, { step: 'briefing' })
try {
const res = await fetch('/api/brief-discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: item.title,
card_type: 'article',
}),
})
const data = await res.json()
if (res.ok && data.brief) briefData = data.brief
} catch (_) { /* on continue sans brief */ }
}
// ── Étape 2 : générer le guide ────────────────────────────
updateResult(item.id, { step: 'guiding', brief_data_resolved: briefData })
try {
const res = await fetch('/api/content/guide-from-brief', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
keyword: item.keyword,
brief_data: briefData,
client_id: clientId,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erreur API')
updateResult(item.id, { step: 'done', guide_plan: data.guide_plan, brief_data_resolved: briefData ?? undefined })
successTitles.push(item.title)
} catch (err: any) {
updateResult(item.id, { step: 'error', errorMsg: err.message })
}
}
setIsRunning(false)
setIsDone(true)
// ── Enregistrement en base (non-bloquant) ─────────────────────
if (successTitles.length > 0) {
fetch('/api/batch-guides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: clientId || null,
article_count: successTitles.length,
article_titles: successTitles,
}),
}).catch(err => console.warn('[batch-guides save]', err))
}
}
async function handleDownload() {
try {
const blob = await buildDocx(results)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `guides-redaction-argos-${new Date().toISOString().slice(0, 10)}.docx`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
console.error('[docx export]', err)
alert('Erreur lors de la génération du fichier.')
}
}
// ── Rendu ─────────────────────────────────────────────────────
const progressPct = totalCount > 0 ? Math.round((doneCount + errorCount) / totalCount * 100) : 0
return (
<>
{/* Overlay */}
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[200]" onClick={isDone ? onClose : undefined} />
{/* Modal */}
<div className="fixed inset-0 z-[201] flex items-center justify-center p-4">
<div className="w-full max-w-xl bg-[#0e0e16] border border-white/10 rounded-3xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-white/5 flex items-center justify-between bg-gradient-to-r from-indigo-500/10 to-purple-500/10">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-indigo-500/20 flex items-center justify-center">
<Sparkles size={16} className="text-indigo-400" />
</div>
<div>
<h2 className="text-sm font-black text-white tracking-tight">Génération en batch</h2>
<p className="text-[10px] text-white/40 uppercase tracking-widest font-bold">
{isRunning ? 'En cours…' : isDone ? 'Terminé' : 'Prêt'}
</p>
</div>
</div>
{isDone && (
<button onClick={onClose} className="p-2 rounded-xl hover:bg-white/10 text-white/40 hover:text-white transition-all">
<X size={16} />
</button>
)}
</div>
{/* Barre de progression globale */}
<div className="px-6 pt-5 pb-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-white/60">
{doneCount + errorCount} / {totalCount} traités
</span>
<span className="text-xs font-black text-indigo-400">{progressPct}%</span>
</div>
<div className="w-full h-1.5 bg-white/5 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
{/* Liste des items */}
<div className="px-6 py-4 space-y-2 max-h-72 overflow-y-auto">
{results.map(r => (
<div
key={r.id}
className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${
r.step === 'done' ? 'bg-emerald-500/5 border-emerald-500/20' :
r.step === 'error' ? 'bg-red-500/5 border-red-500/20' :
r.step === 'guiding' || r.step === 'briefing' ? 'bg-indigo-500/10 border-indigo-500/30' :
'bg-white/2 border-white/5'
}`}
>
{/* Icône statut */}
<div className="shrink-0">
{r.step === 'done' && <CheckCircle2 size={16} className="text-emerald-400" />}
{r.step === 'error' && <AlertTriangle size={16} className="text-red-400" />}
{(r.step === 'guiding' || r.step === 'briefing') && (
<Loader2 size={16} className="text-indigo-400 animate-spin" />
)}
{r.step === 'pending' && <div className="w-4 h-4 rounded-full border-2 border-white/10" />}
</div>
{/* Titre + statut */}
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-white truncate">{r.title}</p>
<p className={`text-[10px] mt-0.5 ${
r.step === 'done' ? 'text-emerald-400' :
r.step === 'error' ? 'text-red-400' :
r.step === 'guiding' ? 'text-indigo-400' :
r.step === 'briefing'? 'text-amber-400' :
'text-white/30'
}`}>
{r.step === 'pending' && 'En attente…'}
{r.step === 'briefing' && 'Génération du brief…'}
{r.step === 'guiding' && 'Génération du guide…'}
{r.step === 'done' && 'Guide prêt ✓'}
{r.step === 'error' && (r.errorMsg || 'Erreur')}
</p>
</div>
</div>
))}
</div>
{/* Footer */}
<div className="px-6 py-5 border-t border-white/5 bg-white/2">
{isDone ? (
<div className="space-y-3">
{/* Résumé */}
<div className="flex items-center gap-4 text-[11px]">
<span className="flex items-center gap-1.5 text-emerald-400 font-bold">
<CheckCircle2 size={12} /> {doneCount} guide{doneCount > 1 ? 's' : ''} générés
</span>
{errorCount > 0 && (
<span className="flex items-center gap-1.5 text-red-400 font-bold">
<AlertTriangle size={12} /> {errorCount} erreur{errorCount > 1 ? 's' : ''}
</span>
)}
</div>
{/* Bouton téléchargement */}
{doneCount > 0 && (
<>
<button
onClick={handleDownload}
className="w-full flex items-center justify-center gap-2 py-3 bg-gradient-to-r from-indigo-500 to-purple-600 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:opacity-90 transition-all shadow-lg shadow-indigo-500/20"
>
<Download size={14} />
Télécharger les guides (.docx)
</button>
<p className="text-[10px] text-white/30 text-center leading-relaxed">
Glissez le fichier dans <strong className="text-white/50">Google Drive</strong> → clic droit → <em>Ouvrir avec Google Docs</em>
</p>
</>
)}
</div>
) : (
<div className="flex items-center gap-2 text-[11px] text-white/40">
<Loader2 size={12} className="animate-spin text-indigo-400" />
<span>Génération en cours, ne fermez pas cette fenêtre…</span>
</div>
)}
</div>
</div>
</div>
</>
)
}