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"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -18,7 +19,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp
|
||||
} from 'lucide-react'
|
||||
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
@@ -29,6 +30,7 @@ import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { format } from 'date-fns'
|
||||
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
|
||||
|
||||
interface NoteEditorToolbarProps {
|
||||
mode: 'fullPage' | 'dialog'
|
||||
@@ -38,16 +40,70 @@ interface NoteEditorToolbarProps {
|
||||
}
|
||||
|
||||
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [shareOpen, setShareOpen] = useState(false)
|
||||
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
||||
const mdImportInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
|
||||
|
||||
// ── Markdown export ───────────────────────────────────────────────────────
|
||||
const handleExportMarkdown = () => {
|
||||
try {
|
||||
const editor = richTextEditorRef?.current?.getEditor()
|
||||
if (!editor) {
|
||||
toast.error(t('richTextEditor.markdownExportError'))
|
||||
return
|
||||
}
|
||||
const html = editor.getHTML()
|
||||
const title = state.title || note.title || 'note'
|
||||
const titleLine = title ? `# ${title}\n\n` : ''
|
||||
const markdown = titleLine + tiptapHTMLToMarkdown(html)
|
||||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${title.replace(/[^a-z0-9\-_\s]/gi, '').trim().replace(/\s+/g, '-') || 'note'}.md`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(t('richTextEditor.markdownExportSuccess'))
|
||||
} catch {
|
||||
toast.error(t('richTextEditor.markdownExportError'))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Markdown import ───────────────────────────────────────────────────────
|
||||
const handleImportMarkdownFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const md = ev.target?.result as string
|
||||
const html = markdownToHTML(md)
|
||||
const extractedTitle = extractMarkdownTitle(md)
|
||||
const editor = richTextEditorRef?.current?.getEditor()
|
||||
if (editor) {
|
||||
editor.commands.setContent(html)
|
||||
}
|
||||
actions.setContent(html)
|
||||
if (extractedTitle) actions.setTitle(extractedTitle)
|
||||
toast.success(t('richTextEditor.markdownImportSuccess'))
|
||||
} catch {
|
||||
toast.error(t('richTextEditor.markdownExportError'))
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
// Reset input so same file can be imported again
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleConvertToRichtext = async () => {
|
||||
if (isConverting || !state.content.trim()) return
|
||||
setIsConverting(true)
|
||||
@@ -240,7 +296,16 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onClick={handleExportMarkdown}>
|
||||
<FileDown className="h-4 w-4 me-2" />
|
||||
{t('richTextEditor.exportMarkdown')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => mdImportInputRef.current?.click()}>
|
||||
<FileUp className="h-4 w-4 me-2" />
|
||||
{t('richTextEditor.importMarkdown')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -304,6 +369,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!readOnly && (
|
||||
@@ -432,5 +498,14 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Hidden file input for Markdown import */}
|
||||
<input
|
||||
ref={mdImportInputRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
className="hidden"
|
||||
onChange={handleImportMarkdownFile}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
232
memento-note/components/onboarding/onboarding-editor-hints.tsx
Normal file
232
memento-note/components/onboarding/onboarding-editor-hints.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
202
memento-note/components/onboarding/onboarding-step-aha.tsx
Normal file
202
memento-note/components/onboarding/onboarding-step-aha.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
memento-note/components/onboarding/onboarding-step-features.tsx
Normal file
116
memento-note/components/onboarding/onboarding-step-features.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Pencil, CreditCard, Lightbulb, Check, ArrowRight, Sparkles } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface Props {
|
||||
onDone: () => void
|
||||
onTry: (href: string) => void
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{ icon: Pencil, color: 'text-violet-500', bg: 'bg-violet-500/10', key: 'write', href: '/home' },
|
||||
{ icon: CreditCard, color: 'text-blue-500', bg: 'bg-blue-500/10', key: 'flashcards', href: '/revision' },
|
||||
{ icon: Lightbulb, color: 'text-amber-500', bg: 'bg-amber-500/10', key: 'brainstorm', href: '/brainstorm' },
|
||||
]
|
||||
|
||||
export function OnboardingStepFeatures({ onDone, onTry }: Props) {
|
||||
const { t } = useLanguage()
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
|
||||
function handleTry(key: string, href: string) {
|
||||
setChecked(prev => new Set([...prev, key]))
|
||||
onTry(href)
|
||||
}
|
||||
|
||||
const allChecked = checked.size === ACTIONS.length
|
||||
|
||||
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-amber-500/10 text-amber-500"
|
||||
>
|
||||
<Sparkles className="h-10 w-10" />
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-1 text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('onboarding.step_features_title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{t('onboarding.step_features_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 w-full">
|
||||
{ACTIONS.map(({ icon: Icon, color, bg, key, href }, i) => {
|
||||
const done = checked.has(key)
|
||||
return (
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.08 + i * 0.08 }}
|
||||
className={`flex items-center gap-3 rounded-xl border p-3 transition-all ${
|
||||
done ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border bg-muted/10 hover:border-border/80'
|
||||
}`}
|
||||
>
|
||||
{/* Checkmark */}
|
||||
<span className={`shrink-0 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
done ? 'border-emerald-500 bg-emerald-500' : 'border-border'
|
||||
}`}>
|
||||
<AnimatePresence>
|
||||
{done && (
|
||||
<motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${bg} ${color}`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium leading-tight ${done ? 'text-muted-foreground' : 'text-foreground'}`}>
|
||||
{t(`onboarding.action_${key}_title`)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
|
||||
{t(`onboarding.action_${key}_desc`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Essayer — minimise wizard + navigue */}
|
||||
<button
|
||||
onClick={() => handleTry(key, href)}
|
||||
className={`shrink-0 flex items-center gap-1 text-xs font-medium px-2.5 py-1.5 rounded-lg transition-opacity ${color} ${bg} ${done ? 'opacity-40' : 'hover:opacity-80'}`}
|
||||
>
|
||||
{done ? t('onboarding.action_done') : t('onboarding.action_try')}
|
||||
{!done && <ArrowRight className="h-3 w-3" />}
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button onClick={onDone} size="lg" className="w-full gap-2 mt-1">
|
||||
{allChecked ? t('onboarding.step_features_cta_all') : t('onboarding.step_features_cta')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
203
memento-note/components/onboarding/onboarding-step-notes.tsx
Normal file
203
memento-note/components/onboarding/onboarding-step-notes.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Brain } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface Props {
|
||||
onNext: () => void
|
||||
onSkip: () => void
|
||||
userName?: string | null
|
||||
}
|
||||
|
||||
export function OnboardingStepWelcome({ onNext, onSkip, userName }: Props) {
|
||||
const { t } = useLanguage()
|
||||
const firstName = userName?.split(' ')[0] ?? null
|
||||
|
||||
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-brand-accent/10 text-brand-accent"
|
||||
>
|
||||
<Brain className="h-10 w-10" />
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{firstName
|
||||
? t('onboarding.welcome_title_name', { name: firstName })
|
||||
: t('onboarding.welcome_title')}
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground max-w-xs">
|
||||
{t('onboarding.welcome_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full max-w-xs">
|
||||
<Button onClick={onNext} size="lg" className="w-full">
|
||||
{t('onboarding.welcome_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>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
219
memento-note/components/onboarding/onboarding-wizard.tsx
Normal file
219
memento-note/components/onboarding/onboarding-wizard.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { OnboardingStepWelcome } from './onboarding-step-welcome'
|
||||
import { OnboardingStepNotes } from './onboarding-step-notes'
|
||||
import { OnboardingStepAha } from './onboarding-step-aha'
|
||||
import { OnboardingStepFeatures } from './onboarding-step-features'
|
||||
|
||||
const TOTAL_STEPS = 4
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
const res = await fetch('/api/user/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ onboardingCompleted: true }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to save onboarding state')
|
||||
}
|
||||
|
||||
async function getNoteCount(): Promise<number> {
|
||||
try {
|
||||
const res = await fetch('/api/notes?limit=1')
|
||||
if (!res.ok) return 0
|
||||
const data = await res.json()
|
||||
if (data?.data && Array.isArray(data.data)) return data.data.length
|
||||
if (Array.isArray(data)) return data.length
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingWizard() {
|
||||
const { data: session, update: updateSession } = useSession()
|
||||
const { language, t } = useLanguage()
|
||||
const router = useRouter()
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const [noteCount, setNoteCount] = useState(0)
|
||||
const [demoNotesJustCreated, setDemoNotesJustCreated] = useState(false)
|
||||
const [triedCount, setTriedCount] = useState(0)
|
||||
|
||||
const user = session?.user as any
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
if (user?.onboardingCompleted === false) {
|
||||
setVisible(true)
|
||||
getNoteCount().then(setNoteCount)
|
||||
}
|
||||
}, [session, user?.onboardingCompleted])
|
||||
|
||||
const handleSkip = useCallback(async () => {
|
||||
setVisible(false)
|
||||
setMinimized(false)
|
||||
try {
|
||||
await markOnboardingComplete()
|
||||
await updateSession({ onboardingCompleted: true })
|
||||
} catch (e) {
|
||||
console.error('[Onboarding] skip failed:', e)
|
||||
}
|
||||
}, [updateSession])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setStep(s => Math.min(s + 1, TOTAL_STEPS))
|
||||
}, [])
|
||||
|
||||
const handleNotesNext = useCallback((justCreated: boolean) => {
|
||||
setDemoNotesJustCreated(justCreated)
|
||||
setStep(s => Math.min(s + 1, TOTAL_STEPS))
|
||||
}, [])
|
||||
|
||||
const handleDone = useCallback(async () => {
|
||||
setVisible(false)
|
||||
setMinimized(false)
|
||||
try {
|
||||
await markOnboardingComplete()
|
||||
await updateSession({ onboardingCompleted: true })
|
||||
} catch (e) {
|
||||
console.error('[Onboarding] done failed:', e)
|
||||
}
|
||||
}, [updateSession])
|
||||
|
||||
// Called from step 4 when user clicks "Essayer" on a feature
|
||||
const handleTry = useCallback((href: string) => {
|
||||
setMinimized(true)
|
||||
setTriedCount(c => c + 1)
|
||||
router.push(href)
|
||||
}, [router])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Minimized pill — always rendered when minimized so it shows on any page */}
|
||||
<AnimatePresence>
|
||||
{minimized && (
|
||||
<motion.button
|
||||
key="onboarding-pill"
|
||||
initial={{ opacity: 0, y: 40, scale: 0.8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 40, scale: 0.8 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 28 }}
|
||||
onClick={() => setMinimized(false)}
|
||||
className="fixed bottom-6 right-6 z-[300] flex items-center gap-2.5 rounded-full bg-violet-600 text-white shadow-lg shadow-violet-500/30 px-4 py-3 text-sm font-medium hover:bg-violet-700 transition-colors"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{t('onboarding.pill_resume')}</span>
|
||||
{triedCount > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-xs font-bold">
|
||||
{triedCount}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Full wizard modal */}
|
||||
<AnimatePresence>
|
||||
{!minimized && (
|
||||
<motion.div
|
||||
key="onboarding-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[200] flex items-end justify-center sm:items-center bg-black/50 backdrop-blur-sm p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0, scale: 0.97 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ y: 60, opacity: 0, scale: 0.95 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className={cn(
|
||||
'relative w-full max-w-md rounded-2xl bg-background border border-border shadow-2xl p-8',
|
||||
'sm:rounded-2xl rounded-t-2xl rounded-b-none sm:rounded-b-2xl'
|
||||
)}
|
||||
>
|
||||
{/* Progress */}
|
||||
<div className="flex flex-col items-center gap-1.5 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{
|
||||
width: i + 1 === step ? 24 : 8,
|
||||
backgroundColor: i + 1 <= step ? 'var(--brand-accent, #7c3aed)' : 'var(--border)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-2 rounded-full bg-border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
{t('onboarding.progress', { current: step, total: TOTAL_STEPS })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skip / close button (steps 1-3 only, step 4 has its own CTA) */}
|
||||
{step < 4 && (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute top-4 right-4 rounded-lg p-1.5 text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Step content */}
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<OnboardingStepWelcome
|
||||
key="step-1"
|
||||
onNext={handleNext}
|
||||
onSkip={handleSkip}
|
||||
userName={user?.name}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<OnboardingStepNotes
|
||||
key="step-2"
|
||||
noteCount={noteCount}
|
||||
onNext={handleNotesNext}
|
||||
onSkip={handleSkip}
|
||||
locale={language}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<OnboardingStepAha
|
||||
key="step-3"
|
||||
onDone={handleNext}
|
||||
locale={language}
|
||||
autoSearch={demoNotesJustCreated}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<OnboardingStepFeatures
|
||||
key="step-4"
|
||||
onDone={handleDone}
|
||||
onTry={handleTry}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
59
memento-note/components/onboarding/starter-pack-badge.tsx
Normal file
59
memento-note/components/onboarding/starter-pack-badge.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Zap, ArrowUpRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface QuotaData { remaining: number; limit: number; used: number }
|
||||
interface UsageData { quotas: Record<string, QuotaData>; tier: string }
|
||||
|
||||
export function StarterPackBadge() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const { data } = useQuery<UsageData>({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current')
|
||||
if (!res.ok) throw new Error('Failed')
|
||||
return res.json()
|
||||
},
|
||||
staleTime: 5000,
|
||||
})
|
||||
|
||||
if (!data) return null
|
||||
// Paid tiers do not need the badge; only BASIC (free) users see it
|
||||
const PAID_TIERS = ['PRO', 'BUSINESS', 'ENTERPRISE']
|
||||
if (PAID_TIERS.includes(data.tier)) return null
|
||||
|
||||
const semanticQuota = data.quotas?.['semantic_search']
|
||||
if (!semanticQuota) return null
|
||||
|
||||
const remaining = semanticQuota.remaining ?? 0
|
||||
const isCritical = remaining === 0
|
||||
const isWarning = remaining > 0 && remaining < 5
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium transition-colors',
|
||||
isCritical
|
||||
? 'bg-destructive/10 text-destructive hover:bg-destructive/20'
|
||||
: isWarning
|
||||
? 'bg-orange-500/10 text-orange-500 hover:bg-orange-500/20'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'
|
||||
)}
|
||||
>
|
||||
<Zap className={cn('h-3.5 w-3.5', isWarning || isCritical ? 'animate-pulse' : '')} />
|
||||
<span>
|
||||
{isCritical
|
||||
? t('onboarding.badge_upgrade')
|
||||
: t('onboarding.badge_credits', { count: remaining })
|
||||
}
|
||||
</span>
|
||||
{isCritical && <ArrowUpRight className="h-3 w-3 ml-auto" />}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import type { ReactNode } from 'react'
|
||||
import type { Translations } from '@/lib/i18n/load-translations'
|
||||
import { AiConsentProvider } from '@/components/legal/ai-consent-provider'
|
||||
import { SearchModalProvider } from '@/context/search-modal-context'
|
||||
import { OnboardingWizard } from '@/components/onboarding/onboarding-wizard'
|
||||
import { OnboardingEditorHints } from '@/components/onboarding/onboarding-editor-hints'
|
||||
|
||||
const RTL_LANGUAGES = ['ar', 'fa']
|
||||
|
||||
@@ -42,6 +44,8 @@ export function ProvidersWrapper({
|
||||
<DirWrapper>
|
||||
<SearchModalProvider>
|
||||
{children}
|
||||
<OnboardingWizard />
|
||||
<OnboardingEditorHints />
|
||||
</SearchModalProvider>
|
||||
</DirWrapper>
|
||||
</AiConsentProvider>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlock
|
||||
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
|
||||
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
|
||||
import { BlockSelectionExtension } from '@/lib/editor/block-selection-extension'
|
||||
import { MarkdownPasteExtension } from '@/lib/editor/markdown-paste-extension'
|
||||
import { TurnIntoShortcutExtension } from '@/lib/editor/turn-into-shortcut-extension'
|
||||
import { UndoRedoFeedbackExtension } from '@/lib/editor/undo-redo-feedback-extension'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
@@ -417,6 +418,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
...globalDragHandleExtensions,
|
||||
SmartPasteExtension,
|
||||
BlockSelectionExtension,
|
||||
MarkdownPasteExtension,
|
||||
TurnIntoShortcutExtension,
|
||||
UndoRedoFeedbackExtension,
|
||||
LiveBlockExtension,
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import { performSignOut } from '@/lib/auth-client'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import { UsageMeter } from './usage-meter'
|
||||
import { StarterPackBadge } from './onboarding/starter-pack-badge'
|
||||
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
@@ -1535,8 +1536,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* ── Usage meter en bas du panneau ── */}
|
||||
<div className="border-t border-border/20 px-3 py-3 mt-auto">
|
||||
{/* ── Usage meter + starter badge en bas du panneau ── */}
|
||||
<div className="border-t border-border/20 px-3 py-3 mt-auto space-y-2">
|
||||
<StarterPackBadge />
|
||||
<UsageMeter />
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user