Spaces:
Running
Running
| '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> | |
| </> | |
| ) | |
| } | |