fix: comprehensive i18n — replace hardcoded French/English strings with t() calls
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s

Replaced ~100+ hardcoded French and English text strings across 30+ components
with proper i18n t() calls. Added 57 new translation keys to all 15 locale files
(ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh).

Key changes:
- contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders)
- ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history)
- note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist)
- lab-skeleton.tsx: French loading text → t()
- admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed
- New AI chat component, agent cards, sidebar, settings panel i18n cleanup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 21:14:45 +02:00
parent e358171c45
commit 153c921960
60 changed files with 4125 additions and 1677 deletions

View File

@@ -10,11 +10,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { LabelBadge } from '@/components/label-badge'
import { EditorConnectionsSection } from '@/components/editor-connections-section'
import { FusionModal } from '@/components/fusion-modal'
@@ -23,9 +18,6 @@ import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import {
updateNote,
togglePin,
toggleArchive,
updateColor,
deleteNote,
createNote,
} from '@/app/actions/notes'
@@ -46,13 +38,7 @@ import {
Sparkles,
Loader2,
Check,
Wand2,
AlignLeft,
Minimize2,
Lightbulb,
RotateCcw,
Languages,
ChevronRight,
} from 'lucide-react'
import { toast } from 'sonner'
import { MarkdownContent } from '@/components/markdown-content'
@@ -63,9 +49,13 @@ import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { TitleSuggestions } from '@/components/title-suggestions'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useNotebooks } from '@/context/notebooks-context'
import { ContextualAIChat } from '@/components/contextual-ai-chat'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
interface NoteInlineEditorProps {
note: Note
@@ -104,6 +94,20 @@ export function NoteInlineEditor({
defaultPreviewMode = false,
}: NoteInlineEditorProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
import('@/app/actions/ai-settings').then(({ getAISettings }) => {
getAISettings(session.user.id).then(settings => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
})
}
}, [session?.user?.id])
const { labels: globalLabels, addLabel } = useLabels()
const [, startTransition] = useTransition()
const { triggerRefresh } = useNoteRefresh()
@@ -131,13 +135,14 @@ export function NoteInlineEditor({
const [showLinkInput, setShowLinkInput] = useState(false)
const [isAddingLink, setIsAddingLink] = useState(false)
// AI popover
// AI side panel
const [aiOpen, setAiOpen] = useState(false)
const [isProcessingAI, setIsProcessingAI] = useState(false)
// Undo after AI: saves content before transformation
// Undo after AI copilot applies content
const [previousContent, setPreviousContent] = useState<string | null>(null)
// Translate sub-panel
const [showTranslate, setShowTranslate] = useState(false)
// Notebooks list (for copilot chat scope)
const { notebooks } = useNotebooks()
const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
@@ -223,7 +228,7 @@ export function NoteInlineEditor({
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text',
enabled: note.type === 'text' && autoLabelingEnabled,
})
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(
@@ -295,7 +300,7 @@ export function NoteInlineEditor({
onChange?.(note.id, { isPinned: !prev })
try {
await updateNote(note.id, { isPinned: !prev }, { skipRevalidation: true })
toast.success(prev ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée')
toast.success(prev ? t('notes.unpinned') : t('notes.pinned') )
} catch {
onChange?.(note.id, { isPinned: prev })
toast.error(t('general.error'))
@@ -390,89 +395,6 @@ export function NoteInlineEditor({
await updateNote(note.id, { links: newLinks })
}
// ── AI actions (called from Popover in toolbar) ───────────────────────────
const callAI = async (option: 'clarify' | 'shorten' | 'improve') => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 10) {
toast.error(t('ai.reformulationMinWords', { count: wc }))
return
}
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content) // save for undo
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to reformulate')
changeContent(data.reformulatedText || data.text)
scheduleSave()
toast.success(t('ai.reformulationApplied'))
} catch {
toast.error(t('ai.reformulationFailed'))
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
const callTranslate = async (targetLanguage: string) => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 3) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content)
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, targetLanguage }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Translation failed')
changeContent(data.translatedText)
scheduleSave()
toast.success(t('ai.translationApplied') || `Traduit en ${targetLanguage}`)
} catch {
toast.error(t('ai.translationFailed') || 'Traduction échouée')
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
const handleTransformMarkdown = async () => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 10) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content)
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/transform-markdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
changeContent(data.transformedText)
setIsMarkdown(true)
scheduleSave()
toast.success(t('ai.transformSuccess'))
} catch {
toast.error(t('ai.transformError'))
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
// ── Checklist helpers ─────────────────────────────────────────────────────
const handleToggleCheckItem = (id: string) => {
const updated = checkItems.map((ci) =>
@@ -503,231 +425,98 @@ export function NoteInlineEditor({
const dateLocale = getDateLocale(language)
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-full w-full overflow-hidden">
<div className="flex flex-1 min-w-0 flex-col overflow-hidden transition-all duration-300">
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2">
<div className="flex items-center gap-1">
{/* Image upload */}
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0"
title={t('notes.addImage') || 'Ajouter une image'}
onClick={() => fileInputRef.current?.click()}
>
{/* ── Toolbar ───────────────────────────────────────────────── */}
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-1.5 gap-2">
{/* Left group: content tools */}
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-8 w-8"
title={t('notes.addImage') }
onClick={() => fileInputRef.current?.click()}>
<ImageIcon className="h-4 w-4" />
</Button>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
{/* Link */}
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0"
title={t('notes.addLink') || 'Ajouter un lien'}
onClick={() => setShowLinkInput(!showLinkInput)}
>
<Button variant="ghost" size="icon" className="h-8 w-8"
title={t('notes.addLink') }
onClick={() => setShowLinkInput(!showLinkInput)}>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Markdown toggle */}
<Button
variant="ghost" size="sm"
className={cn('h-8 gap-1 px-2 text-xs', isMarkdown && 'text-primary')}
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false); scheduleSave() }}
title="Markdown"
>
<FileText className="h-3.5 w-3.5" />
<span className="hidden sm:inline">MD</span>
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
{isMarkdown && (
<Button
variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs"
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
>
<Eye className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{showMarkdownPreview ? t('notes.edit') || 'Éditer' : t('notes.preview') || 'Aperçu'}</span>
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
<Eye className="h-4 w-4" />
</Button>
)}
{/* ── AI Popover (in toolbar, non-intrusive) ─────────────────────── */}
{note.type === 'text' && (
<Popover open={aiOpen} onOpenChange={(o) => { setAiOpen(o); if (!o) setShowTranslate(false) }}>
<PopoverTrigger asChild>
<Button
variant="ghost" size="sm"
className={cn(
'h-8 gap-1.5 px-2 text-xs transition-colors',
isProcessingAI && 'text-primary',
aiOpen && 'bg-muted text-primary',
)}
disabled={isProcessingAI}
title="Assistant IA"
>
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />
}
<span className="hidden sm:inline">IA</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{!showTranslate ? (
<div className="flex flex-col gap-0.5">
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('clarify')}
>
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.clarify') || 'Clarifier'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.clarifyDesc') || 'Rendre plus clair'}</p>
</div>
</button>
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('shorten')}
>
<Minimize2 className="h-4 w-4 text-blue-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.shorten') || 'Raccourcir'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.shortenDesc') || 'Version concise'}</p>
</div>
</button>
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('improve')}
>
<AlignLeft className="h-4 w-4 text-emerald-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.improve') || 'Améliorer'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.improveDesc') || 'Meilleure rédaction'}</p>
</div>
</button>
<button type="button"
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left w-full"
onClick={() => setShowTranslate(true)}
>
<div className="flex items-center gap-2">
<Languages className="h-4 w-4 text-sky-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.translate') || 'Traduire'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.translateDesc') || 'Changer la langue'}</p>
</div>
</div>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
</button>
<div className="my-0.5 border-t border-border/40" />
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={handleTransformMarkdown}
>
<Wand2 className="h-4 w-4 text-violet-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.toMarkdown') || 'En Markdown'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.toMarkdownDesc') || 'Formater en MD'}</p>
</div>
</button>
</div>
) : (
<div className="flex flex-col gap-0.5">
<button type="button"
className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowTranslate(false)}
>
<RotateCcw className="h-3 w-3" />
{t('ai.translateBack') || 'Retour'}
</button>
<div className="my-0.5 border-t border-border/40" />
{[
{ code: 'French', label: 'Français 🇫🇷' },
{ code: 'English', label: 'English 🇬🇧' },
{ code: 'Persian', label: 'فارسی 🇮🇷' },
{ code: 'Spanish', label: 'Español 🇪🇸' },
{ code: 'German', label: 'Deutsch 🇩🇪' },
{ code: 'Italian', label: 'Italiano 🇮🇹' },
{ code: 'Portuguese', label: 'Português 🇵🇹' },
{ code: 'Arabic', label: 'العربية 🇸🇦' },
{ code: 'Chinese', label: '中文 🇨🇳' },
{ code: 'Japanese', label: '日本語 🇯🇵' },
].map(({ code, label }) => (
<button key={code} type="button"
className="w-full rounded-md px-3 py-1.5 text-sm hover:bg-muted text-left"
onClick={() => callTranslate(code)}
>
{label}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
{note.type === 'text' && aiAssistantEnabled && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}
title={t('ai.aiCopilot')}>
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{t('ai.aiCopilot')}</span>
</Button>
)}
{/* ── Undo AI button ─────────────────────────────────────────────── */}
{previousContent !== null && (
<Button
variant="ghost" size="sm"
className="h-8 gap-1.5 px-2 text-xs text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
title={t('ai.undoAI') || 'Annuler transformation IA'}
onClick={() => {
changeContent(previousContent)
setPreviousContent(null)
scheduleSave()
toast.info(t('ai.undoApplied') || 'Texte original restauré')
}}
>
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-500 hover:text-amber-600"
title={t('ai.undoAI') }
onClick={() => { changeContent(previousContent); setPreviousContent(null); scheduleSave(); toast.info(t('ai.undoApplied') ) }}>
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('ai.undo') || 'Annuler IA'}</span>
</Button>
)}
</div>
{/* Right group: meta actions + save indicator */}
<div className="flex items-center gap-1">
{/* Save status indicator */}
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> Sauvegarde</>
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
) : isDirty ? (
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> Modifié</>
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> {t('notes.dirtyStatus')}</>
) : (
<><Check className="h-3 w-3 text-emerald-500" /> Sauvegardé</>
<><Check className="h-3 w-3 text-emerald-500" /> {t('notes.savedStatus')}</>
)}
</span>
{/* Pin */}
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
title={note.isPinned ? t('notes.unpin') : t('notes.pin')} onClick={handleTogglePin}>
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current text-primary')} />
</Button>
{/* Color picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.changeColor')}>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
<button type="button"
key={name}
className={cn(
'h-7 w-7 rounded-full border-2 transition-transform hover:scale-110',
cls.bg,
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => handleColorChange(name)}
title={name}
/>
<button type="button" key={name}
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', cls.bg,
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
onClick={() => handleColorChange(name)} title={name} />
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* More actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.moreOptions')}>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.moreOptions')}>
<span className="text-base leading-none text-muted-foreground"></span>
</Button>
</DropdownMenuTrigger>
@@ -759,7 +548,7 @@ export function NoteInlineEditor({
autoFocus
/>
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Ajouter'}
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : t('notes.add')}
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
<X className="h-4 w-4" />
@@ -785,19 +574,18 @@ export function NoteInlineEditor({
</div>
)}
{/* ── Scrollable editing area (takes all remaining height) ─────────── */}
<div className="flex flex-1 flex-col overflow-y-auto px-8 py-5">
{/* Title row with optional AI suggest button */}
<div className="group relative flex items-start gap-2 shrink-0">
{/* ── Scrollable editing area ── */}
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-5">
{/* Title */}
<div className="group relative flex items-start gap-2 shrink-0 mb-1">
<input
type="text"
dir="auto"
className="flex-1 bg-transparent text-2xl font-bold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
className="flex-1 bg-transparent text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title}
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
/>
{/* AI title suggestion — show when title is empty and there's content */}
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
<button type="button"
onClick={async (e) => {
@@ -814,15 +602,13 @@ export function NoteInlineEditor({
const suggested = data.title || data.suggestedTitle || ''
if (suggested) { changeTitle(suggested); scheduleSave() }
}
} catch { /* silent */ } finally { setIsProcessingAI(false) }
} catch { } finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="mt-1.5 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
title="Suggestion de titre par IA"
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
title={t('ai.suggestTitle')}
>
{isProcessingAI
? <Loader2 className="h-4 w-4 animate-spin" />
: <Sparkles className="h-4 w-4" />}
{isProcessingAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</button>
)}
</div>
@@ -884,8 +670,8 @@ export function NoteInlineEditor({
dir="auto"
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={isMarkdown
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
: t('notes.takeNote') || 'Écris quelque chose…'
? t('notes.takeNoteMarkdown')
: t('notes.takeNote')
}
value={content}
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
@@ -908,7 +694,7 @@ export function NoteInlineEditor({
dir="auto"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
value={ci.text}
placeholder={t('notes.listItem') || 'Élément…'}
placeholder={t('notes.listItem') }
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
/>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
@@ -922,13 +708,13 @@ export function NoteInlineEditor({
onClick={handleAddCheckItem}
>
<Plus className="h-4 w-4" />
{t('notes.addItem') || 'Ajouter un élément'}
{t('notes.addItem') }
</button>
{checkItems.filter((ci) => ci.checked).length > 0 && (
<div className="mt-3">
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
Complétés ({checkItems.filter((ci) => ci.checked).length})
{t('notes.completedLabel')} ({checkItems.filter((ci) => ci.checked).length})
</p>
{checkItems.filter((ci) => ci.checked).map((ci) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
@@ -964,7 +750,7 @@ export function NoteInlineEditor({
{/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
<span>{t('notes.modified') || 'Modifiée'} {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>·</span>
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
</div>
@@ -988,6 +774,28 @@ export function NoteInlineEditor({
notes={comparisonNotes}
/>
)}
</div>
{/* ── AI Copilot Side Panel ── */}
{aiOpen && (
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
onApplyToNote={(newContent) => {
setPreviousContent(content)
changeContent(newContent)
scheduleSave()
}}
onUndoLastAction={previousContent !== null ? () => {
changeContent(previousContent)
setPreviousContent(null)
scheduleSave()
} : undefined}
lastActionApplied={previousContent !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
/>
)}
</div>
)
}