fix: comprehensive i18n — replace hardcoded French/English strings with t() calls
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
Replaced ~100+ hardcoded French and English text strings across 30+ components with proper i18n t() calls. Added 57 new translation keys to all 15 locale files (ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh). Key changes: - contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders) - ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history) - note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist) - lab-skeleton.tsx: French loading text → t() - admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed - New AI chat component, agent cards, sidebar, settings panel i18n cleanup Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user