fix: comprehensive i18n — replace hardcoded French/English strings with t() calls
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user