Epic 6: Stories 6-2 (Markdown roundtrip) + 6-3 (Brainstorm PPTX + Canvas)
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m37s
CI / Deploy production (on server) (push) Has been cancelled

Story 6-2 — Markdown roundtrip export/import:
- lib/editor/markdown-export.ts: tiptapHTMLToMarkdown, markdownToHTML, looksLikeMarkdown
- lib/editor/markdown-paste-extension.ts: TipTap extension paste Markdown → blocs
- note-editor-toolbar.tsx: export .md + import .md (file picker)
- rich-text-editor.tsx: intégration MarkdownPasteExtension
- 40 tests unitaires markdown-export.test.ts

Story 6-3 — Brainstorm PPTX + Canvas:
- lib/brainstorm/export-pptx.ts: génération PPTX 5 slides (pptxgenjs)
- app/api/brainstorm/[sessionId]/export-pptx/route.ts: route POST protégée
- brainstorm-page.tsx: bouton PPTX, auto-select session, fix emoji, fix router.replace
- wave-canvas.tsx: fitTrigger recentrage, légende bas-droite

Onboarding activation wizard (Story 6-1):
- components/onboarding/: wizard multi-étapes, hints éditeur
- app/api/onboarding/: route PATCH onboarding
- prisma/migrations: champs onboarding user

Locales: 15 langues mises à jour (brainstorm, markdown, onboarding keys)
Sprint: 6-1 done, 6-2 review, 6-3 review

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Antigravity
2026-05-29 11:24:56 +00:00
parent dae56187fc
commit 6b4ed8514f
49 changed files with 5215 additions and 66 deletions

View File

@@ -94,12 +94,23 @@ export function BrainstormPage() {
setActiveSessionId(urlSessionId)
}
}, [urlSessionId])
const [showActivityFeed, setShowActivityFeed] = useState(false)
const [showShareDialog, setShowShareDialog] = useState(false)
const [manualEditCount, setManualEditCount] = useState(0)
const [shareStatus, setShareStatus] = useState<'idle' | 'copying' | 'copied'>('idle')
const { data: sessions, isLoading: sessionsLoading } = useBrainstormSessions()
// Auto-sélectionner la dernière session si aucune session active
useEffect(() => {
if (!activeSessionId && !urlSessionId && !sessionsLoading && sessions && sessions.length > 0) {
const last = sessions[0]
setActiveSessionId(last.id)
router.replace('/brainstorm?session=' + last.id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessions, sessionsLoading])
const { data: sessionResult, isLoading: sessionLoading } = useBrainstormSession(activeSessionId)
const session = sessionResult?.session || null
const sessionMeta = sessionResult?.meta
@@ -128,6 +139,9 @@ export function BrainstormPage() {
const [viewMode, setViewMode] = useState<'canvas' | 'list'>('canvas')
const [summaryOpen, setSummaryOpen] = useState(false)
const [summaryText, setSummaryText] = useState<string | null>(null)
const [pptxLoading, setPptxLoading] = useState(false)
const [pptxError, setPptxError] = useState<string | null>(null)
const [fitTrigger, setFitTrigger] = useState(0)
const [renamingSession, setRenamingSession] = useState<string | null>(null)
const [renameInput, setRenameInput] = useState('')
const canvasContainerRef = useRef<HTMLDivElement>(null)
@@ -237,6 +251,36 @@ export function BrainstormPage() {
} catch {}
}
const handleExportPptx = async () => {
if (!activeSessionId) return
setPptxLoading(true)
setPptxError(null)
try {
const res = await fetch(`/api/brainstorm/${activeSessionId}/export-pptx`, {
method: 'POST',
credentials: 'include',
})
if (!res.ok) throw new Error(t('brainstorm.pptxError') || 'Export PPTX failed')
const blob = await res.blob()
const disposition = res.headers.get('Content-Disposition') || ''
const match = disposition.match(/filename="([^"]+)"/)
const filename = match?.[1] || 'brainstorm.pptx'
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err: any) {
setPptxError(err?.message || t('brainstorm.pptxError') || 'Export PPTX failed')
setTimeout(() => setPptxError(null), 4000)
} finally {
setPptxLoading(false)
}
}
const handleExport = async () => {
setExportError(null)
try {
@@ -577,6 +621,7 @@ export function BrainstormPage() {
remoteMove={remoteMove}
manualEditTrigger={manualEditCount}
playbackIdeas={playbackIdeas}
fitTrigger={fitTrigger}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
@@ -594,7 +639,7 @@ export function BrainstormPage() {
animate={{ opacity: 1, y: 0 }}
className="absolute bottom-6 left-6 flex gap-2"
>
<div className="px-4 py-2 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
<div className="px-4 py-2 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-5">
{[1, 2, 3].map((w) => (
<div key={w} className="flex items-center gap-2">
<div
@@ -609,6 +654,20 @@ export function BrainstormPage() {
</span>
</div>
))}
{viewMode === 'canvas' && (
<>
<div className="w-px h-4 bg-border/60" />
<button
onClick={() => setFitTrigger((c) => c + 1)}
title={t('brainstorm.fitToScreen') || 'Recentrer'}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
</button>
</>
)}
</div>
{canEdit && (
@@ -854,7 +913,10 @@ export function BrainstormPage() {
{sessions?.map((s) => (
<div key={s.id} className="relative group/session">
<button
onClick={() => setActiveSessionId(s.id)}
onClick={() => {
setActiveSessionId(s.id)
router.replace('/brainstorm?session=' + s.id)
}}
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0 ${
activeSessionId === s.id
? 'bg-foreground text-background scale-110 shadow-lg'
@@ -864,7 +926,7 @@ export function BrainstormPage() {
}`}
title={s.seedIdea}
>
{s.seedIdea.charAt(0).toUpperCase()}
{(s.seedIdea.replace(/\p{Emoji}/gu, '').trim().charAt(0) || '?').toUpperCase()}
</button>
{activeSessionId === s.id && (
<button
@@ -943,14 +1005,29 @@ export function BrainstormPage() {
{t('brainstorm.regenerateSummary') || 'Regenerate'}
</button>
)}
<button
onClick={() => { setSummaryOpen(false); handleExport() }}
disabled={exportBrainstorm.isPending || summarize.isPending}
className="ms-auto flex items-center gap-2 px-5 py-2.5 bg-foreground text-background rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:opacity-80 transition-opacity"
>
<Download size={13} />
{t('brainstorm.exportAsNote') || 'Export as note'}
</button>
<div className="ms-auto flex items-center gap-2">
<button
onClick={handleExportPptx}
disabled={pptxLoading || summarize.isPending}
title={t('brainstorm.downloadPptxDesc') || 'Download as PowerPoint'}
className="flex items-center gap-2 px-4 py-2.5 border border-foreground/20 text-foreground rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:bg-foreground/5 transition-colors"
>
{pptxLoading ? (
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: 'linear' }} className="w-3 h-3 border-2 border-current border-t-transparent rounded-full" />
) : (
<Download size={13} />
)}
{t('brainstorm.downloadPptx') || 'PPTX'}
</button>
<button
onClick={() => { setSummaryOpen(false); handleExport() }}
disabled={exportBrainstorm.isPending || summarize.isPending}
className="flex items-center gap-2 px-5 py-2.5 bg-foreground text-background rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:opacity-80 transition-opacity"
>
<Download size={13} />
{t('brainstorm.exportAsNote') || 'Export as note'}
</button>
</div>
</div>
</motion.div>
</motion.div>
@@ -1033,6 +1110,20 @@ export function BrainstormPage() {
)}
</AnimatePresence>
<AnimatePresence>
{pptxError && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-rose-500 text-white rounded-2xl shadow-2xl flex items-center gap-4 text-sm font-medium"
>
<span>{pptxError}</span>
<button onClick={() => setPptxError(null)} className="text-white/60 hover:text-white"></button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{exportToast && (
<motion.div

View File

@@ -14,6 +14,7 @@ interface WaveCanvasProps {
remoteMove?: { ideaId: string; x: number; y: number; _seq: number } | null
manualEditTrigger?: number
playbackIdeas?: any[] | null
fitTrigger?: number
}
const WAVE_COLORS: Record<number, string> = {
@@ -31,6 +32,7 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
remoteMove,
manualEditTrigger,
playbackIdeas,
fitTrigger,
}) => {
const { t, language } = useLanguage()
const svgRef = useRef<SVGSVGElement>(null)
@@ -39,6 +41,8 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
const linkRef = useRef<d3.Selection<SVGLineElement, any, SVGGElement, unknown> | null>(null)
const simulationRef = useRef<d3.Simulation<any, any> | null>(null)
const transformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity)
const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null)
const centerRef = useRef<{ x: number; y: number; scale: number }>({ x: 0, y: 0, scale: 0.8 })
const onNodeSelectRef = useRef(onNodeSelect)
onNodeSelectRef.current = onNodeSelect
@@ -167,6 +171,9 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
transformRef.current = event.transform
})
zoomRef.current = zoom
centerRef.current = { x: centerX, y: centerY, scale: 0.8 }
svg.call(zoom)
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8))
@@ -517,6 +524,16 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
}, [remoteMove])
// Fit-to-screen: re-center when fitTrigger increments
useEffect(() => {
if (!fitTrigger || !svgRef.current || !zoomRef.current) return
const { x, y, scale } = centerRef.current
d3.select(svgRef.current)
.transition()
.duration(500)
.call(zoomRef.current.transform, d3.zoomIdentity.translate(x, y).scale(scale))
}, [fitTrigger])
return (
<div
ref={containerRef}
@@ -524,6 +541,21 @@ export const WaveCanvas: React.FC<WaveCanvasProps> = ({
>
<svg ref={svgRef} className="w-full h-full" />
{/* Legend overlay — bottom right */}
<div className="absolute bottom-6 right-6 pointer-events-none flex flex-col gap-1 bg-white/80 dark:bg-[#1A1A1A]/80 backdrop-blur-sm rounded-xl px-3 py-2.5 shadow-sm border border-black/[0.06] dark:border-white/[0.06]">
{[
{ color: '#fb923c', label: t('brainstorm.legendWave1') || 'Variations' },
{ color: '#60a5fa', label: t('brainstorm.legendWave2') || 'Analogies' },
{ color: '#a78bfa', label: t('brainstorm.legendWave3') || 'Disruptions' },
{ color: '#10b981', label: t('brainstorm.legendConverted') || 'Convertie' },
].map(({ color, label }) => (
<div key={label} className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
<span className="text-[10px] text-foreground/50 font-medium">{label}</span>
</div>
))}
</div>
{editingNode && (
<div
className="absolute z-50 pointer-events-auto"