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>
204 lines
6.7 KiB
TypeScript
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>
|
|
)
|
|
}
|