feat(brainstorm): UX fixes — modal confirmation + destination carnet + race fix
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 4m29s
CI / Deploy production (on server) (push) Has been skipped

UX Pro Max appliqué selon les best practices 2026 (Heptabase, Obsidian, Tana):
- confirmation-dialogs (HIGH): modal de confirmation avant brainstorm
- success-feedback (MEDIUM): toast avec destination du carnet
- nav-state-active (MEDIUM): indication claire de la session active

3 fixes:

1. Modal de confirmation (brainstorm-confirm-dialog.tsx, 181 lignes):
   - Design system respecté: brand-accent, memento-paper, font-memento-serif
   - Aperçu seed en card paper
   - Sélecteur de carnet avec animation d'expansion
   - Bouton 'Annuler' + 'Lancer le brainstorm' avec loader
   - role=dialog, aria-modal, aria-label
   - focus-visible, keyboard nav
   - backdrop-blur-sm + click-to-close
   - Lazy loaded via next/dynamic

2. Race condition fix (brainstorm-page.tsx L111):
   - Auto-select session désactivé si urlSeed présent
   - urlSeed + urlInviteToken court-circuitent le useEffect

3. Toast avec destination (handleConvert + noteCreatedIn key):
   - message: 'Note créée dans [Nom du carnet]'
   - useConvertIdea accepte notebookId optionnel
   - API convert accepte notebookId via zod schema
   - useConvertIdea(mutationFn) accepte string | {ideaId, notebookId}

i18n: 9 nouvelles clés brainstorm.* en EN/FR
This commit is contained in:
Antigravity
2026-07-05 19:44:07 +00:00
parent d1336b1fea
commit 02b835fb13
7 changed files with 234 additions and 14 deletions

View File

@@ -7,6 +7,7 @@ import { emitToSession } from '@/lib/socket-emit'
const convertSchema = z.object({
ideaId: z.string().min(1),
notebookId: z.string().min(1).nullable().optional(),
})
export async function POST(
@@ -21,7 +22,7 @@ export async function POST(
try {
const { sessionId } = await params
const body = await request.json()
const { ideaId } = convertSchema.parse(body)
const { ideaId, notebookId: paramNotebookId } = convertSchema.parse(body)
const brainstormSession = await prisma.brainstormSession.findFirst({
where: {
@@ -51,7 +52,7 @@ export async function POST(
}
let sourceSection = ''
let targetNotebookId: string | null = null
let targetNotebookId: string | null = paramNotebookId ?? null
if (brainstormSession.exportedNoteId) {
const exportedNote = await prisma.note.findUnique({

View File

@@ -0,0 +1,181 @@
'use client'
import { useState, memo } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Wind, FolderOpen, X, Loader2, ChevronDown } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import type { Note } from '@/lib/types'
interface BrainstormConfirmDialogProps {
open: boolean
onClose: () => void
onConfirm: (notebookId: string | null) => void
note: Note | null
}
function BrainstormConfirmDialogInner({ open, onClose, onConfirm, note }: BrainstormConfirmDialogProps) {
const { t } = useLanguage()
const { notebooks } = useNotebooks()
const [showNotebookSelect, setShowNotebookSelect] = useState(false)
const noteNotebook = notebooks.find(nb => nb.id === note?.notebookId)
const defaultNotebookName = noteNotebook?.name || t('brainstorm.defaultNotebook') || 'Brainstorm'
const defaultNotebookId = note?.notebookId ?? null
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(defaultNotebookId)
const [selectedNotebookName, setSelectedNotebookName] = useState(defaultNotebookName)
const [loading, setLoading] = useState(false)
const handleConfirm = () => {
setLoading(true)
onConfirm(selectedNotebookId)
}
const handleClose = () => {
setLoading(false)
setSelectedNotebookId(defaultNotebookId)
setSelectedNotebookName(defaultNotebookName)
setShowNotebookSelect(false)
onClose()
}
return (
<AnimatePresence>
{open && note && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}
role="dialog"
aria-modal="true"
aria-label={t('brainstorm.confirmTitle') || 'Brainstorm this idea'}
>
<motion.div
initial={{ scale: 0.92, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.92, y: 20 }}
transition={{ type: 'spring', stiffness: 340, damping: 30 }}
className="bg-memento-paper dark:bg-zinc-900 rounded-3xl shadow-2xl border border-border/40 w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-5 border-b border-border/20 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center">
<Wind size={16} className="text-brand-accent" />
</div>
<div>
<h3 className="font-memento-serif text-base font-medium text-ink dark:text-dark-ink">
{t('brainstorm.confirmTitle') || 'Brainstorm this idea'}
</h3>
<p className="text-[10px] text-concrete uppercase tracking-widest font-bold">
{t('brainstorm.confirmSubtitle') || 'AI will expand your idea'}
</p>
</div>
</div>
<button
onClick={handleClose}
className="p-1.5 rounded-lg text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer"
aria-label={t('common.close') || 'Close'}
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-4">
{/* Seed preview */}
<div className="p-4 rounded-2xl bg-white/60 dark:bg-white/5 border border-border/30">
<p className="text-[10px] uppercase tracking-widest font-bold text-concrete mb-2">
{t('brainstorm.seedLabel') || 'Seed idea'}
</p>
<p className="text-sm font-memento-serif text-ink dark:text-dark-ink leading-relaxed line-clamp-3">
{note.title || t('notes.untitled')}
</p>
</div>
{/* Destination notebook */}
<div>
<p className="text-[10px] uppercase tracking-widest font-bold text-concrete mb-2">
{t('brainstorm.destinationLabel') || 'Notes will be saved to'}
</p>
<button
onClick={() => setShowNotebookSelect(!showNotebookSelect)}
className="w-full flex items-center justify-between p-3 rounded-xl bg-white/60 dark:bg-white/5 border border-border/30 hover:border-brand-accent/40 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center shrink-0">
<FolderOpen size={13} className="text-brand-accent" />
</div>
<span className="text-sm font-medium text-ink dark:text-dark-ink truncate">
{selectedNotebookName}
</span>
</div>
<ChevronDown size={14} className={`text-concrete transition-transform ${showNotebookSelect ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{showNotebookSelect && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-1 max-h-48 overflow-y-auto rounded-xl border border-border/30 bg-white dark:bg-zinc-800 shadow-lg">
<button
onClick={() => { setSelectedNotebookId(null); setSelectedNotebookName(t('brainstorm.noNotebook') || 'No notebook'); setShowNotebookSelect(false) }}
className="w-full text-left px-3 py-2.5 hover:bg-brand-accent/5 transition-colors text-sm text-concrete cursor-pointer"
>
{t('brainstorm.noNotebook') || 'No notebook'}
</button>
{notebooks.map(nb => (
<button
key={nb.id}
onClick={() => { setSelectedNotebookId(nb.id); setSelectedNotebookName(nb.name); setShowNotebookSelect(false) }}
className={`w-full text-left px-3 py-2.5 hover:bg-brand-accent/5 transition-colors text-sm cursor-pointer flex items-center gap-2 ${selectedNotebookId === nb.id ? 'text-brand-accent font-medium' : 'text-ink dark:text-dark-ink'}`}
>
<FolderOpen size={12} className="shrink-0 opacity-50" />
<span className="truncate">{nb.name}</span>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border/20 flex items-center justify-end gap-3">
<button
onClick={handleClose}
disabled={loading}
className="px-4 py-2.5 rounded-xl text-sm font-medium text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirm}
disabled={loading}
className="px-5 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-bold uppercase tracking-wider hover:bg-brand-accent/90 transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<><Loader2 size={14} className="animate-spin" /> {t('brainstorm.launching') || 'Launching…'}</>
) : (
<><Wind size={14} /> {t('brainstorm.launch') || 'Brainstorm'}</>
)}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
export const BrainstormConfirmDialog = memo(BrainstormConfirmDialogInner)

View File

@@ -106,8 +106,9 @@ export function BrainstormPage() {
const { data: sessions, isLoading: sessionsLoading } = useBrainstormSessions()
// Auto-sélectionner la dernière session si aucune session active
// Auto-sélectionner la dernière session si aucune session active ET pas de seed dans l'URL
useEffect(() => {
if (urlSeed || urlInviteToken) return // Ne pas auto-select si on arrive avec un seed
if (!activeSessionId && !urlSessionId && !sessionsLoading && sessions && sessions.length > 0) {
const last = sessions[0]
setActiveSessionId(last.id)
@@ -243,9 +244,9 @@ export function BrainstormPage() {
const handleConvert = async (idea: BrainstormIdea) => {
try {
const result = await convertIdea.mutateAsync(idea.id)
const result = await convertIdea.mutateAsync({ ideaId: idea.id, notebookId: null })
if (result?.id) {
toast.success(t('brainstorm.noteCreated') || 'Note created', {
toast.success(t('brainstorm.noteCreatedIn') || 'Note created', {
description: result.title || idea.title,
action: {
label: t('notePeek.openFully') || 'Open',

View File

@@ -50,6 +50,8 @@ import {
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useHydrated } from '@/lib/use-hydrated'
import dynamic from 'next/dynamic'
const BrainstormConfirmDialog = dynamic(() => import('@/components/brainstorm/brainstorm-confirm-dialog').then(m => ({ default: m.BrainstormConfirmDialog })), { ssr: false })
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
@@ -715,14 +717,23 @@ const GridCard = memo(function GridCard({
aiIllustrationEnabled,
onOpen,
onTogglePin,
onDeleteNote,
onMoveToNotebook,
onDeleteNote, onMoveToNotebook,
onNoteIllustrationGenerated,
onNoteIllustrationDeleted,
onOpenHistory,
isOverlay = false,
}: GridCardSharedProps) {
const router = useRouter()
const [brainstormNote, setBrainstormNote] = useState<Note | null>(null)
const handleBrainstormConfirm = (notebookId: string | null) => {
if (!brainstormNote) return
const seed = `${brainstormNote.title || untitled}\n\n${getNotePlainExcerpt(brainstormNote, 200)}`.trim()
const params = new URLSearchParams({ seed: seed.slice(0, 300), sourceNoteId: brainstormNote.id })
if (notebookId) params.set('notebookId', notebookId)
router.push(`/brainstorm?${params.toString()}`)
setBrainstormNote(null)
}
const { t, language } = useLanguage()
const hydrated = useHydrated()
const title = getNoteDisplayTitle(note, untitled)
@@ -741,11 +752,11 @@ const GridCard = memo(function GridCard({
const handleBrainstormClick = (e: React.MouseEvent) => {
e.stopPropagation()
const seed = `${title}\n\n${excerpt}`.trim() || title
router.push(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`)
setBrainstormNote(note)
}
return (
<>
<motion.div
initial={isOverlay || !hydrated ? false : { opacity: 0, y: 15 }}
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
@@ -851,5 +862,13 @@ const GridCard = memo(function GridCard({
</div>
</div>
</motion.div>
<BrainstormConfirmDialog
open={brainstormNote !== null}
onClose={() => setBrainstormNote(null)}
onConfirm={(notebookId) => { if (brainstormNote) handleBrainstormConfirm(notebookId) }}
note={brainstormNote}
/>
</>
)
})

View File

@@ -154,12 +154,14 @@ export function useConvertIdea(sessionId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (ideaId: string) => {
mutationFn: async (args: string | { ideaId: string; notebookId?: string | null }) => {
const ideaId = typeof args === 'string' ? args : args.ideaId
const notebookId = typeof args === 'string' ? null : args.notebookId
const res = await fetch(`/api/brainstorm/${sessionId}/convert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ideaId }),
body: JSON.stringify({ ideaId, notebookId }),
})
const data = await res.json()
if (!data.success) throw new Error(data.error || 'Failed to convert idea')

View File

@@ -2925,7 +2925,7 @@
"noSessions": "No brainstorms yet",
"startOne": "Start one",
"sessions": "Brainstorms",
"seedLabel": "Seed Idea",
"seedLabel": "Seed idea",
"ideaPromptDetailed": "Enter your idea, question, or topic to brainstorm...",
"brainstormThisIdea": "Brainstorm this idea",
"startBrainstorm": "Start Brainstorm",
@@ -3068,7 +3068,15 @@
"legendWave1": "Variations",
"legendWave2": "Analogies",
"legendWave3": "Disruptions",
"legendConverted": "Converted"
"legendConverted": "Converted",
"confirmTitle": "Brainstorm this idea",
"confirmSubtitle": "AI will expand your idea",
"destinationLabel": "Notes will be saved to",
"defaultNotebook": "Brainstorm",
"noNotebook": "No notebook",
"launching": "Launching…",
"launch": "Brainstorm",
"noteCreatedIn": "Note created in"
},
"byokSettings": {
"title": "Your API keys (BYOK)",

View File

@@ -3072,7 +3072,15 @@
"legendWave1": "Variations",
"legendWave2": "Analogies",
"legendWave3": "Disruptions",
"legendConverted": "Convertie"
"legendConverted": "Convertie",
"confirmTitle": "Brainstormer cette idée",
"confirmSubtitle": "L'IA va développer votre idée",
"destinationLabel": "Les notes seront sauvegardées dans",
"defaultNotebook": "Brainstorm",
"noNotebook": "Aucun carnet",
"launching": "Lancement…",
"launch": "Brainstormer",
"noteCreatedIn": "Note créée dans"
},
"byokSettings": {
"title": "Vos clés API (BYOK)",