'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(`
${ulBuf.map(i => `- ${i}
`).join('')}
`); ulBuf = [] }
}
const flushOl = () => {
if (olBuf.length) { out.push(`${olBuf.map(i => `- ${i}
`).join('')}
`); olBuf = [] }
}
const inline = (t: string) =>
t.replace(/\*\*\*([^*]+)\*\*\*/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/_([^_]+)_/g, '$1')
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(`${inline(line.replace(/^#{1,6}\s+/, ''))}`)
} 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('
')
} else {
flushUl(); flushOl(); out.push(`${inline(line)}
`)
}
}
flushUl(); flushOl()
return out.join('\n').replace(/(
<\/p>\s*){2,}/g, '
')
}
// ── 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 = {
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 {
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(
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) =>
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 */}
{/* Modal */}
{/* Header */}
Génération en batch
{isRunning ? 'En cours…' : isDone ? 'Terminé' : 'Prêt'}
{isDone && (
)}
{/* Barre de progression globale */}
{doneCount + errorCount} / {totalCount} traités
{progressPct}%
{/* Liste des items */}
{results.map(r => (
{/* Icône statut */}
{r.step === 'done' &&
}
{r.step === 'error' &&
}
{(r.step === 'guiding' || r.step === 'briefing') && (
)}
{r.step === 'pending' &&
}
{/* Titre + statut */}
{r.title}
{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')}
))}
{/* Footer */}
{isDone ? (
{/* Résumé */}
{doneCount} guide{doneCount > 1 ? 's' : ''} générés
{errorCount > 0 && (
{errorCount} erreur{errorCount > 1 ? 's' : ''}
)}
{/* Bouton téléchargement */}
{doneCount > 0 && (
<>
Glissez le fichier dans Google Drive → clic droit → Ouvrir avec Google Docs
>
)}
) : (
Génération en cours, ne fermez pas cette fenêtre…
)}
>
)
}