Files
Momento/memento-note/components/onboarding/onboarding-step-notes.tsx
Antigravity 6b4ed8514f
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m37s
CI / Deploy production (on server) (push) Has been cancelled
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>
2026-05-29 11:24:56 +00:00

204 lines
6.7 KiB
TypeScript

'use client'
import { useRef, useState } from 'react'
import { motion } from 'motion/react'
import { FileText, Upload, Sparkles, CheckCircle, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
import { markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
interface Props {
noteCount: number
onNext: (justCreated: boolean) => void
onSkip: () => void
locale: string
}
async function createNote(title: string | null, content: string) {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title ?? 'Untitled', content }),
})
if (!res.ok) throw new Error('Failed to create note')
}
export function OnboardingStepNotes({ noteCount, onNext, onSkip, locale }: Props) {
const { t } = useLanguage()
const [loading, setLoading] = useState(false)
const [created, setCreated] = useState(false)
const [importError, setImportError] = useState<string | null>(null)
const [importedCount, setImportedCount] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
async function handleCreateDemo() {
setLoading(true)
try {
await fetch('/api/onboarding/seed-demo-notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale }),
})
setCreated(true)
setTimeout(() => onNext(true), 1200)
} finally {
setLoading(false)
}
}
async function handleImportFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
setLoading(true)
setImportError(null)
let count = 0
try {
for (const file of files) {
const text = await file.text()
const isMd = file.name.endsWith('.md')
const title = extractMarkdownTitle(text) ?? file.name.replace(/\.(md|txt)$/, '')
const content = isMd ? markdownToHTML(text) : `<p>${text.replace(/\n\n+/g, '</p><p>').replace(/\n/g, '<br/>')}</p>`
await createNote(title, content)
count++
}
setImportedCount(count)
setCreated(true)
setTimeout(() => onNext(false), 1200)
} catch {
setImportError(t('onboarding.import_error'))
} finally {
setLoading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const hasNotes = noteCount > 0
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-6 text-center"
>
<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-blue-500/10 text-blue-500"
>
<FileText className="h-10 w-10" />
</motion.div>
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight text-foreground">
{t('onboarding.step_notes_title')}
</h2>
<p className="text-base text-muted-foreground max-w-xs">
{hasNotes
? t('onboarding.step_notes_has_notes', { count: noteCount })
: t('onboarding.step_notes_empty')}
</p>
{!hasNotes && (
<p className="text-xs text-violet-500/80 max-w-xs">
{t('onboarding.step_notes_hint')}
</p>
)}
</div>
{hasNotes ? (
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button onClick={() => onNext(false)} size="lg" className="w-full">
{t('onboarding.step_notes_cta')}
</Button>
<button
onClick={onSkip}
className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors py-1"
>
{t('onboarding.skip')}
</button>
</div>
) : (
<div className="flex flex-col gap-3 w-full max-w-xs">
{created ? (
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex items-center justify-center gap-2 rounded-lg bg-green-500/10 py-3 text-green-600"
>
<CheckCircle className="h-5 w-5" />
<span className="text-sm font-medium">
{importedCount > 0
? t('onboarding.import_notes_ready', { count: importedCount })
: t('onboarding.demo_notes_ready')}
</span>
</motion.div>
) : (
<>
<Button
onClick={handleCreateDemo}
size="lg"
className="w-full gap-2"
disabled={loading}
>
{loading ? (
<>
<span className="animate-spin h-4 w-4 border-2 border-white/30 border-t-white rounded-full" />
{t('onboarding.creating_demo_notes')}
</>
) : (
<>
<Sparkles className="h-4 w-4" />
{t('onboarding.step_notes_demo')}
</>
)}
</Button>
{/* Hidden file input — accepts .md and .txt */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
multiple
className="hidden"
onChange={handleImportFiles}
/>
<Button
onClick={() => fileInputRef.current?.click()}
size="lg"
variant="outline"
className="w-full gap-2"
disabled={loading}
>
<Upload className="h-4 w-4" />
{t('onboarding.step_notes_import')}
</Button>
<p className="text-xs text-muted-foreground/50">
{t('onboarding.import_formats')}
</p>
</>
)}
{importError && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-2 rounded-lg bg-destructive/10 py-2 px-3 text-destructive text-sm"
>
<AlertCircle className="h-4 w-4 shrink-0" />
{importError}
</motion.div>
)}
<button
onClick={onSkip}
className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors py-1"
>
{t('onboarding.skip')}
</button>
</div>
)}
</motion.div>
)
}