fix: Memory Echo sans vol de scroll, badge IA sidebar, wizard langue
- Ne plus auto-scroller vers Memory Echo : pastille bas de page + reset scroll au titre - Badges notes/carnets générés par l’IA dans la sidebar - Wizard : langue du contenu = langue de la requête ; refresh carnet après création - Voix : erreurs not-allowed/no-speech moins bruyantes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||
import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react'
|
||||
import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -12,7 +12,6 @@ import { toast } from 'sonner'
|
||||
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
|
||||
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||
import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity'
|
||||
|
||||
interface ConnectionData {
|
||||
noteId: string
|
||||
@@ -106,6 +105,9 @@ export function MemoryEchoSection({
|
||||
const [embeddingId, setEmbeddingId] = useState<string | null>(null)
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
const [previewTarget, setPreviewTarget] = useState<PreviewTarget | null>(null)
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [sectionInView, setSectionInView] = useState(false)
|
||||
const [cueDismissed, setCueDismissed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -113,6 +115,8 @@ export function MemoryEchoSection({
|
||||
setConsentRequired(false)
|
||||
setConnections([])
|
||||
setRetroRefs([])
|
||||
setCueDismissed(false)
|
||||
setSectionInView(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -154,18 +158,24 @@ export function MemoryEchoSection({
|
||||
}
|
||||
}, [noteId])
|
||||
|
||||
// Scroll doux vers la section quand une forte connexion apparaît (une fois par note)
|
||||
// Observe section visibility — never auto-scroll; cue only when off-screen
|
||||
useEffect(() => {
|
||||
if (isLoading || connections.length === 0) return
|
||||
const top = connections[0]
|
||||
if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return
|
||||
const key = `memory-echo-scroll-${noteId}`
|
||||
if (sessionStorage.getItem(key)) return
|
||||
sessionStorage.setItem(key, '1')
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById('memory-echo-section')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
}, [isLoading, connections, noteId])
|
||||
const el = sectionRef.current
|
||||
if (!el) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setSectionInView(entry.isIntersecting && entry.intersectionRatio > 0.08)
|
||||
},
|
||||
{ root: null, threshold: [0, 0.08, 0.2, 0.5] },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [noteId, isLoading, connections.length, retroRefs.length, consentRequired, isVisible])
|
||||
|
||||
const scrollToSection = useCallback(() => {
|
||||
sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, [])
|
||||
|
||||
const handleEmbed = useCallback(async (conn: ConnectionData) => {
|
||||
setEmbeddingId(conn.noteId)
|
||||
@@ -251,8 +261,29 @@ export function MemoryEchoSection({
|
||||
const topConnection = connections[0]
|
||||
const restConnections = connections.slice(1)
|
||||
|
||||
// Floating cue: signal activity below without stealing the title viewport
|
||||
const showBottomCue =
|
||||
!cueDismissed &&
|
||||
!sectionInView &&
|
||||
(isLoading || hasConnections || hasRetro || consentRequired)
|
||||
|
||||
const cueLabel = isLoading
|
||||
? t('memoryEcho.editorSection.bottomCueLoading')
|
||||
: hasConnections
|
||||
? connections.length === 1
|
||||
? t('memoryEcho.editorSection.bottomCueFoundOne')
|
||||
: t('memoryEcho.editorSection.bottomCueFound', { count: connections.length })
|
||||
: hasRetro
|
||||
? t('memoryEcho.editorSection.bottomCueRetro', { count: retroRefs.length })
|
||||
: t('memoryEcho.editorSection.bottomCueConsent')
|
||||
|
||||
return (
|
||||
<div id="memory-echo-section" className="mt-10 space-y-6 scroll-mt-24">
|
||||
<>
|
||||
<div
|
||||
id="memory-echo-section"
|
||||
ref={sectionRef}
|
||||
className="mt-10 space-y-6 scroll-mt-24"
|
||||
>
|
||||
{isLoading && (
|
||||
<div
|
||||
className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-5 animate-pulse space-y-3"
|
||||
@@ -551,6 +582,47 @@ export function MemoryEchoSection({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showBottomCue && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 z-40 -translate-x-1/2',
|
||||
'flex items-center gap-1 rounded-full border border-indigo-500/25',
|
||||
'bg-background/95 shadow-lg shadow-indigo-500/10 backdrop-blur-md',
|
||||
'pl-3 pr-1.5 py-1.5 max-w-[min(92vw,22rem)]',
|
||||
'animate-in fade-in slide-in-from-bottom-2 duration-300',
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToSection}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-full px-1 py-0.5 text-left transition-colors hover:bg-indigo-500/5"
|
||||
title={t('memoryEcho.editorSection.bottomCueScroll')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-indigo-500" />
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-indigo-500" />
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-foreground/90">
|
||||
{cueLabel}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-indigo-500/80 animate-bounce" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCueDismissed(true)}
|
||||
className="shrink-0 rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t('memoryEcho.editorSection.bottomCueDismiss')}
|
||||
title={t('memoryEcho.editorSection.bottomCueDismiss')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { NoteAttachments } from '@/components/note-attachments'
|
||||
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
|
||||
import { MemoryEchoSection } from '@/components/memory-echo-section'
|
||||
|
||||
@@ -40,6 +40,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
const [attachmentsCount, setAttachmentsCount] = useState(0)
|
||||
const [uploadTrigger, setUploadTrigger] = useState(0)
|
||||
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Always open a note at the top (title visible) — never inherit previous scroll
|
||||
useEffect(() => {
|
||||
const el = scrollRootRef.current
|
||||
if (!el) return
|
||||
el.scrollTop = 0
|
||||
}, [note.id])
|
||||
|
||||
const fetchNotesByIds = async (noteIds: string[]) => {
|
||||
const notes = await Promise.all(noteIds.map(async (id) => {
|
||||
@@ -67,7 +75,10 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
{/* ── outer container ── */}
|
||||
<div className="flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden">
|
||||
{/* ── main scrollable column ── */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0">
|
||||
<div
|
||||
ref={scrollRootRef}
|
||||
className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0"
|
||||
>
|
||||
|
||||
{/* TOOLBAR */}
|
||||
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />
|
||||
|
||||
@@ -117,6 +117,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
|
||||
const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({
|
||||
onTranscript: handleTranscript,
|
||||
onError: (message) => {
|
||||
toast.error(message)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Markdown export ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -48,6 +48,7 @@ import { Notebook, Note } from '@/lib/types'
|
||||
import { toast } from 'sonner'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||
import { listAiCreatedNotebookIds, isRecentlyCreated } from '@/lib/ai-created-highlight'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const CreateNotebookDialog = dynamic(() => import('./create-notebook-dialog').then(m => ({ default: m.CreateNotebookDialog })), { ssr: false })
|
||||
@@ -79,14 +80,20 @@ function NoteLink({
|
||||
title,
|
||||
isActive,
|
||||
isPinned,
|
||||
autoGenerated,
|
||||
isRecent,
|
||||
onClick,
|
||||
}: {
|
||||
title: string
|
||||
isActive: boolean
|
||||
isPinned?: boolean
|
||||
/** Note créée par l'IA (agents, wizard, …) */
|
||||
autoGenerated?: boolean
|
||||
/** Créée récemment (< 48h) — point discret « nouveau » */
|
||||
isRecent?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { language } = useLanguage()
|
||||
const { language, t } = useLanguage()
|
||||
const slideX = language === 'fa' || language === 'ar' ? 10 : -10
|
||||
return (
|
||||
<motion.button
|
||||
@@ -97,14 +104,38 @@ function NoteLink({
|
||||
'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start',
|
||||
isActive
|
||||
? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
|
||||
: autoGenerated
|
||||
? 'text-foreground/90 hover:text-foreground hover:bg-brand-accent/5 border border-transparent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
|
||||
autoGenerated && !isActive && 'bg-brand-accent/[0.04]',
|
||||
)}
|
||||
title={autoGenerated ? (t('sidebar.aiGeneratedNote') || 'Note générée par l\'IA') : undefined}
|
||||
>
|
||||
<FileText
|
||||
size={12}
|
||||
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
|
||||
/>
|
||||
{autoGenerated ? (
|
||||
<Sparkles
|
||||
size={12}
|
||||
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-brand-accent/80')}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<FileText
|
||||
size={12}
|
||||
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate flex-1">{title}</span>
|
||||
{autoGenerated && (
|
||||
<span className="shrink-0 text-[8px] font-bold uppercase tracking-wider text-brand-accent/90 bg-brand-accent/10 px-1.5 py-0.5 rounded">
|
||||
{t('sidebar.aiBadge') || 'IA'}
|
||||
</span>
|
||||
)}
|
||||
{isRecent && !autoGenerated && (
|
||||
<span
|
||||
className="shrink-0 w-1.5 h-1.5 rounded-full bg-brand-accent"
|
||||
title={t('sidebar.recentNote') || 'Récemment créée'}
|
||||
aria-label={t('sidebar.recentNote') || 'Récemment créée'}
|
||||
/>
|
||||
)}
|
||||
{isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />}
|
||||
</motion.button>
|
||||
)
|
||||
@@ -347,6 +378,7 @@ function SidebarCarnetItem({
|
||||
onDelete,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
isAiCreated,
|
||||
children,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
@@ -358,7 +390,7 @@ function SidebarCarnetItem({
|
||||
}: {
|
||||
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
||||
isActive: boolean
|
||||
notes: { id: string; title: string; isPinned?: boolean }[]
|
||||
notes: { id: string; title: string; isPinned?: boolean; autoGenerated?: boolean; isRecent?: boolean }[]
|
||||
activeNoteId: string | null
|
||||
onCarnetClick: () => void
|
||||
onNoteClick: (noteId: string, carnetId: string) => void
|
||||
@@ -367,6 +399,8 @@ function SidebarCarnetItem({
|
||||
onDelete: () => void
|
||||
onTogglePin: () => void
|
||||
isPinned: boolean
|
||||
/** Carnet créé via wizard IA (repère temporaire) */
|
||||
isAiCreated?: boolean
|
||||
children?: React.ReactNode
|
||||
isDragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
@@ -436,8 +470,13 @@ function SidebarCarnetItem({
|
||||
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
|
||||
className={cn(
|
||||
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative overflow-hidden',
|
||||
isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5'
|
||||
isActive
|
||||
? 'bg-white dark:bg-white/10 shadow-sm border border-border/40'
|
||||
: isAiCreated
|
||||
? 'hover:bg-brand-accent/10 border border-brand-accent/20 bg-brand-accent/[0.06]'
|
||||
: 'hover:bg-white/40 dark:hover:bg-white/5',
|
||||
)}
|
||||
title={isAiCreated ? (t('sidebar.aiGeneratedNotebook') || 'Carnet créé avec l\'IA') : undefined}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
@@ -446,6 +485,9 @@ function SidebarCarnetItem({
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
{isAiCreated && !isActive && (
|
||||
<span className="absolute -start-1 w-1 h-4 bg-brand-accent/70 rounded-full" aria-hidden />
|
||||
)}
|
||||
<div className={cn(
|
||||
'w-5 h-5 flex items-center justify-center shrink-0 transition-colors',
|
||||
isActive ? 'text-brand-accent' : 'text-muted-foreground/80',
|
||||
@@ -460,6 +502,12 @@ function SidebarCarnetItem({
|
||||
)}>
|
||||
{carnet.name}
|
||||
</span>
|
||||
{isAiCreated && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 text-[8px] font-bold uppercase tracking-wider text-brand-accent bg-brand-accent/15 px-1.5 py-0.5 rounded">
|
||||
<Sparkles size={9} aria-hidden />
|
||||
{t('sidebar.aiBadge') || 'IA'}
|
||||
</span>
|
||||
)}
|
||||
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
||||
{!isActive && hasActiveDescendant && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" />
|
||||
@@ -573,6 +621,8 @@ function SidebarCarnetItem({
|
||||
key={note.id}
|
||||
title={note.title}
|
||||
isPinned={note.isPinned}
|
||||
autoGenerated={note.autoGenerated}
|
||||
isRecent={note.isRecent}
|
||||
isActive={activeNoteId === note.id}
|
||||
onClick={() => onNoteClick(note.id, carnet.id)}
|
||||
/>
|
||||
@@ -635,7 +685,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
||||
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean }[]>>({})
|
||||
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean; autoGenerated?: boolean; isRecent?: boolean }[]>>({})
|
||||
const [aiNotebookIds, setAiNotebookIds] = useState<Set<string>>(() => new Set())
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||
@@ -886,6 +937,18 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
|
||||
|
||||
// Repères carnets IA (localStorage TTL 7j)
|
||||
useEffect(() => {
|
||||
const refresh = () => setAiNotebookIds(listAiCreatedNotebookIds())
|
||||
refresh()
|
||||
window.addEventListener('memento-ai-created-changed', refresh)
|
||||
window.addEventListener('storage', refresh)
|
||||
return () => {
|
||||
window.removeEventListener('memento-ai-created-changed', refresh)
|
||||
window.removeEventListener('storage', refresh)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notebookIdsKey) return
|
||||
let cancelled = false
|
||||
@@ -897,6 +960,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
id: n.id,
|
||||
title: getNoteDisplayTitle(n, t('notes.untitled')),
|
||||
isPinned: n.isPinned,
|
||||
autoGenerated: Boolean(n.autoGenerated),
|
||||
isRecent: isRecentlyCreated(n.createdAt, 48),
|
||||
}))
|
||||
return [nb.id, mapped] as const
|
||||
})
|
||||
@@ -1261,6 +1326,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
onDelete={() => setDeletingNotebook(notebook)}
|
||||
onTogglePin={() => togglePin(notebook.id)}
|
||||
isPinned={pinnedIds.has(notebook.id)}
|
||||
isAiCreated={aiNotebookIds.has(notebook.id)}
|
||||
isDragging={isDragging}
|
||||
level={level}
|
||||
isExpanded={isExpanded}
|
||||
@@ -1273,7 +1339,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
|
||||
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, aiNotebookIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1977,8 +2043,34 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{showAiWizard && (
|
||||
<AiNotebookWizard
|
||||
onClose={() => setShowAiWizard(false)}
|
||||
onComplete={(notebookId: string) => {
|
||||
onComplete={async (notebookId: string) => {
|
||||
// 1) Marquer IA + expand dans la sidebar
|
||||
try {
|
||||
const { markAiCreatedNotebook } = await import('@/lib/ai-created-highlight')
|
||||
markAiCreatedNotebook(notebookId)
|
||||
} catch { /* ignore */ }
|
||||
setAiNotebookIds((prev) => new Set(prev).add(notebookId))
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(notebookId)
|
||||
return next
|
||||
})
|
||||
// 2) Recharger la liste des carnets (sinon le carnet n'apparaît qu'après F5)
|
||||
await refreshNotebooks()
|
||||
// 3) Forcer le rechargement des notes de ce carnet dans la sidebar
|
||||
try {
|
||||
const notes = await getAllNotes(false, notebookId)
|
||||
const mapped = notes.map((n: Note) => ({
|
||||
id: n.id,
|
||||
title: getNoteDisplayTitle(n, t('notes.untitled')),
|
||||
isPinned: n.isPinned,
|
||||
autoGenerated: Boolean(n.autoGenerated),
|
||||
isRecent: true,
|
||||
}))
|
||||
setNotebookNotes((prev) => ({ ...prev, [notebookId]: mapped }))
|
||||
} catch { /* ignore */ }
|
||||
setShowAiWizard(false)
|
||||
// 4) Ouvrir le carnet
|
||||
router.push(`/home?notebook=${notebookId}`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { GraduationCap, BookOpen, Wrench, Sparkles, Loader2, X, ArrowRight, ArrowLeft, Check, FileText } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { markAiCreatedNotebook } from '@/lib/ai-created-highlight'
|
||||
|
||||
type WizardProfile = 'student' | 'teacher' | 'engineer'
|
||||
|
||||
@@ -42,7 +43,7 @@ const PROFILES: ProfileOption[] = [
|
||||
|
||||
const LEVELS = ['wizard.levelBeginner', 'wizard.levelIntermediate', 'wizard.levelAdvanced', 'wizard.levelExpert']
|
||||
|
||||
export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void }) {
|
||||
export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void | Promise<void> }) {
|
||||
const { t, language } = useLanguage()
|
||||
const [step, setStep] = useState<0 | 1 | 2>(0)
|
||||
const [profile, setProfile] = useState<WizardProfile | null>(null)
|
||||
@@ -52,6 +53,25 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [progressMsg, setProgressMsg] = useState('')
|
||||
const [success, setSuccess] = useState<{ notebookId: string; notebookName: string; noteTitles: string[] } | null>(null)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const completedRef = useRef(false)
|
||||
|
||||
const finishAndOpen = async (notebookId: string, notebookName: string) => {
|
||||
if (completedRef.current) return
|
||||
completedRef.current = true
|
||||
setOpening(true)
|
||||
try {
|
||||
markAiCreatedNotebook(notebookId)
|
||||
const toastMsg = (t('wizard.createdToast') || 'Carnet « {name} » prêt — notes IA disponibles')
|
||||
.replace('{name}', notebookName)
|
||||
toast.success(toastMsg)
|
||||
await onComplete(notebookId)
|
||||
} catch (e: any) {
|
||||
completedRef.current = false
|
||||
setOpening(false)
|
||||
toast.error(e?.message || t('general.error') || 'Erreur')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!profile || !topic.trim()) return
|
||||
@@ -68,7 +88,6 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
|
||||
level: t(LEVELS[level]),
|
||||
count,
|
||||
language,
|
||||
// Server generates a short title from the topic; do not force raw prompt as name
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -87,14 +106,16 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
|
||||
return
|
||||
}
|
||||
|
||||
setProgressMsg(t('wizard.progressCreating') || 'Création du carnet et des notes...')
|
||||
|
||||
const notebookId = data.notebookId as string
|
||||
const notebookName = (data.notebookName || topic) as string
|
||||
setSuccess({
|
||||
notebookId: data.notebookId,
|
||||
notebookName: data.notebookName || topic,
|
||||
notebookId,
|
||||
notebookName,
|
||||
noteTitles: data.noteTitles || [],
|
||||
})
|
||||
setLoading(false)
|
||||
// Ouvre tout de suite : refresh sidebar + navigation (plus besoin de F5)
|
||||
void finishAndOpen(notebookId, notebookName)
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
setLoading(false)
|
||||
@@ -159,12 +180,19 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onComplete(success.notebookId)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
|
||||
type="button"
|
||||
disabled={opening}
|
||||
onClick={() => void finishAndOpen(success.notebookId, success.notebookName)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium disabled:opacity-60"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('wizard.openNotebook') || 'Ouvrir le carnet'}
|
||||
{opening ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
{opening
|
||||
? (t('wizard.openingNotebook') || 'Ouverture…')
|
||||
: (t('wizard.openNotebook') || 'Ouvrir le carnet')}
|
||||
</button>
|
||||
<p className="text-[11px] text-muted-foreground text-center max-w-sm">
|
||||
{t('wizard.autoOpenHint') || 'Ouverture automatique du carnet et mise à jour de la barre latérale…'}
|
||||
</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
|
||||
Reference in New Issue
Block a user