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

@@ -1,7 +1,8 @@
'use client'
import { signOut, useSession } from 'next-auth/react'
import { Shield, Search, Settings, LogOut, User, StickyNote, MessageSquare, FlaskConical, Bot } from 'lucide-react'
import { Shield, Search, Settings, LogOut, User, StickyNote, FlaskConical, Bot } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -51,10 +52,10 @@ export function AdminHeader() {
</div>
<input
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
placeholder={t('search.placeholder') || 'Rechercher…'}
placeholder={t('search.placeholder') }
type="text"
disabled
aria-label="Recherche désactivée en mode admin"
aria-label={t('search.disabledAdmin')}
/>
</div>
</label>
@@ -64,13 +65,6 @@ export function AdminHeader() {
<div className="flex flex-1 justify-end gap-2 items-center">
{/* Nav pills — toutes en <a> pour éviter la RSC race condition */}
<div className="hidden md:flex items-center gap-1 bg-slate-100 dark:bg-slate-800/60 rounded-full px-1.5 py-1">
<a
href="/chat"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<MessageSquare className="h-3.5 w-3.5" />
<span>{t('nav.chat') || 'AI Chat'}</span>
</a>
<a
href="/agents"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
@@ -87,6 +81,7 @@ export function AdminHeader() {
</a>
</div>
{/* Notifications */}
<NotificationPanel />
@@ -94,7 +89,7 @@ export function AdminHeader() {
<a
href="/settings"
className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors"
aria-label="Paramètres"
aria-label={t('settings.title')}
>
<Settings className="w-5 h-5" />
</a>
@@ -122,7 +117,7 @@ export function AdminHeader() {
<DropdownMenuItem asChild className="cursor-pointer">
<a href="/settings/profile">
<User className="mr-2 h-4 w-4" />
<span>{t('settings.profile') || 'Profile'}</span>
<span>{t('settings.profile') }</span>
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -131,7 +126,7 @@ export function AdminHeader() {
className="cursor-pointer text-red-600 focus:text-red-600"
>
<LogOut className="mr-2 h-4 w-4" />
<span>{t('auth.signOut') || 'Se déconnecter'}</span>
<span>{t('auth.signOut') }</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -51,7 +51,7 @@ export function AdminSidebar({ className }: AdminSidebarProps) {
>
<nav className="space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href))
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href + '/'))
return (
// <a> instead of <Link>: avoids Next.js RSC navigation transitions

View File

@@ -2,7 +2,7 @@
/**
* Agent Card Component
* Displays a single agent with status, actions, and metadata.
* Compact card matching the reference design — with a "Next Run / Status" footer.
*/
import { useState, useEffect } from 'react'
@@ -13,8 +13,6 @@ import {
Play,
Trash2,
Loader2,
ToggleLeft,
ToggleRight,
Globe,
Search,
Eye,
@@ -22,6 +20,7 @@ import {
CheckCircle2,
XCircle,
Clock,
Pencil,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
@@ -52,11 +51,11 @@ interface AgentCardProps {
// --- Config ---
const typeConfig: Record<string, { icon: typeof Globe; color: string; bgColor: string }> = {
scraper: { icon: Globe, color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800' },
researcher: { icon: Search, color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800' },
monitor: { icon: Eye, color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800' },
custom: { icon: Settings, color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800' },
const typeConfig: Record<string, { icon: typeof Globe; color: string; bgColor: string; label: string }> = {
scraper: { icon: Globe, color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-50 dark:bg-blue-950', label: 'Veilleur' },
researcher: { icon: Search, color: 'text-violet-600 dark:text-violet-400', bgColor: 'bg-violet-50 dark:bg-violet-950', label: 'Chercheur' },
monitor: { icon: Eye, color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-50 dark:bg-amber-950', label: 'Surveillant' },
custom: { icon: Settings, color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-50 dark:bg-emerald-950', label: 'Personnalisé' },
}
const frequencyKeys: Record<string, string> = {
@@ -67,13 +66,6 @@ const frequencyKeys: Record<string, string> = {
monthly: 'agents.frequencies.monthly',
}
const statusKeys: Record<string, string> = {
success: 'agents.status.success',
failure: 'agents.status.failure',
running: 'agents.status.running',
pending: 'agents.status.pending',
}
// --- Component ---
export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps) {
@@ -83,14 +75,13 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
const [isToggling, setIsToggling] = useState(false)
const [mounted, setMounted] = useState(false)
// Prevent hydration mismatch for date formatting
useEffect(() => { setMounted(true) }, [])
const config = typeConfig[agent.type || 'scraper'] || typeConfig.custom
const Icon = config.icon
const lastAction = agent.actions[0]
const dateLocale = language === 'fr' ? fr : enUS
const isNew = new Date(agent.createdAt).getTime() === new Date(agent.updatedAt).getTime()
const isNew = Date.now() - new Date(agent.createdAt).getTime() < 5 * 60 * 1000
const handleRun = async () => {
setIsRunning(true)
@@ -141,114 +132,151 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
}
}
// Derive "Next Run" label
const nextRunLabel = (() => {
if (!agent.isEnabled) return '—'
if (agent.frequency === 'manual') return t('agents.frequencies.manual')
if (agent.nextRun && new Date(agent.nextRun) > new Date()) {
if (!mounted) return '...'
return formatDistanceToNow(new Date(agent.nextRun), { addSuffix: true, locale: dateLocale })
}
return t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')
})()
return (
<div className={`
bg-card rounded-xl border-2 transition-all overflow-hidden
${agent.isEnabled ? 'border-border hover:border-primary/30 hover:shadow-md' : 'border-border/50 opacity-60'}
font-display group flex flex-col bg-card rounded-lg border transition-all duration-200
${agent.isEnabled
? 'border-border/40 hover:border-primary/30 hover:shadow-[0_2px_12px_rgba(0,91,193,0.08)]'
: 'border-border/30 opacity-60'
}
`}>
<div className={`h-1 ${agent.isEnabled ? 'bg-primary' : 'bg-muted'}`} />
{/* Card body */}
<div className="p-4 flex flex-col gap-3 flex-1">
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`p-2 rounded-lg border ${config.bgColor}`}>
<Icon className={`w-4 h-4 ${config.color}`} />
{/* Header row: icon + name/type + toggle */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${config.bgColor}`}>
<Icon className={`w-5 h-5 ${config.color}`} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-card-foreground truncate">{agent.name}</h3>
<div className="flex items-center gap-1.5">
<h3 className="font-semibold text-sm text-card-foreground truncate leading-tight">{agent.name}</h3>
{mounted && isNew && (
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 rounded">
<span className="flex-shrink-0 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 rounded">
{t('agents.newBadge')}
</span>
)}
</div>
<span className={`text-xs font-medium ${config.color}`}>
<span className={`text-[11px] font-bold uppercase tracking-wider ${config.color}`}>
{t(`agents.types.${agent.type || 'custom'}`)}
</span>
</div>
</div>
<button onClick={handleToggle} disabled={isToggling} className="flex-shrink-0 ml-2 disabled:opacity-50 disabled:cursor-not-allowed">
{agent.isEnabled ? (
<ToggleRight className="w-6 h-6 text-primary" />
) : (
<ToggleLeft className="w-6 h-6 text-muted-foreground/40" />
)}
{/* Toggle */}
<button
onClick={handleToggle}
disabled={isToggling}
className="flex-shrink-0 disabled:opacity-50"
title={agent.isEnabled ? 'Désactiver' : 'Activer'}
>
<div className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
agent.isEnabled ? 'bg-primary' : 'bg-muted-foreground/30'
}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
agent.isEnabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`} />
</div>
</button>
</div>
{/* Description */}
{agent.description && (
<p className="text-xs text-muted-foreground mb-3 line-clamp-2">{agent.description}</p>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{agent.description}</p>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3">
{/* Meta: frequency + executions */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')}
</span>
{agent.notebook && (
<span className="flex items-center gap-1">
{(() => {
const Icon = getNotebookIcon(agent.notebook.icon)
return <Icon className="w-3 h-3" />
})()} {agent.notebook.name}
</span>
)}
<span>·</span>
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
{agent.notebook && (
<>
<span>·</span>
<span className="flex items-center gap-1">
{(() => {
const NbIcon = getNotebookIcon(agent.notebook!.icon)
return <NbIcon className="w-3 h-3" />
})()}
{agent.notebook.name}
</span>
</>
)}
</div>
</div>
{agent.frequency !== 'manual' && agent.nextRun && new Date(agent.nextRun) > new Date() && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-3">
<Clock className="w-3 h-3" />
{t('agents.schedule.nextRun')}{' '}
{mounted
? formatDistanceToNow(new Date(agent.nextRun), { addSuffix: true, locale: dateLocale })
: new Date(agent.nextRun).toISOString().split('T')[0]}
</div>
)}
{lastAction && (
<div className={`
flex items-center gap-1.5 text-xs px-2 py-1 rounded-md mb-3
${lastAction.status === 'success' ? 'bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400' : ''}
${lastAction.status === 'failure' ? 'bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400' : ''}
${lastAction.status === 'running' ? 'bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400' : ''}
`}>
{lastAction.status === 'success' && <CheckCircle2 className="w-3 h-3" />}
{lastAction.status === 'failure' && <XCircle className="w-3 h-3" />}
{lastAction.status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
{t(statusKeys[lastAction.status] || lastAction.status)}
{' - '}
{mounted
? formatDistanceToNow(new Date(lastAction.createdAt), { addSuffix: true, locale: dateLocale })
: new Date(lastAction.createdAt).toISOString().split('T')[0]}
</div>
)}
<div className="flex items-center gap-2">
<button
onClick={() => onEdit(agent.id)}
className="flex-1 px-3 py-1.5 text-xs font-medium text-primary bg-primary/5 rounded-lg hover:bg-primary/10 transition-colors"
>
{t('agents.actions.edit')}
</button>
<button
onClick={handleRun}
disabled={isRunning || !agent.isEnabled}
className="p-1.5 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950 rounded-lg hover:bg-green-100 dark:hover:bg-green-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={t('agents.actions.run')}
>
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-1.5 text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950 rounded-lg hover:bg-red-100 dark:hover:bg-red-900 transition-colors disabled:opacity-50"
title={t('agents.actions.delete')}
>
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button>
{/* Footer: Next Run + Last Status */}
<div className="border-t border-border/30 grid grid-cols-2 divide-x divide-border/30">
<div className="px-4 py-2.5">
<p className="text-[10px] text-muted-foreground font-medium mb-0.5">Prochaine exéc.</p>
<p className="text-xs font-semibold text-foreground flex items-center gap-1">
<Clock className="w-3 h-3 text-muted-foreground/60" />
{nextRunLabel}
</p>
</div>
<div className="px-4 py-2.5">
<p className="text-[10px] text-muted-foreground font-medium mb-0.5">Dernier statut</p>
{lastAction ? (
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
lastAction.status === 'success' ? 'text-emerald-600 dark:text-emerald-400' :
lastAction.status === 'failure' ? 'text-red-600 dark:text-red-400' :
lastAction.status === 'running' ? 'text-primary' :
'text-muted-foreground'
}`}>
{lastAction.status === 'success' && <CheckCircle2 className="w-3 h-3" />}
{lastAction.status === 'failure' && <XCircle className="w-3 h-3" />}
{lastAction.status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
{lastAction.status === 'success' && 'Réussi'}
{lastAction.status === 'failure' && 'Échec'}
{lastAction.status === 'running' && 'En cours'}
{lastAction.status === 'pending' && 'En attente'}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
{/* Actions row */}
<div className="border-t border-border/30 flex items-center px-4 py-2 gap-2">
<button
onClick={() => onEdit(agent.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted-foreground bg-muted/40 hover:bg-muted/80 rounded-md transition-colors"
>
<Pencil className="w-3 h-3" />
{t('agents.actions.edit')}
</button>
<button
onClick={handleRun}
disabled={isRunning || !agent.isEnabled}
className="p-1.5 text-primary bg-primary/10 rounded-md hover:bg-primary/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={t('agents.actions.run')}
>
{isRunning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-1.5 text-red-500 bg-red-50 dark:bg-red-950/20 rounded-md hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors disabled:opacity-40"
title={t('agents.actions.delete')}
>
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
</div>
</div>
)

View File

@@ -207,8 +207,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
]
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="fixed inset-0 bg-black/20 flex justify-end z-50" onClick={onCancel}>
<div
className="bg-card shadow-2xl w-full max-w-md h-full overflow-y-auto animate-in slide-in-from-right duration-300 flex flex-col border-l border-border/40"
onClick={e => e.stopPropagation()}
>
{/* Header — editable agent name */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<input
@@ -550,7 +553,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<div className="flex items-center justify-end gap-3 pt-6 pb-4 mt-auto border-t border-border/40 bg-card sticky bottom-0">
<button
type="button"
onClick={onCancel}
@@ -561,7 +564,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<button
type="submit"
disabled={isSaving}
className="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 shadow-sm"
>
{isSaving ? t('agents.form.saving') : agent ? t('agents.form.save') : t('agents.form.create')}
</button>

View File

@@ -65,8 +65,11 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
}, [agentId])
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-2xl shadow-xl max-w-md w-full max-h-[70vh] overflow-hidden flex flex-col">
<div className="fixed inset-0 bg-black/20 flex justify-end z-50" onClick={onClose}>
<div
className="bg-card shadow-2xl w-full max-w-md h-full overflow-y-auto animate-in slide-in-from-right duration-300 flex flex-col border-l border-border/40"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div>

View File

@@ -102,7 +102,7 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
return (
<div>
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
{t('agents.templates.title')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
@@ -115,15 +115,15 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
return (
<div
key={tpl.id}
className="border-2 border-dashed border-slate-200 rounded-xl p-4 hover:border-primary/30 hover:bg-primary/[0.02] transition-all group"
className="border-2 border-dashed border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-primary/30 hover:bg-primary/[0.02] transition-all group"
>
<div className="flex items-center gap-2.5 mb-2">
<div className={`p-1.5 rounded-lg ${typeColors[tpl.type]}`}>
<Icon className="w-4 h-4" />
</div>
<h4 className="font-medium text-sm text-slate-700">{t(nameKey)}</h4>
<h4 className="font-medium text-sm text-foreground">{t(nameKey)}</h4>
</div>
<p className="text-xs text-slate-400 mb-3 line-clamp-2">{t(descKey)}</p>
<p className="text-xs text-muted-foreground mb-3 line-clamp-2">{t(descKey)}</p>
<button
onClick={() => handleInstall(tpl)}
disabled={isInstalling}

View File

@@ -0,0 +1,408 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import type { UIMessage } from 'ai'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { X, Bot, Sparkles, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee, Loader2, BookOpen, Layers } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
import { useNotebooks } from '@/context/notebooks-context'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
const TONES = [
{ id: 'professional', label: 'Professional', icon: Briefcase },
{ id: 'creative', label: 'Creative', icon: Palette },
{ id: 'academic', label: 'Academic', icon: GraduationCap },
{ id: 'casual', label: 'Casual', icon: Coffee },
]
export function AIChat() {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
const { notebooks } = useNotebooks()
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [activeTab, setActiveTab] = useState('chat')
const [isContextualAIVisible, setIsContextualAIVisible] = useState(false)
const [selectedTone, setSelectedTone] = useState('professional')
const [webSearch, setWebSearch] = useState(false)
const [chatScope, setChatScope] = useState<'all' | string>('all')
const [input, setInput] = useState('')
const [conversationId, setConversationId] = useState<string | undefined>()
const [history, setHistory] = useState<any[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [insights, setInsights] = useState<string>('')
const [insightsLoading, setInsightsLoading] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
const { messages, setMessages, sendMessage, status } = useChat({
transport,
onError: (error) => {
console.error('Chat error:', error)
}
})
const isLoading = status === 'submitted' || status === 'streaming'
const handleSend = async () => {
const text = input.trim()
if (!text || isLoading) return
setInput('')
await sendMessage(
{ text },
{
body: {
tone: selectedTone,
chatScope,
notebookId: chatScope !== 'all' ? chatScope : undefined,
webSearch: webSearch && webSearchAvailable,
conversationId
}
}
)
}
const fetchHistory = async () => {
setHistoryLoading(true)
try {
const res = await fetch('/api/chat/history')
if (res.ok) setHistory(await res.json())
} catch (e) { console.error(e) }
setHistoryLoading(false)
}
const fetchInsights = async () => {
setInsightsLoading(true)
try {
const res = await fetch('/api/chat/insights')
if (res.ok) {
const data = await res.json()
setInsights(data.insight)
}
} catch (e) { console.error(e) }
setInsightsLoading(false)
}
useEffect(() => {
if (activeTab === 'history') fetchHistory()
if (activeTab === 'insights' && !insights) fetchInsights()
}, [activeTab])
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [messages])
useEffect(() => {
const handleToggle = () => setIsOpen(v => !v)
const handleVisibility = (e: any) => setIsContextualAIVisible(e.detail)
window.addEventListener('toggle-ai-chat', handleToggle)
window.addEventListener('contextual-ai-visibility', handleVisibility)
return () => {
window.removeEventListener('toggle-ai-chat', handleToggle)
window.removeEventListener('contextual-ai-visibility', handleVisibility)
}
}, [])
if (!isOpen) {
if (isContextualAIVisible) return null
return (
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105"
size="icon"
title={t('ai.openAssistant')}
>
<Sparkles className="h-5 w-5" />
</Button>
)
}
return (
<aside className={cn(
"fixed bottom-20 right-6 border border-border/40 bg-card flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
)}>
{/* Header */}
<div className="px-5 py-4 border-b border-border/40 flex items-center justify-between">
<div>
<h2 className="font-heading text-lg font-semibold text-foreground tracking-tight">{t('ai.assistantTitle')}</h2>
<p className="text-xs text-muted-foreground font-medium">{t('ai.poweredByMomento')}</p>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
{isExpanded ? (
<>
<polyline points="4 14 10 14 10 20"></polyline>
<polyline points="20 10 14 10 14 4"></polyline>
<line x1="14" y1="10" x2="21" y2="3"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</>
) : (
<>
<polyline points="15 3 21 3 21 9"></polyline>
<polyline points="9 21 3 21 3 15"></polyline>
<line x1="21" y1="3" x2="14" y2="10"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</>
)}
</svg>
</Button>
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted">
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex px-4 pt-4 border-b border-border/40 shrink-0">
<button
onClick={() => setActiveTab('chat')}
className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
activeTab === 'chat' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<Bot className="h-4 w-4" /> {t('ai.chatTab')}
</button>
<button
onClick={() => setActiveTab('insights')}
className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
activeTab === 'insights' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<Sparkles className="h-4 w-4" /> {t('ai.insightsTab')}
</button>
<button
onClick={() => setActiveTab('history')}
className={cn(
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
activeTab === 'history' ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<History className="h-4 w-4" /> {t('ai.historyTab')}
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-5 space-y-6">
{activeTab === 'chat' && (
<>
{/* AI Welcome Message */}
{messages.length === 0 && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<p className="text-sm text-foreground leading-relaxed">
{t('ai.welcomeMsg')}
</p>
</div>
</div>
)}
{/* Messages */}
{messages.map((msg: UIMessage) => (
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
msg.role === 'user'
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
: 'bg-primary/10 text-primary border-primary/20',
)}>
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
</div>
<div className={cn(
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
msg.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground',
)}>
{msg.role === 'assistant'
? <MarkdownContent content={msg.parts?.map(p => 'text' in p ? p.text : '').join('') || ''} />
: <p>{msg.parts?.map(p => 'text' in p ? p.text : '').join('') || ''}</p>}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted/30 border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
{activeTab === 'insights' && (
<div className="h-full">
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> {t('ai.summaryLast5')}</h3>
{insightsLoading ? (
<div className="flex flex-col items-center justify-center py-10 opacity-60">
<Loader2 className="h-8 w-8 animate-spin mb-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{t('ai.analyzingProgress')}</p>
</div>
) : insights ? (
<div className="prose prose-sm dark:prose-invert">
<MarkdownContent content={insights} />
</div>
) : (
<div className="flex justify-center mt-6">
<Button onClick={fetchInsights} variant="outline" size="sm">{t('ai.generateInsightsBtn')}</Button>
</div>
)}
</div>
)}
{activeTab === 'history' && (
<div className="space-y-3">
{historyLoading ? (
<div className="flex justify-center py-10 opacity-60">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : history.length > 0 ? (
history.map(conv => (
<button
key={conv.id}
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-primary/30 transition-all flex flex-col gap-1"
onClick={() => {
setConversationId(conv.id)
setMessages(conv.messages.map((m: any) => ({
id: m.id,
role: m.role,
content: m.content,
parts: [{ type: 'text', text: m.content }]
})))
setActiveTab('chat')
}}
>
<span className="text-sm font-medium line-clamp-1">{conv.title || conv.messages[0]?.content || t('ai.newDiscussion')}</span>
<span className="text-[10px] text-muted-foreground">{new Date(conv.updatedAt).toLocaleString()}</span>
</button>
))
) : (
<div className="flex flex-col items-center justify-center py-10 opacity-60 text-center space-y-2">
<History className="h-8 w-8 text-muted-foreground" />
<p className="text-sm">{t('ai.noRecentConversations')}</p>
</div>
)}
</div>
)}
</div>
{/* Input Area & Tone Controls (Only in Chat tab) */}
<div className={cn("p-4 border-t border-border/40 bg-muted/10 shrink-0", activeTab !== 'chat' && "hidden")}>
{/* Context Scope */}
<div className="mb-3">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.discussionContextLabel')}</span>
<Select value={chatScope} onValueChange={setChatScope}>
<SelectTrigger className="h-8 text-xs bg-card border-border/60">
<div className="flex items-center gap-2">
{chatScope === 'all' ? <Layers className="h-3.5 w-3.5 text-primary" /> : <BookOpen className="h-3.5 w-3.5 text-primary" />}
<SelectValue placeholder={t('ai.selectNotebook')} />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<span>{t('ai.allMyNotes')}</span>
</div>
</SelectItem>
{notebooks && notebooks.length > 0 && notebooks.map(nb => (
<SelectItem key={nb.id} value={nb.id}>
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span>{nb.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Tone Selection */}
<div className="mb-3">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.writingTone')}</span>
<div className="grid grid-cols-4 gap-1">
{TONES.map(tone => {
const Icon = tone.icon
const isSelected = selectedTone === tone.id
return (
<button
key={tone.id}
onClick={() => setSelectedTone(tone.id)}
title={tone.label}
className={cn(
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
isSelected
? "border-primary bg-primary/10 text-primary shadow-sm"
: "border-border/60 bg-card text-muted-foreground hover:bg-muted hover:border-border"
)}
>
<Icon className="h-3 w-3" />
<span className="hidden sm:inline text-[9px]">{tone.label.slice(0, 4)}.</span>
</button>
)
})}
</div>
</div>
{/* Text Input */}
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-all shadow-sm">
<textarea
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
placeholder={t('ai.chatPlaceholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}}
disabled={isLoading}
/>
<div className="flex justify-between items-center px-1 pb-1">
<Button
type="button"
variant="ghost"
size="sm"
className={cn("h-7 px-2 text-[10px] gap-1", webSearch ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-100 hover:text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400" : "text-muted-foreground hover:text-foreground")}
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
disabled={!webSearchAvailable}
title={webSearchAvailable ? t('ai.webSearchLabel') : t('ai.webSearchNotConfigured')}
>
<Globe className="h-3.5 w-3.5" />
Web{webSearchAvailable ? '' : ' ⚠'}
</Button>
<Button
size="icon"
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
onClick={handleSend}
disabled={isLoading || !input.trim()}
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4 ml-0.5" />}
</Button>
</div>
</div>
</div>
</aside>
)
}

View File

@@ -22,6 +22,8 @@ interface AISettingsPanelProps {
aiProvider: 'auto' | 'openai' | 'ollama'
preferredLanguage: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode: boolean
languageDetection: boolean
autoLabeling: boolean
}
}
@@ -118,16 +120,10 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
onChange={(checked) => handleToggle('titleSuggestions', checked)}
/>
<FeatureToggle
name={t('semanticSearch.exactMatch')}
description={t('semanticSearch.searching')}
checked={settings.semanticSearch}
onChange={(checked) => handleToggle('semanticSearch', checked)}
/>
<FeatureToggle
name={t('paragraphRefactor.title')}
description={t('aiSettings.paragraphRefactorDesc')}
name="Assistant IA"
description="Active le bouton de chat IA et les outils d'amélioration du texte"
checked={settings.paragraphRefactor}
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
/>
@@ -167,6 +163,22 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
</Card>
)}
{/* Language Detection Toggle */}
<FeatureToggle
name="Détection de langue"
description="Détecte automatiquement la langue de vos notes"
checked={settings.languageDetection ?? true}
onChange={(checked) => handleToggle('languageDetection', checked)}
/>
{/* Auto Labeling Toggle */}
<FeatureToggle
name="Suggestion des labels"
description="Suggère et applique des étiquettes automatiquement à vos notes"
checked={settings.autoLabeling ?? true}
onChange={(checked) => handleToggle('autoLabeling', checked)}
/>
{/* Demo Mode Toggle */}
<DemoModeToggle
demoMode={settings.demoMode}

View File

@@ -0,0 +1,520 @@
'use client'
import { useRef, useEffect, useState } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import type { UIMessage } from 'ai'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
X, Bot, Sparkles, Send, Loader2,
Briefcase, Palette, GraduationCap, Coffee,
Lightbulb, Minimize2, AlignLeft, Wand2,
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
import { toast } from 'sonner'
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getNotebookIcon } from '@/lib/notebook-icon'
// ── Helpers ──────────────────────────────────────────────────────────────────
function getMessageContent(msg: UIMessage): string {
if (typeof (msg as any).content === 'string') return (msg as any).content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text)
.join('')
}
return ''
}
// ── Constants ─────────────────────────────────────────────────────────────────
const TONES = [
{ id: 'professional', label: 'Pro', full: 'Professional', icon: Briefcase },
{ id: 'creative', label: 'Create', full: 'Creative', icon: Palette },
{ id: 'academic', label: 'Acad.', full: 'Academic', icon: GraduationCap },
{ id: 'casual', label: 'Casual', full: 'Casual', icon: Coffee },
]
interface ActionDef {
id: string
icon: any
apiPath: string
body: (content: string) => object
resultKey: string
i18nKey: string
}
const ACTION_IDS = [
{ id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'clarify' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' },
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
]
// ── Types ─────────────────────────────────────────────────────────────────────
interface ContextualAIChatProps {
onClose: () => void
noteTitle?: string
noteContent?: string
/** Called when an action result should be injected into the note */
onApplyToNote?: (newContent: string) => void
/** Called when the user wants to undo the last injected action */
onUndoLastAction?: () => void
/** Whether the last action has been applied (so we can show undo) */
lastActionApplied?: boolean
/** Notebooks available for scope selection */
notebooks?: Array<{ id: string; name: string }>
/** Extra classes forwarded to the aside root element */
className?: string
}
// ── Component ─────────────────────────────────────────────────────────────────
export function ContextualAIChat({
onClose,
noteTitle,
noteContent,
onApplyToNote,
onUndoLastAction,
lastActionApplied = false,
notebooks = [],
className,
}: ContextualAIChatProps) {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
const [activeTab, setActiveTab] = useState<'chat' | 'actions'>('chat')
const [selectedTone, setSelectedTone] = useState('professional')
const [input, setInput] = useState('')
const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note') // 'note', 'all', or notebook ID
const [webSearch, setWebSearch] = useState(false)
const [expanded, setExpanded] = useState(false)
// Action state
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
const buildChatBody = () => {
const body: Record<string, any> = { language, webSearch }
if (chatScope === 'note') {
body.noteContext = { title: noteTitle || '', content: noteContent || '', tone: selectedTone }
} else if (chatScope !== 'all') {
// scope is a notebook ID
body.notebookId = chatScope
}
return body
}
const { messages, sendMessage, status } = useChat({ transport })
const isLoading = status === 'submitted' || status === 'streaming'
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
return () => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
}
}, [])
// ── Chat send ───────────────────────────────────────────────────────────────
const handleSend = async () => {
const text = input.trim()
if (!text || isLoading) return
setInput('')
await sendMessage({ text }, { body: buildChatBody() })
}
// ── Action execution ────────────────────────────────────────────────────────
const handleAction = async (action: ActionDef) => {
const wc = (noteContent || '').split(/\s+/).filter(Boolean).length
if (!noteContent || wc < 5) {
toast.error(t('ai.minWordsError'))
return
}
setActionLoading(action.id)
setActionPreview(null)
try {
const res = await fetch(action.apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.body(noteContent)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
const result = data[action.resultKey] || ''
setActionPreview({ label: t(action.i18nKey), text: result })
} catch (e: any) {
toast.error(e.message || t('ai.actionError'))
} finally {
setActionLoading(null)
}
}
const handleApplyPreview = () => {
if (!actionPreview || !onApplyToNote) return
onApplyToNote(actionPreview.text)
setActionPreview(null)
toast.success(t('ai.appliedToNote'))
}
const handleDiscardPreview = () => setActionPreview(null)
// ── Scope label ─────────────────────────────────────────────────────────────
const scopeLabel =
chatScope === 'note' ? t('ai.thisNote')
: chatScope === 'all' ? t('ai.allMyNotes')
: notebooks.find(n => n.id === chatScope)?.name ?? t('ai.notebookGeneric')
return (
<aside className={cn(
'border-l border-border/40 bg-card flex flex-col self-stretch flex-shrink-0 z-10 transition-all duration-300',
expanded ? 'w-[560px]' : 'w-[360px]',
className,
)}>
{/* ── Header ───────────────────────────────────────────────── */}
<div className="px-4 py-3 border-b border-border/40 flex items-center justify-between shrink-0">
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0" />
{t('ai.assistantTitle')}
</h2>
<p className="text-xs text-muted-foreground truncate">
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{lastActionApplied && onUndoLastAction && (
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
onClick={onUndoLastAction}
title={t('ai.undoLastAction')}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
{/* Expand / Shrink */}
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(e => !e)}
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
>
{expanded
? <Minimize2 className="h-3.5 w-3.5" />
: <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* ── Tabs ─────────────────────────────────────────────────── */}
<div className="flex border-b border-border/40 shrink-0">
{(['chat', 'actions'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
'flex-1 py-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all capitalize',
activeTab === tab
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{tab === 'chat' && <Bot className="h-3.5 w-3.5" />}
{tab === 'actions' && <Wand2 className="h-3.5 w-3.5" />}
{tab === 'chat' ? t('ai.chatTab') : t('ai.noteActions')}
</button>
))}
</div>
{/* ══════════════════════════════════════════════════════════ */}
{/* ── TAB: CHAT ─────────────────────────────────────────── */}
{/* ══════════════════════════════════════════════════════════ */}
{activeTab === 'chat' && (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Messages */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3 flex flex-col">
{messages.length === 0 && (
<div className="flex-1 flex flex-col items-center justify-center text-center opacity-60">
<div className="w-14 h-14 rounded-full bg-primary/5 flex items-center justify-center border border-primary/10 mb-4">
<Sparkles className="h-6 w-6 text-primary/50" />
</div>
<p className="text-sm text-muted-foreground max-w-[220px]">
{t('ai.askToStart')}
</p>
</div>
)}
{messages.map((msg: UIMessage) => {
const content = getMessageContent(msg)
return (
<div key={msg.id} className={cn('flex gap-2', msg.role === 'user' && 'flex-row-reverse')}>
<div className={cn(
'w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
msg.role === 'user'
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
: 'bg-primary/10 text-primary border-primary/20',
)}>
{msg.role === 'user' ? 'U' : <Bot className="h-3 w-3" />}
</div>
<div className={cn(
'max-w-[88%] p-3 rounded-2xl text-sm leading-relaxed',
msg.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/40 border border-border/40 rounded-tl-sm text-foreground',
)}>
{msg.role === 'assistant'
? <MarkdownContent content={content} />
: <p>{content}</p>}
</div>
</div>
)
})}
{isLoading && (
<div className="flex gap-2">
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<Bot className="h-3 w-3" />
</div>
<div className="bg-muted/40 border border-border/40 p-3 rounded-2xl rounded-tl-sm">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Scope & Tone Control Area */}
<div className="border-t border-border/20 shrink-0 bg-muted/10">
{/* Scope bar */}
<div className="px-3 py-2 border-b border-border/10 flex flex-col gap-1.5">
<label className="text-xs font-bold tracking-wider text-muted-foreground uppercase">
{t('ai.contextLabel')}
</label>
<div className="flex items-center gap-2">
<Select value={chatScope} onValueChange={setChatScope}>
<SelectTrigger className="h-8 flex-1 text-sm bg-card">
<SelectValue placeholder={t('ai.selectContext')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="note" className="text-sm">
<div className="flex items-center gap-2">
<FileText className="h-3 w-3 text-muted-foreground" /> {t('ai.thisNote')}
</div>
</SelectItem>
<SelectItem value="all" className="text-sm">
<div className="flex items-center gap-2">
<Bot className="h-3 w-3 text-muted-foreground" /> {t('ai.allMyNotes')}
</div>
</SelectItem>
{notebooks.map(nb => (
<SelectItem key={nb.id} value={nb.id} className="text-xs">
<div className="flex items-center gap-2">
{(() => {
const Icon = getNotebookIcon((nb as any).icon)
return <Icon className="w-3 h-3 text-muted-foreground" />
})()}
{nb.name.length > 25 ? nb.name.slice(0, 25) + '…' : nb.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Tone selector */}
<div className="px-3 py-2 flex flex-col gap-1.5">
<label className="text-xs font-bold tracking-wider text-muted-foreground uppercase">
{t('ai.writingTone')}
</label>
<div className="grid grid-cols-4 gap-1">
{TONES.map(tone => {
const Icon = tone.icon
const sel = selectedTone === tone.id
return (
<button
key={tone.id}
onClick={() => setSelectedTone(tone.id)}
title={tone.full}
className={cn(
'py-1.5 rounded border text-xs font-medium transition-all flex flex-col items-center gap-1',
sel
? 'border-primary bg-primary/10 text-primary'
: 'border-border/40 text-muted-foreground hover:bg-muted bg-card',
)}
>
<Icon className="h-3 w-3" />
{tone.label}
</button>
)
})}
</div>
</div>
</div>
{/* Input */}
<div className="p-3 border-t border-border/40 shrink-0 bg-card">
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-all">
<textarea
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/60 p-2 min-h-[64px] max-h-[120px]"
placeholder={
chatScope === 'note'
? t('ai.askAboutThisNote')
: t('ai.askAboutYourNotes')
}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}}
disabled={isLoading}
/>
<div className="flex justify-between items-center px-1 pb-1 pt-1">
<div className="flex items-center gap-2">
{webSearchAvailable && (
<button
onClick={() => setWebSearch(w => !w)}
title={t('ai.webSearchLabel')}
className={cn(
'flex items-center justify-center gap-1.5 h-8 px-3 rounded-md border transition-all text-xs font-medium',
webSearch
? 'border-emerald-500 bg-emerald-50 text-emerald-600 dark:bg-emerald-950/30'
: 'border-border/50 text-muted-foreground hover:bg-muted bg-card',
)}
>
<Globe className="h-3 w-3" />
Web
</button>
)}
</div>
<Button
size="icon"
className="h-7 w-7 rounded-lg bg-primary text-primary-foreground shadow-sm disabled:opacity-50"
onClick={handleSend}
disabled={isLoading || !input.trim()}
>
{isLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5 ml-0.5" />}
</Button>
</div>
</div>
<p className="text-[9px] text-muted-foreground/40 text-center mt-1">{t('ai.newLineHint')}</p>
</div>
</div>
)}
{/* ══════════════════════════════════════════════════════════ */}
{/* ── TAB: ACTIONS ─────────────────────────────────────── */}
{/* ══════════════════════════════════════════════════════════ */}
{activeTab === 'actions' && (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Preview panel — result to apply */}
{actionPreview ? (
<div className="flex flex-col flex-1 overflow-hidden">
<div className="px-4 py-2.5 border-b border-border/40 flex items-center justify-between shrink-0">
<p className="text-xs font-semibold text-foreground">{t('ai.resultLabel')} {actionPreview.label}</p>
<button onClick={handleDiscardPreview} className="text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="text-xs leading-relaxed text-foreground bg-muted/30 border border-border/40 rounded-xl p-3 whitespace-pre-wrap">
{actionPreview.text}
</div>
</div>
<div className="p-3 border-t border-border/40 flex gap-2 shrink-0">
<Button
variant="ghost" size="sm"
className="flex-1 text-xs gap-1.5"
onClick={handleDiscardPreview}
>
<X className="h-3.5 w-3.5" /> {t('ai.discardAction')}
</Button>
<Button
size="sm"
className="flex-1 text-xs gap-1.5 bg-primary"
onClick={handleApplyPreview}
disabled={!onApplyToNote}
>
<Check className="h-3.5 w-3.5" /> {t('ai.applyToNote')}
</Button>
</div>
</div>
) : (
<div className="flex-1 overflow-y-auto p-4 space-y-2.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-3">
{t('ai.transformationsDesc')}
</p>
{!noteContent || noteContent.trim().split(/\s+/).filter(Boolean).length < 5 ? (
<div className="flex items-start gap-2 p-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-700 dark:text-amber-400">
{t('ai.writeMinWordsAction')}
</p>
</div>
) : (
ACTION_IDS.map(action => {
const Icon = action.icon
const loading = actionLoading === action.id
return (
<button
key={action.id}
onClick={() => handleAction(action)}
disabled={!!actionLoading}
className="w-full flex items-center gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 text-sm font-medium text-foreground hover:bg-muted hover:border-primary/40 transition-all text-left disabled:opacity-60"
>
{loading
? <Loader2 className="h-4 w-4 text-primary animate-spin shrink-0" />
: <Icon className="h-4 w-4 text-primary shrink-0" />
}
<span>{t(action.i18nKey)}</span>
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
</button>
)
})
)}
{/* Undo last action shortcut */}
{lastActionApplied && onUndoLastAction && (
<button
onClick={onUndoLastAction}
className="w-full mt-2 flex items-center gap-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 px-4 py-2.5 text-sm font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-950/50 transition-all text-left"
>
<RotateCcw className="h-4 w-4 shrink-0" />
{t('ai.undoLastAction')}
</button>
)}
</div>
)}
</div>
)}
</aside>
)
}

View File

@@ -123,7 +123,7 @@ export function EditorConnectionsSection({
console.error('❌ Failed to dismiss connections:', error)
}
}}
title={t('memoryEcho.editorSection.close') || 'Fermer'}
title={t('memoryEcho.editorSection.close') }
>
<X className="h-4 w-4 text-gray-500" />
</Button>

View File

@@ -321,7 +321,7 @@ export function Header({
</div>
<input
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
placeholder={t('search.placeholder') || "Rechercher..."}
placeholder={t('search.placeholder') }
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
@@ -348,18 +348,6 @@ export function Header({
<span>{t('sidebar.notes') || 'Notes'}</span>
</Link>
)}
<Link
href="/chat"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
pathname === '/chat'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="h-3.5 w-3.5" />
<span>{t('nav.chat')}</span>
</Link>
<Link
href="/agents"
className={cn(

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
@@ -140,6 +140,8 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
useReminderCheck(notes)
const prevRefreshKey = useRef(refreshKey)
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
// Les notes initiales suffisent sans filtre
useEffect(() => {
@@ -149,12 +151,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const notebook = searchParams.get('notebook')
const semanticMode = searchParams.get('semantic') === 'true'
const isBackgroundRefresh = refreshKey > prevRefreshKey.current
prevRefreshKey.current = refreshKey
// Pour le refreshKey (mutations), toujours recharger
// Pour les filtres, charger depuis le serveur
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
const load = async () => {
setIsLoading(true)
if (!isBackgroundRefresh) {
setIsLoading(true)
}
let allNotes = search
? await searchNotes(search, semanticMode, notebook || undefined)
: await getAllNotes()
@@ -355,7 +362,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onSizeChange={handleSizeChange}
/>
{notes.filter((note) => !note.isPinned).length > 0 && (
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
<NotesMainSection
viewMode={notesViewMode}
@@ -367,7 +374,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
</div>
)}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && !isTabs && (
<div className="text-center py-8 text-gray-500">
{t('notes.emptyState')}
</div>

View File

@@ -1,8 +1,10 @@
'use client'
import { Skeleton } from "@/components/ui/skeleton"
import { useLanguage } from '@/lib/i18n'
export function LabSkeleton() {
const { t } = useLanguage()
return (
<div className="flex-1 w-full h-full bg-slate-50 dark:bg-[#1a1c22] relative overflow-hidden">
{/* Mesh grid background simulation */}
@@ -31,8 +33,8 @@ export function LabSkeleton() {
<div className="flex flex-col items-center gap-4 bg-white/80 dark:bg-[#252830]/80 p-8 rounded-3xl border shadow-2xl backdrop-blur-xl animate-in fade-in zoom-in duration-500">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<div className="flex flex-col items-center gap-1">
<h3 className="font-bold text-lg">Initialisation de l'espace</h3>
<p className="text-sm text-muted-foreground animate-pulse">Chargement de vos idées...</p>
<h3 className="font-bold text-lg">{t('lab.initializing')}</h3>
<p className="text-sm text-muted-foreground animate-pulse">{t('lab.loadingIdeas')}</p>
</div>
</div>
</div>

View File

@@ -132,13 +132,13 @@ function getInitials(name: string): string {
function getAvatarColor(name: string): string {
const colors = [
'bg-primary',
'bg-purple-500',
'bg-green-500',
'bg-orange-500',
'bg-pink-500',
'bg-teal-500',
'bg-red-500',
'bg-indigo-500',
'bg-purple-600',
'bg-emerald-600',
'bg-amber-600',
'bg-pink-600',
'bg-teal-600',
'bg-blue-600',
'bg-indigo-600',
]
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
@@ -404,14 +404,13 @@ export const NoteCard = memo(function NoteCard({
}}
onDragEnd={() => onDragEnd?.()}
className={cn(
'note-card group relative rounded-2xl overflow-hidden p-5 border shadow-sm',
'note-card group relative rounded-lg overflow-hidden p-6 border-transparent shadow-sm',
'transition-all duration-200 ease-out',
'hover:shadow-xl hover:-translate-y-1',
'hover:shadow-md hover:border-border/50 hover:-translate-y-0.5',
colorClasses.bg,
colorClasses.card,
colorClasses.hover,
colorClasses.hover,
isDragging && 'shadow-2xl' // Removed opacity, scale, and rotation for clean drag
isDragging && 'shadow-lg'
)}
onClick={(e) => {
// Only trigger edit if not clicking on buttons
@@ -474,7 +473,7 @@ export const NoteCard = memo(function NoteCard({
size="sm"
data-testid="pin-button"
className={cn(
"absolute top-2 right-12 z-20 min-h-[44px] min-w-[44px] h-8 w-8 p-0 rounded-md transition-opacity",
"absolute top-2 right-12 z-20 h-8 w-8 p-0 rounded-md transition-opacity",
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => {
@@ -513,7 +512,7 @@ export const NoteCard = memo(function NoteCard({
{/* Title */}
{optimisticNote.title && (
<h3 className="text-base font-medium mb-2 pr-10 text-foreground">
<h3 className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight">
{optimisticNote.title}
</h3>
)}
@@ -587,7 +586,10 @@ export const NoteCard = memo(function NoteCard({
{/* Content */}
{optimisticNote.type === 'text' ? (
<div className="text-sm text-foreground line-clamp-10">
<MarkdownContent content={optimisticNote.content} />
<MarkdownContent
content={optimisticNote.content}
className="prose-h1:text-xl prose-h1:font-semibold prose-h1:leading-snug prose-h1:mt-1 prose-h1:mb-2 prose-h2:text-lg prose-h2:font-medium prose-h3:text-base prose-p:text-sm prose-p:leading-relaxed"
/>
</div>
) : (
<NoteChecklist

View File

@@ -40,12 +40,16 @@ import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import { EditorConnectionsSection } from './editor-connections-section'
import { ComparisonModal } from './comparison-modal'
import { FusionModal } from './fusion-modal'
import { AIAssistantActionBar } from './ai-assistant-action-bar'
import { ContextualAIChat } from './contextual-ai-chat'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useNotebooks } from '@/context/notebooks-context'
import { NoteSize } from '@/lib/types'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
interface NoteEditorProps {
note: Note
@@ -54,6 +58,19 @@ interface NoteEditorProps {
}
export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) {
const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
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, refreshLabels, setNotebookId: setContextNotebookId } = useLabels()
const { triggerRefresh } = useNoteRefresh()
const { t } = useLanguage()
@@ -81,7 +98,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text'
enabled: note.type === 'text' && autoLabelingEnabled
})
// Reminder state
@@ -108,6 +125,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
// AI processing state for ActionBar
const [isProcessingAI, setIsProcessingAI] = useState(false)
const [aiOpen, setAiOpen] = useState(false)
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
// Notebooks (for copilot chat scope)
const { notebooks } = useNotebooks()
// Memory Echo Connections state
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
@@ -564,23 +587,41 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
className={cn(
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-y-auto',
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch',
colorClasses.bg
)}
>
<DialogHeader>
<DialogTitle className="sr-only">{t('notes.edit')}</DialogTitle>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
{readOnly && (
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
{t('notes.readOnly')}
</Badge>
)}
</div>
</DialogHeader>
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto space-y-4 px-6 py-6">
<DialogHeader>
<DialogTitle className="sr-only">{t('notes.edit')}</DialogTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
{/* AI Copilot Toggle Button next to title */}
{note.type === 'text' && !readOnly && aiAssistantEnabled && (
<Button
variant="ghost" size="sm"
className={cn(
'h-8 gap-1.5 px-2 text-xs transition-colors ml-2',
aiOpen && 'bg-primary/10 text-primary'
)}
onClick={() => setAiOpen(!aiOpen)}
title="Toggle AI Copilot"
>
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Assistant IA</span>
</Button>
)}
</div>
{readOnly && (
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
{t('notes.readOnly')}
</Badge>
)}
</div>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-4">
{/* Title */}
<div className="relative">
<Input
@@ -650,47 +691,10 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Content or Checklist */}
{note.type === 'text' ? (
<div className="space-y-2">
{/* Markdown controls */}
<div className="flex items-center justify-between gap-2 pb-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsMarkdown(!isMarkdown)
if (isMarkdown) setShowMarkdownPreview(false)
}}
className={cn("h-7 text-xs", isMarkdown && "text-primary")}
>
<FileText className="h-3 w-3 mr-1" />
{isMarkdown ? t('notes.markdownOn') : t('notes.markdownOff')}
</Button>
{isMarkdown && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
className="h-7 text-xs"
>
{showMarkdownPreview ? (
<>
<FileText className="h-3 w-3 mr-1" />
{t('general.edit')}
</>
) : (
<>
<Eye className="h-3 w-3 mr-1" />
{t('notes.preview')}
</>
)}
</Button>
)}
</div>
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
content={content || t('notes.noContent')}
className="min-h-[200px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
className="min-h-[200px] p-3 rounded-md border border-border/40 bg-muted/20"
/>
) : (
<Textarea
@@ -699,13 +703,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
onChange={(e) => setContent(e.target.value)}
disabled={readOnly}
className={cn(
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none",
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none text-sm leading-relaxed",
readOnly && "cursor-default"
)}
/>
)}
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={labels}
@@ -713,19 +715,6 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
/>
{/* AI Assistant ActionBar */}
{!readOnly && (
<AIAssistantActionBar
onClarify={handleClarifyDirect}
onShorten={handleShortenDirect}
onImprove={handleImproveDirect}
onTransformMarkdown={handleTransformMarkdown}
isMarkdownMode={isMarkdown}
disabled={isProcessingAI || !content}
className="mt-3"
/>
)}
</div>
) : (
<div className="space-y-2">
@@ -742,22 +731,13 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
placeholder={t('notes.listItem')}
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
/>
<Button
variant="ghost"
size="sm"
className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => handleRemoveCheckItem(item.id)}
>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="ghost"
size="sm"
onClick={handleAddCheckItem}
className="text-gray-600 dark:text-gray-400"
>
<Button variant="ghost" size="sm" onClick={handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
@@ -837,111 +817,69 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
)}
{/* Toolbar */}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex items-center gap-2">
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div className="flex items-center gap-0.5">
{!readOnly && (
<>
{/* Reminder Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowReminderDialog(true)}
title={t('notes.setReminder')}
className={currentReminder ? "text-primary" : ""}
>
{/* Reminder */}
<Button variant="ghost" size="icon" className={cn('h-8 w-8', currentReminder && 'text-primary')}
onClick={() => setShowReminderDialog(true)} title={t('notes.setReminder')}>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image Button */}
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
title={t('notes.addImage')}
>
{/* Add Image */}
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowLinkDialog(true)}
title={t('notes.addLink')}
>
{/* Add Link */}
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
{/* AI Assistant Button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
title={t('ai.assistant')}
className="text-purple-600 hover:text-purple-700 dark:text-purple-400"
>
<Wand2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleGenerateTitles} disabled={isGeneratingTitles}>
<Sparkles className="h-4 w-4 mr-2" />
{isGeneratingTitles ? t('ai.generating') : t('ai.generateTitles')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Wand2 className="h-4 w-4 mr-2" />
{t('ai.reformulateText')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => handleReformulate('clarify')}
disabled={isReformulating}
>
<Sparkles className="h-4 w-4 mr-2" />
{isReformulating ? t('ai.reformulating') : t('ai.clarify')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleReformulate('shorten')}
disabled={isReformulating}
>
<Sparkles className="h-4 w-4 mr-2" />
{isReformulating ? t('ai.reformulating') : t('ai.shorten')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleReformulate('improve')}
disabled={isReformulating}
>
<Sparkles className="h-4 w-4 mr-2" />
{isReformulating ? t('ai.reformulating') : t('ai.improveStyle')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
{/* Markdown toggle */}
{note.type === 'text' && (
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false) }}
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
)}
{/* Markdown preview toggle */}
{isMarkdown && (
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
<Eye className="h-4 w-4" />
</Button>
)}
{/* AI Copilot */}
{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="Assistant IA">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Assistant IA</span>
</Button>
)}
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title={t('notes.changeSize')}>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeSize')}>
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="flex flex-col gap-1 p-1">
{['small', 'medium', 'large'].map((s) => (
<Button
key={s}
variant="ghost"
size="sm"
onClick={() => setSize(s as NoteSize)}
className={cn(
"justify-start capitalize",
size === s && "bg-accent"
)}
>
{(['small', 'medium', 'large'] as const).map((s) => (
<Button key={s} variant="ghost" size="sm"
onClick={() => setSize(s)}
className={cn('justify-start capitalize', size === s && 'bg-accent')}>
{s}
</Button>
))}
@@ -952,34 +890,24 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" 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>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => setColor(colorName)}
title={colorName}
/>
<button key={colorName}
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', classes.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
onClick={() => setColor(colorName)} title={colorName} />
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager
existingLabels={labels}
notebookId={note.notebookId}
onUpdate={setLabels}
/>
<LabelManager existingLabels={labels} notebookId={note.notebookId} onUpdate={setLabels} />
</>
)}
{readOnly && (
@@ -1034,14 +962,34 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImageUpload}
/>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImageUpload}
/>
</div>
{/* ── AI Copilot Side Panel ── */}
{aiOpen && (
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
onApplyToNote={(newContent) => {
setPreviousContentForCopilot(content)
setContent(newContent)
}}
onUndoLastAction={previousContentForCopilot !== null ? () => {
setContent(previousContentForCopilot)
setPreviousContentForCopilot(null)
} : undefined}
lastActionApplied={previousContentForCopilot !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
/>
)}
</DialogContent>
<ReminderDialog

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>
)
}

View File

@@ -23,6 +23,9 @@ import {
import { createNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, Note } from '@/lib/types'
import { ContextualAIChat } from './contextual-ai-chat'
import { Maximize2, Minimize2, Sparkles } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
@@ -80,6 +83,20 @@ export function NoteInput({
}: NoteInputProps) {
const { labels: globalLabels, addLabel } = useLabels()
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 { t } = useLanguage()
const searchParams = useSearchParams()
const currentNotebookId = searchParams.get('notebook') || undefined // Get current notebook from URL
@@ -93,10 +110,14 @@ export function NoteInput({
const [isSubmitting, setIsSubmitting] = useState(false)
const [color, setColor] = useState<NoteColor>('default')
const [isArchived, setIsArchived] = useState(false)
const [isExpandedFull, setIsExpandedFull] = useState(false)
const [aiOpen, setAiOpen] = useState(false)
const { notebooks } = useNotebooks()
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
const [collaborators, setCollaborators] = useState<string[]>([])
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Simple state without complex undo/redo
const [title, setTitle] = useState('')
@@ -107,6 +128,14 @@ export function NoteInput({
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
// Auto-resize textarea based on content
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}, [content, isExpandedFull, aiOpen])
// Combine text content and link metadata for AI analysis
const fullContentForAI = [
content,
@@ -116,7 +145,7 @@ export function NoteInput({
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded,
enabled: type === 'text' && isExpanded && autoLabelingEnabled,
notebookId: currentNotebookId
})
@@ -566,163 +595,124 @@ export function NoteInput({
setDismissedTitleSuggestions(false)
}
const widthClass = fullWidth ? 'w-full max-w-none mx-0' : 'max-w-2xl mx-auto'
const collapsedWidthClass = fullWidth ? 'w-full max-w-none mx-0' : 'max-w-2xl mx-auto'
if (!isExpanded) {
return (
<Card className={cn('p-4 mb-8 cursor-text shadow-md hover:shadow-lg transition-shadow', widthClass)}>
<div className="flex items-center gap-4">
<div
className={cn(
'mb-8 overflow-hidden rounded-lg border border-border bg-card shadow-[0_4px_20px_rgba(15,23,42,0.05)] transition-all duration-300 hover:shadow-[0_10px_30px_rgba(15,23,42,0.08)] cursor-text',
collapsedWidthClass
)}
onClick={() => setIsExpanded(true)}
>
<div className="flex items-center gap-2 px-4 py-2">
<Input dir="auto"
placeholder={t('notes.placeholder')}
onClick={() => setIsExpanded(true)}
placeholder={t('notes.placeholder') || "Créer une note..."}
readOnly
value=""
className="border-0 focus-visible:ring-0 cursor-text"
className="border-0 bg-transparent focus-visible:ring-0 cursor-text h-10 text-base shadow-none font-medium text-foreground placeholder:text-muted-foreground/70"
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
setType('checklist')
setIsExpanded(true)
}}
title={t('notes.newChecklist')}
>
<CheckSquare className="h-5 w-5" />
</Button>
<div className="flex shrink-0 items-center gap-1 text-muted-foreground">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setType('checklist')
setIsExpanded(true)
}}
title={t('notes.newChecklist')}
className="h-8 w-8 rounded hover:bg-muted"
>
<CheckSquare className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
</div>
)
}
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
const widthClass = (aiOpen || isExpandedFull)
? 'w-full max-w-6xl mx-auto'
: fullWidth
? 'w-full mx-0'
: 'max-w-2xl mx-auto'
return (
<>
<Card className={cn('p-4 mb-8 shadow-lg border', widthClass, colorClasses.card)}>
<div className="space-y-3">
<Input dir="auto"
<div className={cn(
'mb-8 flex flex-row items-stretch transition-all duration-300',
(aiOpen || isExpandedFull) ? 'max-h-[calc(100vh-180px)]' : '',
widthClass
)}>
{/* ── Note Card ── */}
<div className={cn(
'flex-1 flex flex-col overflow-hidden border border-border bg-card transition-all duration-200 relative min-w-[260px]',
aiOpen ? 'rounded-l-xl rounded-r-none border-r-0' : 'rounded-xl',
'shadow-sm focus-within:shadow-md focus-within:border-primary/40',
colorClasses.card
)}>
{/* Expand / shrink button — fixed top-right, hidden when AI panel is open */}
{!aiOpen && (
<button
type="button"
onClick={() => setIsExpandedFull(!isExpandedFull)}
className="absolute top-3 right-3 z-10 rounded-md p-1.5 text-muted-foreground/40 hover:bg-muted hover:text-foreground transition-colors"
title={isExpandedFull ? 'Réduire' : 'Agrandir'}
>
{isExpandedFull ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
)}
{/* Title row */}
<div className="px-5 pt-5 pb-2 pr-10">
<input
dir="auto"
className="w-full bg-transparent text-lg font-semibold text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={t('notes.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="border-0 focus-visible:ring-0 text-base font-semibold"
/>
</div>
{/* Title Suggestions */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
{/* Title suggestions */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
<div className="px-5">
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={(selectedTitle) => setTitle(selectedTitle)}
onSelect={(s) => setTitle(s)}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
)}
{/* Image Preview */}
{images.length > 0 && (
<div className="flex flex-col gap-2">
{images.map((img, idx) => (
<div key={idx} className="relative group">
<img
src={img}
alt={`Upload ${idx + 1}`}
className="max-w-full h-auto max-h-96 object-contain rounded-lg"
/>
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-white/90 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setImages(images.filter((_, i) => i !== idx))}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{/* Link Previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{links.map((link, idx) => (
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
{link.imageUrl && (
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary truncate hover:underline block mt-1">
{new URL(link.url).hostname}
</a>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Selected Labels Display */}
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{selectedLabels.map(label => (
<LabelBadge
key={label}
label={label}
onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))}
/>
))}
</div>
)}
</div>
)}
{/* Content area — scrolls internally when constrained by max-h */}
<div className="px-5 pb-3 flex-1 min-h-0 overflow-y-auto">
{type === 'text' ? (
<div className="space-y-2">
{/* Markdown toggle button */}
{isMarkdown && (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
className="h-7 text-xs"
>
{showMarkdownPreview ? (
<>
<FileText className="h-3 w-3 mr-1" />
{t('general.edit')}
</>
) : (
<>
<Eye className="h-3 w-3 mr-1" />
{t('general.preview')}
</>
)}
</Button>
</div>
)}
<>
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
content={content || '*No content*'}
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
content={content || '*Aucun contenu*'}
className="min-h-[120px] py-2 text-sm"
/>
) : (
<Textarea dir="auto"
<textarea
ref={textareaRef}
dir="auto"
className={cn(
'w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40 overflow-hidden',
isExpandedFull ? 'min-h-[400px]' : aiOpen ? 'min-h-[200px]' : 'min-h-[120px]'
)}
placeholder={isMarkdown ? t('notes.markdownPlaceholder') : t('notes.placeholder')}
value={content}
onChange={(e) => setContent(e.target.value)}
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
autoFocus
/>
)}
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={selectedLabels}
@@ -730,379 +720,255 @@ export function NoteInput({
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
/>
{/* AI Assistant ActionBar */}
{type === 'text' && (
<AIAssistantActionBar
onClarify={handleClarify}
onShorten={handleShorten}
onImprove={handleImprove}
onTransformMarkdown={handleTransformMarkdown}
isMarkdownMode={isMarkdown}
disabled={isProcessingAI || !content}
className="mt-3"
/>
)}
</div>
</>
) : (
<div className="space-y-2">
<div className="space-y-1.5 py-2">
{checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox className="mt-2" />
<Input dir="auto"
<div key={item.id} className="flex items-center gap-2 group">
<Checkbox className="shrink-0" />
<input
dir="auto"
value={item.text}
onChange={(e) => handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
className="flex-1 border-0 focus-visible:ring-0"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
autoFocus={checkItems[checkItems.length - 1].id === item.id}
/>
<Button
variant="ghost"
size="sm"
className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
<button
type="button"
className="opacity-0 group-hover:opacity-100 transition-opacity rounded p-0.5 hover:bg-muted"
onClick={() => handleRemoveCheckItem(item.id)}
>
<X className="h-4 w-4" />
</Button>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
))}
<Button
variant="ghost"
size="sm"
<button
type="button"
onClick={handleAddCheckItem}
className="text-gray-600 dark:text-gray-400 w-full justify-start"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
>
<X className="h-3.5 w-3.5 rotate-45" />
{t('notes.addListItem')}
</Button>
</button>
</div>
)}
</div>
<div className="flex items-center justify-between pt-2">
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
currentReminder && "text-primary"
)}
title={t('notes.remindMe')}
onClick={handleReminderOpen}
>
<Bell className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('notes.remindMe')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
isMarkdown && "text-primary"
)}
onClick={() => {
setIsMarkdown(!isMarkdown)
if (isMarkdown) setShowMarkdownPreview(false)
}}
title={t('notes.markdown')}
>
<FileText className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('notes.markdown')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={t('notes.addImage')}
onClick={() => fileInputRef.current?.click()}
>
<Image className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('notes.addImage')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={t('notes.addCollaborators')}
onClick={() => setShowCollaboratorDialog(true)}
>
<UserPlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('notes.addCollaborators')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowLinkDialog(true)}
title={t('notes.addLink')}
>
<LinkIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('notes.addLink')}</TooltipContent>
</Tooltip>
<LabelSelector
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
triggerLabel=""
align="start"
/>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.backgroundOptions')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{t('notes.backgroundOptions')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-40">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, colorClass]) => (
<button
key={colorName}
onClick={() => setColor(colorName as NoteColor)}
className={cn(
'w-7 h-7 rounded-full border-2 hover:scale-110 transition-transform',
colorClass.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-transparent'
)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
isArchived && "text-yellow-600"
)}
onClick={() => setIsArchived(!isArchived)}
title={t('notes.archive')}
>
<Archive className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{isArchived ? t('notes.unarchive') : t('notes.archive')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.more')}>
<MoreVertical className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('notes.more')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleUndo}
disabled={historyIndex === 0}
>
<Undo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('notes.undoShortcut')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleRedo}
disabled={historyIndex >= history.length - 1}
>
<Redo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('notes.redoShortcut')}</TooltipContent>
</Tooltip>
{/* Images */}
{images.length > 0 && (
<div className="flex flex-col gap-2 px-5 pb-3">
{images.map((img, idx) => (
<div key={idx} className="relative group">
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain" />
<Button variant="ghost" size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setImages(images.filter((_, i) => i !== idx))}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</TooltipProvider>
))}
</div>
)}
{/* Link previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2 px-5 pb-3">
{links.map((link, idx) => (
<div key={idx} className="relative group flex overflow-hidden rounded-lg border border-border/60 bg-muted/20">
{link.imageUrl && (
<div className="w-20 h-20 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="flex flex-col justify-center gap-0.5 p-3 min-w-0">
<p className="text-sm font-medium truncate">{link.title || link.url}</p>
{link.description && <p className="text-xs text-muted-foreground line-clamp-1">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline truncate">
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
</a>
</div>
<button type="button"
className="absolute top-2 right-2 rounded-full bg-background/80 p-1 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10"
onClick={() => handleRemoveLink(idx)}>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* Selected labels */}
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-5 pb-3">
{selectedLabels.map(label => (
<LabelBadge key={label} label={label} onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))} />
))}
</div>
)}
{/* ── Toolbar ── */}
<div className="flex items-center justify-between border-t border-border/30 px-3 py-2 gap-2">
<TooltipProvider>
<div className="flex items-center gap-0.5 flex-nowrap overflow-hidden flex-1 min-w-0">
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-8 w-8', currentReminder && 'text-primary')} onClick={handleReminderOpen}>
<Bell className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.remindMe')}</TooltipContent></Tooltip>
{type === 'text' && (
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false) }}>
<FileText className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.markdown')}</TooltipContent></Tooltip>
)}
{type === 'text' && isMarkdown && (
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{showMarkdownPreview ? t('general.edit') : t('general.preview')}</TooltipContent></Tooltip>
)}
{type === 'text' && aiAssistantEnabled && (
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors shrink-0', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}>
<Sparkles className="h-3.5 w-3.5" />
<span>Assistant IA</span>
</Button>
</TooltipTrigger><TooltipContent>Ouvrir le copilote IA</TooltipContent></Tooltip>
)}
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => fileInputRef.current?.click()}>
<Image className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.addImage')}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowCollaboratorDialog(true)}>
<UserPlus className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.addCollaborators')}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowLinkDialog(true)}>
<LinkIcon className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.addLink')}</TooltipContent></Tooltip>
<LabelSelector selectedLabels={selectedLabels} onLabelsChange={setSelectedLabels} triggerLabel="" align="start" />
<DropdownMenu>
<Tooltip><TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger><TooltipContent>{t('notes.backgroundOptions')}</TooltipContent></Tooltip>
<DropdownMenuContent align="start" className="w-40">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, colorClass]) => (
<button key={colorName} onClick={() => setColor(colorName as NoteColor)}
className={cn('w-7 h-7 rounded-full border-2 hover:scale-110 transition-transform', colorClass.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-transparent')}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-8 w-8', isArchived && 'text-amber-500')} onClick={() => setIsArchived(!isArchived)}>
<Archive className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{isArchived ? t('notes.unarchive') : t('notes.archive')}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleUndo} disabled={historyIndex === 0}>
<Undo2 className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.undoShortcut')}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleRedo} disabled={historyIndex >= history.length - 1}>
<Redo2 className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.redoShortcut')}</TooltipContent></Tooltip>
<div className="flex gap-2">
<Button
onClick={handleSubmit}
disabled={isSubmitting}
size="sm"
>
{isSubmitting ? t('notes.adding') : t('notes.add')}
</Button>
<Button
variant="ghost"
onClick={handleClose}
size="sm"
>
{t('general.close')}
</Button>
</div>
</TooltipProvider>
<div className="flex items-center gap-2 shrink-0">
<Button variant="ghost" size="sm" onClick={handleClose} className="text-muted-foreground hover:text-foreground px-3 whitespace-nowrap">
{t('general.close')}
</Button>
<Button size="sm" onClick={handleSubmit} disabled={isSubmitting} className="px-5 font-medium whitespace-nowrap">
{isSubmitting ? t('notes.adding') : t('notes.add')}
</Button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImageUpload}
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
</div>
{/* ── AI Panel — direct child of flex-row so self-stretch works ── */}
{aiOpen && (
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
onApplyToNote={(newContent) => setContent(newContent)}
lastActionApplied={false}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
className="border border-border border-l-0 rounded-r-xl overflow-hidden shadow-sm"
/>
</Card>
)}
{/* Dialogs */}
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>{t('notes.setReminder')}</DialogTitle>
</DialogHeader>
<DialogContent>
<DialogHeader><DialogTitle>{t('notes.setReminder')}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium">
{t('notes.date')}
</label>
<Input dir="auto"
id="reminder-date"
type="date"
value={reminderDate}
onChange={(e) => setReminderDate(e.target.value)}
className="w-full"
/>
<label htmlFor="reminder-date" className="text-sm font-medium">{t('notes.date')}</label>
<input id="reminder-date" type="date" value={reminderDate} onChange={(e) => setReminderDate(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</div>
<div className="space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium">
{t('notes.time')}
</label>
<Input dir="auto"
id="reminder-time"
type="time"
value={reminderTime}
onChange={(e) => setReminderTime(e.target.value)}
className="w-full"
/>
<label htmlFor="reminder-time" className="text-sm font-medium">{t('notes.time')}</label>
<input id="reminder-time" type="time" value={reminderTime} onChange={(e) => setReminderTime(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
{t('general.cancel')}
</Button>
<Button onClick={handleReminderSave}>
{t('notes.setReminderButton')}
</Button>
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>{t('general.cancel')}</Button>
<Button onClick={handleReminderSave}>{t('notes.setReminderButton')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>{t('notes.addLink')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input dir="auto"
placeholder="https://example.com"
value={linkUrl}
<DialogContent>
<DialogHeader><DialogTitle>{t('notes.addLink')}</DialogTitle></DialogHeader>
<div className="py-4">
<input type="url" placeholder="https://example.com" value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLink()
}
}}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddLink() } }}
autoFocus
/>
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-primary" />
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
{t('general.cancel')}
</Button>
<Button onClick={handleAddLink}>
{t('general.add')}
</Button>
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>{t('general.cancel')}</Button>
<Button onClick={handleAddLink}>{t('general.add')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1116,6 +982,6 @@ export function NoteInput({
onCollaboratorsChange={setCollaborators}
initialCollaborators={collaborators}
/>
</>
</div>
)
}
}

View File

@@ -153,7 +153,7 @@ export function NotebooksList() {
style={notebook.color ? { color: notebook.color } : undefined}
/>
<span
className={cn("text-sm font-medium tracking-wide truncate min-w-0", !notebook.color && "text-primary dark:text-primary-foreground")}
className={cn("text-[15px] font-medium tracking-wide truncate min-w-0", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
>
{notebook.name}
@@ -241,7 +241,7 @@ export function NotebooksList() {
)}
>
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
<span className="text-[15px] font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
{(notebook as any).notesCount > 0 && (
<span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
)}

View File

@@ -158,12 +158,12 @@ function SortableNoteListItem({
ref={setNodeRef}
style={style}
className={cn(
'group relative flex cursor-pointer select-none items-stretch gap-0 rounded-xl transition-all duration-150',
'border',
'group relative flex cursor-pointer select-none items-stretch gap-0 transition-all duration-150',
'border-b border-border/40 last:border-b-0',
selected
? 'border-primary/20 bg-primary/5 dark:bg-primary/10 shadow-sm'
: 'border-transparent hover:border-border/60 hover:bg-muted/50',
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30'
? 'bg-primary/5 dark:bg-primary/10 shadow-sm'
: 'hover:bg-muted/50',
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30 rounded-lg'
)}
onClick={onSelect}
role="option"
@@ -172,7 +172,7 @@ function SortableNoteListItem({
{/* Color accent bar */}
<div
className={cn(
'w-1 shrink-0 rounded-s-xl transition-all duration-200',
'w-1 shrink-0 transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
)}
/>
@@ -213,7 +213,7 @@ function SortableNoteListItem({
<div className="flex items-center gap-2">
<p
className={cn(
'truncate text-sm font-medium transition-colors',
'truncate text-base font-heading font-medium transition-colors',
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
)}
>
@@ -290,13 +290,28 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
...fresh,
title: p.title,
content: p.content,
checkItems: p.checkItems,
isMarkdown: p.isMarkdown,
// Always use server labels if different (for global label changes)
labels: labelsChanged ? fresh.labels : p.labels
}
})
}
// Different set (add/remove): full sync
return notes
// Different set (add/remove) or reordered from server: full sync
// CRITICAL: We MUST preserve local text edits so inline editor state isn't lost
return notes.map((fresh) => {
const local = prev.find((p) => p.id === fresh.id)
if (!local) return fresh
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(local.labels?.sort())
return {
...fresh,
title: local.title,
content: local.content,
checkItems: local.checkItems,
isMarkdown: local.isMarkdown,
labels: labelsChanged ? fresh.labels : local.labels
}
})
})
}, [notes])
@@ -350,7 +365,11 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
skipRevalidation: true
})
if (!newNote) return
setItems((prev) => [newNote, ...prev])
setItems((prev) => {
const pinned = prev.filter(n => n.isPinned)
const unpinned = prev.filter(n => !n.isPinned)
return [...pinned, newNote, ...unpinned]
})
setSelectedId(newNote.id)
triggerRefresh()
} catch {
@@ -359,16 +378,7 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
})
}
if (items.length === 0) {
return (
<div
className="flex min-h-[240px] flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-muted/20 px-6 py-12 text-center"
data-testid="notes-grid-tabs-empty"
>
<p className="max-w-md text-sm text-muted-foreground">{t('notes.emptyStateTabs')}</p>
</div>
)
}
return (
<div
@@ -408,33 +418,42 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
role="listbox"
aria-label={t('notes.viewTabs')}
>
<DndContext
id="notes-tabs-dnd"
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{items.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
selected={note.id === selectedId}
onSelect={() => setSelectedId(note.id)}
onDelete={() => setNoteToDelete(note)}
reorderLabel={t('notes.reorderTabs')}
deleteLabel={t('notes.delete')}
language={language}
untitledLabel={t('notes.untitled')}
/>
))}
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="mb-3 rounded-full bg-background p-3 shadow-sm border border-border/50">
<FileText className="h-5 w-5 text-muted-foreground/40" />
</div>
</SortableContext>
</DndContext>
<p className="text-sm font-medium text-muted-foreground">{t('notes.emptyStateTabs') || 'Aucune note'}</p>
</div>
) : (
<DndContext
id="notes-tabs-dnd"
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{items.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
selected={note.id === selectedId}
onSelect={() => setSelectedId(note.id)}
onDelete={() => setNoteToDelete(note)}
reorderLabel={t('notes.reorderTabs')}
deleteLabel={t('notes.delete')}
language={language}
untitledLabel={t('notes.untitled')}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
</div>
@@ -467,8 +486,18 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
/>
</div>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground/40">
<p className="text-sm">{t('notes.selectNote') || 'Sélectionnez une note'}</p>
<div className="flex min-w-0 flex-1 items-center justify-center bg-muted/10 border-l border-border/40">
<div className="text-center px-6">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-background shadow-sm border border-border/50">
<FileText className="h-8 w-8 text-muted-foreground/30" />
</div>
<h3 className="text-lg font-heading font-medium text-foreground">{items.length === 0 ? 'Carnet vide' : 'Aucune note sélectionnée'}</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
{items.length === 0
? "Ce carnet ne contient aucune note. Cliquez sur le bouton + pour en créer une."
: "Sélectionnez une note dans la liste à gauche ou créez-en une nouvelle."}
</p>
</div>
</div>
)}
@@ -495,7 +524,7 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
onClick={async () => {
if (!noteToDelete) return
try {
await deleteNote(noteToDelete.id)
await deleteNote(noteToDelete.id, { skipRevalidation: true })
setItems((prev) => prev.filter((n) => n.id !== noteToDelete.id))
setSelectedId((prev) => (prev === noteToDelete.id ? null : prev))
setNoteToDelete(null)

View File

@@ -101,7 +101,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
href={href}
className={cn(
"flex items-center gap-4 px-6 py-3 rounded-e-full me-2 transition-colors",
"text-sm font-medium tracking-wide",
"text-[15px] font-medium tracking-wide",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
: "text-muted-foreground hover:bg-muted/50 dark:hover:bg-muted/30"
@@ -135,34 +135,9 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
label={t('sidebar.notes') || 'Notes'}
active={isActive('/')}
/>
<NavItem
href="/reminders"
icon={Bell}
label={t('sidebar.reminders') || 'Rappels'}
active={isActive('/reminders')}
/>
{pathname === '/' && homeBridge?.controls?.isTabsMode && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
className="w-full justify-start gap-3 rounded-e-full ps-4 pe-3 font-medium shadow-sm"
onClick={() => homeBridge.controls?.openNoteComposer()}
>
<Plus className="h-5 w-5 shrink-0" />
<span className="truncate">{t('sidebar.newNoteTabs')}</span>
<Sparkles className="ms-auto h-4 w-4 shrink-0 text-primary" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[240px]">
{t('sidebar.newNoteTabsHint')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{/* Notebooks Section */}
<div className="flex flex-col mt-2">
<NotebooksList />
@@ -206,7 +181,13 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
)}
{/* Archive & Trash */}
<div className="flex flex-col mt-2 border-t border-transparent">
<div className="flex flex-col mt-auto pb-4 border-t border-transparent">
<NavItem
href="/reminders"
icon={Bell}
label={t('sidebar.reminders') || 'Rappels'}
active={isActive('/reminders')}
/>
<NavItem
href="/archive"
icon={Archive}