fix: images, couverture, slash, agents, wizard, Stripe et admin
- Collage/accès images: ownership path userId + meta legacy, 403 non caché - Couverture note: explicite (choix/aucune), plus d’auto depuis le corps - Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée - Slash: listes par titre (plus d’index), keywords nettoyés - Agents: nextRun à la réactivation, cron sans stampede null - Wizard: titres courts + mode Expert plus robuste - Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings - Indexation notes courtes + test unit aligné
This commit is contained in:
@@ -539,8 +539,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
{/* 1. Status */}
|
||||
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none me-1">
|
||||
{state.isSaving
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
|
||||
: state.isDirty
|
||||
@@ -557,165 +558,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</span>
|
||||
)}
|
||||
|
||||
{state.isMarkdown && !readOnly && (
|
||||
{/* 2. Save (primary) */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
|
||||
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
|
||||
onClick={() => actions.handleSaveInPlace()}
|
||||
disabled={state.isSaving || !state.isDirty}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.showMarkdownPreview
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
state.isDirty
|
||||
? 'bg-foreground text-background border-foreground hover:opacity-80'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Eye size={16} />
|
||||
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state.isMarkdown && !readOnly && (
|
||||
<button
|
||||
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||
onClick={handleConvertToRichtext}
|
||||
disabled={isConverting}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
isConverting && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
title={t('ai.openAssistant')}
|
||||
aria-label={t('ai.openAssistant')}
|
||||
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.aiOpen
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={t('notes.brainstormThisIdea')}
|
||||
aria-label={t('notes.brainstormThisIdeaAria')}
|
||||
onClick={() => {
|
||||
const title = note.title || ''
|
||||
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
|
||||
const seed = title ? `${title}. ${summary}` : summary
|
||||
if (!seed.trim()) return
|
||||
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
|
||||
}}
|
||||
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
|
||||
>
|
||||
<Wind size={16} />
|
||||
</button>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="relative">
|
||||
<button
|
||||
title={t('flashcards.toolbarGenerate')}
|
||||
aria-label={t('flashcards.toolbarGenerate')}
|
||||
onClick={() => setShowEduMenu(v => !v)}
|
||||
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
<GraduationCap size={16} />
|
||||
</button>
|
||||
{showEduMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
|
||||
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
|
||||
<GraduationCap size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
|
||||
disabled={generatingExercises}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
|
||||
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && voiceSupported && (
|
||||
<button
|
||||
title={voiceState === 'listening'
|
||||
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
||||
: (t('editor.voiceStart') || 'Dicter du texte')}
|
||||
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
|
||||
onClick={toggleVoice}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
voiceState === 'listening'
|
||||
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && onToggleAttachments && (
|
||||
<button
|
||||
title={t('notes.attachments') || 'Attachments'}
|
||||
aria-label={t('notes.attachments') || 'Attachments'}
|
||||
onClick={onToggleAttachments}
|
||||
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
{(attachmentsCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
|
||||
{attachmentsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
|
||||
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
|
||||
onClick={() => actions.handleSaveInPlace()}
|
||||
disabled={state.isSaving || !state.isDirty}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.isDirty
|
||||
? 'bg-foreground text-background border-foreground hover:opacity-80'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Publish */}
|
||||
{!readOnly && (
|
||||
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -904,6 +765,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* 4. Share */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
title={t('notes.shareNoteTitle')}
|
||||
@@ -915,6 +777,151 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 5. AI assistant */}
|
||||
<button
|
||||
title={t('ai.openAssistant')}
|
||||
aria-label={t('ai.openAssistant')}
|
||||
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.aiOpen
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
|
||||
{/* 6. Brainstorm */}
|
||||
<button
|
||||
title={t('notes.brainstormThisIdea')}
|
||||
aria-label={t('notes.brainstormThisIdeaAria')}
|
||||
onClick={() => {
|
||||
const title = note.title || ''
|
||||
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
|
||||
const seed = title ? `${title}. ${summary}` : summary
|
||||
if (!seed.trim()) return
|
||||
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
|
||||
}}
|
||||
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
|
||||
>
|
||||
<Wind size={16} />
|
||||
</button>
|
||||
|
||||
{/* 7. Education (flashcards / exercises) */}
|
||||
{!readOnly && (
|
||||
<div className="relative">
|
||||
<button
|
||||
title={t('flashcards.toolbarGenerate')}
|
||||
aria-label={t('flashcards.toolbarGenerate')}
|
||||
onClick={() => setShowEduMenu(v => !v)}
|
||||
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
<GraduationCap size={16} />
|
||||
</button>
|
||||
{showEduMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
|
||||
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
|
||||
<GraduationCap size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
|
||||
disabled={generatingExercises}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
|
||||
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 8. Voice / attachments / markdown tools */}
|
||||
{!readOnly && voiceSupported && (
|
||||
<button
|
||||
title={voiceState === 'listening'
|
||||
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
||||
: (t('editor.voiceStart') || 'Dicter du texte')}
|
||||
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
|
||||
onClick={toggleVoice}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
voiceState === 'listening'
|
||||
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && onToggleAttachments && (
|
||||
<button
|
||||
title={t('notes.attachments') || 'Attachments'}
|
||||
aria-label={t('notes.attachments') || 'Attachments'}
|
||||
onClick={onToggleAttachments}
|
||||
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
{(attachmentsCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
|
||||
{attachmentsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state.isMarkdown && !readOnly && (
|
||||
<button
|
||||
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.showMarkdownPreview
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state.isMarkdown && !readOnly && (
|
||||
<button
|
||||
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||
onClick={handleConvertToRichtext}
|
||||
disabled={isConverting}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
isConverting && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 9. Overflow menu */}
|
||||
{!readOnly && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
Reference in New Issue
Block a user