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

@@ -0,0 +1,202 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Search, Sparkles, ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
import { semanticSearch } from '@/app/actions/semantic-search'
import confetti from 'canvas-confetti'
interface SearchResult { id: string; title: string | null; snippet?: string }
interface Props {
onDone: () => void
locale: string
autoSearch?: boolean
}
const SEARCH_PREFILL: Record<string, string> = {
fr: 'notes sur ma productivité',
en: 'notes about productivity',
fa: 'یادداشت‌های بهره‌وری',
ar: 'ملاحظات حول الإنتاجية',
de: 'Notizen zur Produktivität',
es: 'notas sobre productividad',
it: 'note sulla produttività',
pt: 'notas sobre produtividade',
ru: 'заметки о продуктивности',
zh: '关于生产力的笔记',
ja: '生産性に関するメモ',
ko: '생산성에 관한 노트',
nl: 'notities over productiviteit',
pl: 'notatki o produktywności',
hi: 'उत्पादकता पर नोट्स',
}
function fireConfetti() {
const end = Date.now() + 800
const colors = ['#7c3aed', '#a78bfa', '#fbbf24', '#34d399']
const frame = () => {
confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0 }, colors })
confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1 }, colors })
if (Date.now() < end) requestAnimationFrame(frame)
}
frame()
}
export function OnboardingStepAha({ onDone, locale, autoSearch = false }: Props) {
const { t } = useLanguage()
const lang = locale.split('-')[0].toLowerCase()
const prefill = SEARCH_PREFILL[lang] ?? SEARCH_PREFILL.en
const [query, setQuery] = useState(prefill)
const [results, setResults] = useState<SearchResult[] | null>(null)
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [quotaExceeded, setQuotaExceeded] = useState(false)
const autoSearched = useRef(false)
async function handleSearch() {
const q = query.trim()
if (!q) return
setLoading(true)
setQuotaExceeded(false)
try {
const res = await semanticSearch(q, { limit: 5 })
const hits = res.results.map(r => ({ id: r.noteId, title: r.title, snippet: r.content?.slice(0, 80) }))
setResults(hits)
setSearched(true)
if (hits.length > 0) {
setTimeout(fireConfetti, 200)
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message.toLowerCase() : ''
if (msg.includes('quota') || msg.includes('limit') || msg.includes('exceeded') || msg.includes('upgrade')) {
setQuotaExceeded(true)
}
setResults([])
setSearched(true)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!autoSearch || autoSearched.current) return
autoSearched.current = true
const timer = setTimeout(() => { void handleSearch() }, 800)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoSearch])
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className="flex flex-col items-center gap-5 w-full"
>
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
className="flex h-20 w-20 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-500"
>
<Sparkles className="h-10 w-10" />
</motion.div>
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight text-foreground">
{t('onboarding.step_aha_title')}
</h2>
<p className="text-base text-muted-foreground max-w-xs mx-auto">
{t('onboarding.step_aha_subtitle')}
</p>
</div>
<motion.div
animate={searched ? {} : { boxShadow: ['0 0 0 0 rgba(139,92,246,0)', '0 0 0 6px rgba(139,92,246,0.15)', '0 0 0 0 rgba(139,92,246,0)'] }}
transition={{ duration: 2, repeat: searched ? 0 : Infinity }}
className="flex w-full max-w-sm rounded-xl border border-border bg-background overflow-hidden shadow-sm"
>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
className="flex-1 px-4 py-3 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
dir={['fa', 'ar'].includes(lang) ? 'rtl' : 'ltr'}
aria-label={t('onboarding.step_aha_search_aria')}
/>
<button
onClick={handleSearch}
disabled={loading || !query.trim()}
aria-label={t('onboarding.step_aha_search_button')}
className="flex items-center gap-1.5 px-4 text-sm font-medium text-muted-foreground hover:text-violet-500 transition-colors disabled:opacity-50"
>
{loading
? <span className="h-4 w-4 animate-spin border-2 border-violet-500/30 border-t-violet-500 rounded-full block" />
: <Search className="h-4 w-4" />
}
<span className="hidden sm:inline">{t('onboarding.step_aha_search_button')}</span>
</button>
</motion.div>
<AnimatePresence>
{searched && results !== null && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="w-full max-w-sm space-y-2"
>
{quotaExceeded ? (
<p className="text-sm text-center text-destructive py-2">
{t('onboarding.quota_exceeded')}
</p>
) : results.length === 0 ? (
<p className="text-sm text-center text-muted-foreground py-2">
{t('onboarding.no_results')}
</p>
) : (
<>
{results.slice(0, 3).map((r, i) => (
<motion.div
key={r.id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.07 }}
className="rounded-lg border border-border bg-muted/30 px-3 py-2"
>
<p className="text-sm font-medium text-foreground truncate">{r.title ?? 'Untitled'}</p>
{r.snippet && (
<p className="text-xs text-muted-foreground truncate mt-0.5">{r.snippet}</p>
)}
</motion.div>
))}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="flex items-center justify-center gap-1 text-xs text-violet-500 pt-1"
>
<span></span>
<span>{t('onboarding.search_credit_used')}</span>
</motion.p>
</>
)}
</motion.div>
)}
</AnimatePresence>
<Button
onClick={onDone}
size="lg"
className="w-full max-w-sm gap-2"
>
{t('onboarding.step_aha_cta')}
<ArrowRight className="h-4 w-4" />
</Button>
</motion.div>
)
}