Epic 6: Stories 6-2 (Markdown roundtrip) + 6-3 (Brainstorm PPTX + Canvas)
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user