'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 = [] } } const flushOl = () => { if (olBuf.length) { out.push(`
    ${olBuf.map(i => `
  1. ${i}
  2. `).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…
)}
) }