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:
@@ -112,6 +112,7 @@ export function NoteContentArea() {
|
||||
ref={richTextEditorRef}
|
||||
content={state.content}
|
||||
onChange={(v: string) => actions.setContent(v)}
|
||||
onChangeImmediate={(v: string) => actions.setContentImmediate(v)}
|
||||
className="min-h-[280px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
@@ -129,6 +130,7 @@ export function NoteContentArea() {
|
||||
ref={richTextEditorRef}
|
||||
content={state.content}
|
||||
onChange={actions.setContent}
|
||||
onChangeImmediate={actions.setContentImmediate}
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
|
||||
192
memento-note/components/note-editor/note-cover-hero.tsx
Normal file
192
memento-note/components/note-editor/note-cover-hero.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Couverture sous le titre (full-page).
|
||||
* - Affichée uniquement si une image de couverture est **explicite** (pas auto depuis le corps).
|
||||
* - Permet de retirer la couverture sans supprimer les images du contenu.
|
||||
* - Permet de choisir parmi les images de la note (corps + galerie).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ImageIcon, X, Check, Images } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NoteCoverHeroProps {
|
||||
/** URL de couverture explicite (note.images[0]), null = pas de couverture */
|
||||
coverUrl: string | null
|
||||
/** Candidats (images du contenu + galerie) pour le sélecteur */
|
||||
candidates: string[]
|
||||
readOnly?: boolean
|
||||
title?: string
|
||||
onSetCover: (url: string) => void
|
||||
onClearCover: () => void
|
||||
}
|
||||
|
||||
export function NoteCoverHero({
|
||||
coverUrl,
|
||||
candidates,
|
||||
readOnly,
|
||||
title,
|
||||
onSetCover,
|
||||
onClearCover,
|
||||
}: NoteCoverHeroProps) {
|
||||
const { t } = useLanguage()
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
|
||||
const uniqueCandidates = Array.from(new Set(candidates.filter(Boolean)))
|
||||
const hasCover = Boolean(coverUrl)
|
||||
const canPick = !readOnly && uniqueCandidates.length > 0
|
||||
|
||||
// Rien à montrer : pas de cover et pas de candidats (ou lecture seule sans cover)
|
||||
if (!hasCover && (readOnly || uniqueCandidates.length === 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Pas de cover mais des images dans la note → barre discrète pour en choisir une
|
||||
if (!hasCover && canPick) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 px-4 py-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<ImageIcon className="h-4 w-4 shrink-0 opacity-70" />
|
||||
<span className="truncate">
|
||||
{t('notes.coverNoneHint') || 'Aucune image de couverture — choisissez une image de la note ou laissez vide.'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-border bg-background hover:bg-muted transition-colors shrink-0"
|
||||
>
|
||||
<Images className="h-3.5 w-3.5" />
|
||||
{t('notes.coverChoose') || 'Choisir une couverture'}
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<CoverPicker
|
||||
candidates={uniqueCandidates}
|
||||
selected={null}
|
||||
onSelect={(url) => {
|
||||
onSetCover(url)
|
||||
setPickerOpen(false)
|
||||
}}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasCover || !coverUrl) return null
|
||||
|
||||
return (
|
||||
<div className="group/cover relative aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={title || ''}
|
||||
className="w-full h-full object-cover grayscale contrast-110 group-hover/cover:grayscale-0 transition-all duration-500"
|
||||
/>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="absolute inset-x-0 top-0 p-3 flex items-start justify-end gap-2 opacity-0 group-hover/cover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
{uniqueCandidates.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1.5 rounded-lg bg-black/60 text-white hover:bg-black/75 backdrop-blur-sm transition-colors"
|
||||
title={t('notes.coverChoose') || 'Changer la couverture'}
|
||||
>
|
||||
<Images className="h-3.5 w-3.5" />
|
||||
{t('notes.coverChange') || 'Changer'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCover}
|
||||
className="inline-flex items-center justify-center h-8 w-8 rounded-lg bg-black/60 text-white hover:bg-red-600/90 backdrop-blur-sm transition-colors"
|
||||
title={t('notes.coverRemove') || 'Retirer la couverture'}
|
||||
aria-label={t('notes.coverRemove') || 'Retirer la couverture'}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pickerOpen && !readOnly && (
|
||||
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/70 to-transparent">
|
||||
<CoverPicker
|
||||
candidates={uniqueCandidates}
|
||||
selected={coverUrl}
|
||||
onSelect={(url) => {
|
||||
onSetCover(url)
|
||||
setPickerOpen(false)
|
||||
}}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
t={t}
|
||||
dark
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CoverPicker({
|
||||
candidates,
|
||||
selected,
|
||||
onSelect,
|
||||
onClose,
|
||||
t,
|
||||
dark,
|
||||
}: {
|
||||
candidates: string[]
|
||||
selected: string | null
|
||||
onSelect: (url: string) => void
|
||||
onClose: () => void
|
||||
t: (key: string) => string
|
||||
dark?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('w-full', dark ? '' : 'col-span-full pt-1')}>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<p className={cn('text-[11px] font-medium', dark ? 'text-white/80' : 'text-muted-foreground')}>
|
||||
{t('notes.coverPickLabel') || 'Image de couverture'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn('text-[11px] underline-offset-2 hover:underline', dark ? 'text-white/70' : 'text-muted-foreground')}
|
||||
>
|
||||
{t('notes.close') || 'Fermer'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{candidates.map((url) => {
|
||||
const isSelected = selected === url
|
||||
return (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
onClick={() => onSelect(url)}
|
||||
className={cn(
|
||||
'relative shrink-0 h-16 w-24 rounded-lg overflow-hidden border-2 transition-all',
|
||||
isSelected
|
||||
? 'border-brand-accent ring-2 ring-brand-accent/40'
|
||||
: dark
|
||||
? 'border-white/20 hover:border-white/50'
|
||||
: 'border-border hover:border-foreground/30',
|
||||
)}
|
||||
>
|
||||
<img src={url} alt="" className="h-full w-full object-cover" />
|
||||
{isSelected && (
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<Check className="h-5 w-5 text-white drop-shadow" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -332,10 +332,29 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setLinks(links.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
/** Images extraites du corps (TipTap / markdown) — pas de couverture auto */
|
||||
const contentImages = useMemo(() => {
|
||||
if (isMarkdown) {
|
||||
const urls = new Set<string>()
|
||||
for (const match of content.matchAll(/!\[.*?\]\((.*?)\)/g)) {
|
||||
const src = match[1]?.trim()
|
||||
if (src) urls.add(src)
|
||||
}
|
||||
return Array.from(urls)
|
||||
}
|
||||
return extractImagesFromHTML(content)
|
||||
}, [content, isMarkdown])
|
||||
|
||||
/**
|
||||
* allImages = galerie explicite + corps (pour IA, sélecteur de couverture).
|
||||
* La couverture hero n'utilise PAS allImages[0] automatiquement.
|
||||
*/
|
||||
const allImages = useMemo(() => {
|
||||
const extracted = !isMarkdown ? extractImagesFromHTML(content) : []
|
||||
return Array.from(new Set([...images, ...extracted]))
|
||||
}, [images, content, isMarkdown])
|
||||
return Array.from(new Set([...images, ...contentImages]))
|
||||
}, [images, contentImages])
|
||||
|
||||
/** Couverture explicite = première entrée de note.images (jamais auto depuis le corps) */
|
||||
const coverImage = images[0] ?? null
|
||||
|
||||
const resolveContentForSave = useCallback((): string => {
|
||||
if (!isMarkdown) {
|
||||
@@ -345,27 +364,32 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return contentRef.current
|
||||
}, [isMarkdown])
|
||||
|
||||
const resolveImagesForSave = useCallback((contentToSave: string): string[] => {
|
||||
// Images présentes dans le contenu de l'éditeur (inline dans le HTML ou Markdown)
|
||||
let contentImages: string[] = []
|
||||
if (contentToSave) {
|
||||
if (!isMarkdown) {
|
||||
contentImages = extractImagesFromHTML(contentToSave)
|
||||
} else {
|
||||
const urls = new Set<string>()
|
||||
const matches = contentToSave.matchAll(/!\[.*?\]\((.*?)\)/g)
|
||||
for (const match of matches) {
|
||||
const src = match[1]?.trim()
|
||||
if (src) urls.add(src)
|
||||
}
|
||||
contentImages = Array.from(urls)
|
||||
}
|
||||
}
|
||||
// Images "standalone" uploadées séparément (hors contenu éditeur), non supprimées
|
||||
const standaloneImages = images.filter(url => !removedImageUrls.includes(url) && !contentImages.includes(url))
|
||||
// Fusion dédupliquée : images du contenu + images standalone conservées
|
||||
return Array.from(new Set([...contentImages, ...standaloneImages]))
|
||||
}, [isMarkdown, images, removedImageUrls])
|
||||
/**
|
||||
* note.images = couverture / galerie **explicite** uniquement.
|
||||
* Ne plus fusionner les images du corps : sinon, coller une image dans l'éditeur
|
||||
* la plaçait sous le titre, et la supprimer du corps la laissait en "standalone" forever.
|
||||
*/
|
||||
const resolveImagesForSave = useCallback((_contentToSave: string): string[] => {
|
||||
return images.filter((url) => !removedImageUrls.includes(url))
|
||||
}, [images, removedImageUrls])
|
||||
|
||||
const setCoverImage = useCallback((url: string) => {
|
||||
setImages((prev) => {
|
||||
const rest = prev.filter((u) => u !== url)
|
||||
return [url, ...rest]
|
||||
})
|
||||
setRemovedImageUrls((prev) => prev.filter((u) => u !== url))
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const clearCoverImage = useCallback(() => {
|
||||
setImages((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
setRemovedImageUrls((r) => Array.from(new Set([...r, ...prev])))
|
||||
return []
|
||||
})
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
|
||||
const handleGenerateTitles = async () => {
|
||||
@@ -902,6 +926,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
isAnalyzingSuggestions,
|
||||
isMarkdown,
|
||||
allImages,
|
||||
contentImages,
|
||||
coverImage,
|
||||
colorClasses,
|
||||
quotaExceededFeature,
|
||||
autoSaveEnabled,
|
||||
@@ -911,13 +937,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
||||
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
||||
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
|
||||
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature, autoSaveEnabled
|
||||
isAnalyzingSuggestions, isMarkdown, allImages, contentImages, coverImage, colorClasses, quotaExceededFeature, autoSaveEnabled
|
||||
])
|
||||
|
||||
const actions: NoteEditorActions = useMemo(() => ({
|
||||
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
|
||||
setDismissedTitleSuggestions,
|
||||
setContent: (c) => { setContent(c); setIsDirty(true) },
|
||||
setContentImmediate: (c) => { setContentImmediate(c); setIsDirty(true) },
|
||||
setCheckItems,
|
||||
handleCheckItem,
|
||||
handleUpdateCheckItem,
|
||||
@@ -930,6 +957,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setImages,
|
||||
handleImageUpload,
|
||||
handleRemoveImage,
|
||||
setCoverImage,
|
||||
clearCoverImage,
|
||||
uploadImageFile,
|
||||
setLinks,
|
||||
handleAddLink,
|
||||
@@ -974,6 +1003,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setQuotaExceededFeature,
|
||||
toggleAutoSave,
|
||||
}), [
|
||||
setContent, setContentImmediate, setCoverImage, clearCoverImage,
|
||||
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
|
||||
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
|
||||
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useNoteEditorContext } from './note-editor-context'
|
||||
import { NoteEditorToolbar } from './note-editor-toolbar'
|
||||
import { NoteTitleBlock } from './note-title-block'
|
||||
import { NoteContentArea } from './note-content-area'
|
||||
import { EditorImages } from '@/components/editor-images'
|
||||
import { NoteCoverHero } from './note-cover-hero'
|
||||
import { ComparisonModal } from '@/components/comparison-modal'
|
||||
import { FusionModal } from '@/components/fusion-modal'
|
||||
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||
@@ -119,16 +119,15 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hero image — show first note image if present */}
|
||||
{state.allImages.length > 0 && (
|
||||
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
|
||||
<img
|
||||
src={state.allImages[0]}
|
||||
alt={state.title}
|
||||
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Couverture explicite — pas d'auto-promo des images collées dans le corps */}
|
||||
<NoteCoverHero
|
||||
coverUrl={state.coverImage}
|
||||
candidates={state.allImages}
|
||||
readOnly={readOnly}
|
||||
title={state.title}
|
||||
onSetCover={actions.setCoverImage}
|
||||
onClearCover={actions.clearCoverImage}
|
||||
/>
|
||||
|
||||
{/* Content area — max-w-4xl for wider reading column */}
|
||||
<div className="max-w-4xl mx-auto w-full space-y-8 pb-32">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface NoteEditorState {
|
||||
|
||||
isMarkdown: boolean
|
||||
allImages: string[]
|
||||
/** Images extraites du corps de la note (pas la couverture) */
|
||||
contentImages: string[]
|
||||
/** URL de couverture explicite (images[0]), null si aucune */
|
||||
coverImage: string | null
|
||||
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
|
||||
quotaExceededFeature: string | null
|
||||
}
|
||||
@@ -61,6 +65,8 @@ export interface NoteEditorActions {
|
||||
setDismissedTitleSuggestions: (dismissed: boolean) => void
|
||||
|
||||
setContent: (content: string) => void
|
||||
/** Immediate content state (no 800ms debounce) — image paste, conversions, etc. */
|
||||
setContentImmediate: (content: string) => void
|
||||
|
||||
setCheckItems: (items: CheckItem[]) => void
|
||||
handleCheckItem: (id: string) => void
|
||||
@@ -76,6 +82,10 @@ export interface NoteEditorActions {
|
||||
setImages: (images: string[]) => void
|
||||
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
handleRemoveImage: (index: number) => void
|
||||
/** Définit l'image de couverture (sous le titre) sans retirer l'image du corps */
|
||||
setCoverImage: (url: string) => void
|
||||
/** Retire la couverture (les images du corps restent) */
|
||||
clearCoverImage: () => void
|
||||
uploadImageFile: (file: File) => Promise<string>
|
||||
|
||||
setLinks: (links: LinkMetadata[]) => void
|
||||
|
||||
Reference in New Issue
Block a user