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>
233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { motion, AnimatePresence } from 'motion/react'
|
|
import { useSession } from 'next-auth/react'
|
|
import { usePathname } from 'next/navigation'
|
|
import {
|
|
Slash, Sparkles, History, GraduationCap, Link2,
|
|
PenLine, FlipVertical, Keyboard, Lightbulb, ArrowRight,
|
|
FileDown, Network, Zap, RefreshCw,
|
|
X, ChevronLeft, ChevronRight,
|
|
} from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
interface HintDef {
|
|
icon: LucideIcon
|
|
color: string
|
|
bg: string
|
|
key: string
|
|
}
|
|
|
|
interface RouteHintSet {
|
|
hints: HintDef[]
|
|
storageKey: string
|
|
}
|
|
|
|
// ── Hint definitions per route ─────────────────────────────────────────────
|
|
// Every item here maps to a real, verified UI element or gesture in the codebase.
|
|
|
|
const ROUTE_HINTS: Record<string, RouteHintSet> = {
|
|
'/home': {
|
|
storageKey: 'momento_hints_home',
|
|
hints: [
|
|
{ icon: PenLine, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'create_note' },
|
|
{ icon: Slash, color: 'text-indigo-500', bg: 'bg-indigo-500/10', key: 'slash' },
|
|
{ icon: Sparkles, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'ai' },
|
|
{ icon: History, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'version' },
|
|
{ icon: Link2, color: 'text-rose-500', bg: 'bg-rose-500/10', key: 'links' },
|
|
{ icon: GraduationCap, color: 'text-emerald-500',bg: 'bg-emerald-500/10',key: 'flashcards' },
|
|
],
|
|
},
|
|
'/revision': {
|
|
storageKey: 'momento_hints_revision',
|
|
hints: [
|
|
{ icon: FlipVertical, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'flip' },
|
|
{ icon: Keyboard, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'rate_keys' },
|
|
{ icon: GraduationCap,color: 'text-emerald-500',bg: 'bg-emerald-500/10',key: 'generate_from_note' },
|
|
],
|
|
},
|
|
'/brainstorm': {
|
|
storageKey: 'momento_hints_brainstorm',
|
|
hints: [
|
|
{ icon: Lightbulb, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'brainstorm_start' },
|
|
{ icon: ArrowRight, color: 'text-violet-500',bg: 'bg-violet-500/10', key: 'brainstorm_deepen' },
|
|
{ icon: FileDown, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'brainstorm_export' },
|
|
],
|
|
},
|
|
'/insights': {
|
|
storageKey: 'momento_hints_insights',
|
|
hints: [
|
|
{ icon: Network, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'insights_clusters' },
|
|
{ icon: Zap, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'insights_bridge' },
|
|
{ icon: RefreshCw, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'insights_refresh' },
|
|
],
|
|
},
|
|
}
|
|
|
|
function getRouteSet(pathname: string): RouteHintSet | null {
|
|
// Exact match first
|
|
if (ROUTE_HINTS[pathname]) return ROUTE_HINTS[pathname]
|
|
// Prefix match (e.g. /brainstorm?session=xxx)
|
|
for (const route of Object.keys(ROUTE_HINTS)) {
|
|
if (pathname.startsWith(route)) return ROUTE_HINTS[route]
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ── Component ──────────────────────────────────────────────────────────────
|
|
|
|
export function OnboardingEditorHints() {
|
|
const { data: session } = useSession()
|
|
const pathname = usePathname()
|
|
const { t } = useLanguage()
|
|
|
|
const [visible, setVisible] = useState(false)
|
|
const [index, setIndex] = useState(0)
|
|
const [routeKey, setRouteKey] = useState<string | null>(null)
|
|
|
|
const user = session?.user as any
|
|
|
|
// When route changes, check if we should show hints for the new page
|
|
useEffect(() => {
|
|
if (!session) return
|
|
if (user?.onboardingCompleted !== false) return
|
|
|
|
const routeSet = getRouteSet(pathname)
|
|
if (!routeSet) {
|
|
setVisible(false)
|
|
return
|
|
}
|
|
|
|
if (typeof window !== 'undefined' && localStorage.getItem(routeSet.storageKey)) {
|
|
setVisible(false)
|
|
return
|
|
}
|
|
|
|
// Reset to first hint when page changes
|
|
setIndex(0)
|
|
setRouteKey(pathname)
|
|
|
|
const timer = setTimeout(() => setVisible(true), 900)
|
|
return () => clearTimeout(timer)
|
|
}, [pathname, session, user?.onboardingCompleted])
|
|
|
|
// Hide when hints change (route switch)
|
|
useEffect(() => {
|
|
if (routeKey !== null && routeKey !== pathname) {
|
|
setVisible(false)
|
|
}
|
|
}, [pathname, routeKey])
|
|
|
|
function dismiss() {
|
|
const routeSet = getRouteSet(pathname)
|
|
if (routeSet && typeof window !== 'undefined') {
|
|
localStorage.setItem(routeSet.storageKey, '1')
|
|
}
|
|
setVisible(false)
|
|
}
|
|
|
|
const routeSet = getRouteSet(pathname)
|
|
if (!routeSet) return null
|
|
|
|
const hints = routeSet.hints
|
|
const hint = hints[Math.min(index, hints.length - 1)]
|
|
const Icon = hint.icon
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{visible && (
|
|
<motion.div
|
|
key={`hints-${pathname}`}
|
|
initial={{ opacity: 0, x: 60 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 60 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
className="fixed bottom-24 right-4 z-[150] w-72 rounded-2xl border border-border bg-background shadow-xl"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 pt-3 pb-2 border-b border-border/50">
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
|
{t('onboarding.editor_hints_title')}
|
|
</p>
|
|
<button
|
|
onClick={dismiss}
|
|
className="rounded p-0.5 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Hint content */}
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={hint.key}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -8 }}
|
|
transition={{ duration: 0.18 }}
|
|
className="px-4 py-3 flex items-start gap-3"
|
|
>
|
|
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${hint.bg} ${hint.color}`}>
|
|
<Icon className="h-4 w-4" />
|
|
</span>
|
|
<div>
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{t(`onboarding.hint_${hint.key}_title`)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
|
|
{t(`onboarding.hint_${hint.key}_desc`)}
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
|
|
{/* Navigation */}
|
|
<div className="flex items-center justify-between px-4 pb-3">
|
|
{/* Dots */}
|
|
<div className="flex gap-1">
|
|
{hints.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => setIndex(i)}
|
|
className={`h-1.5 rounded-full transition-all ${
|
|
i === index ? 'w-4 bg-violet-500' : 'w-1.5 bg-border'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
{/* Arrows + dismiss */}
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setIndex(i => Math.max(0, i - 1))}
|
|
disabled={index === 0}
|
|
className="flex h-6 w-6 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-30 transition-colors"
|
|
>
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
</button>
|
|
{index < hints.length - 1 ? (
|
|
<button
|
|
onClick={() => setIndex(i => i + 1)}
|
|
className="flex h-6 w-6 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
>
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={dismiss}
|
|
className="flex items-center gap-1 text-xs font-medium px-2.5 h-6 rounded-lg bg-violet-500/10 text-violet-500 hover:bg-violet-500/20 transition-colors"
|
|
>
|
|
{t('onboarding.editor_hints_got_it')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|