Files
office_translator/frontend/src/app/dashboard/translate/GlossarySelector.tsx
sepehr 818eac5490
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m5s
feat: prevent duplicate glossary presets + fix i18n source warning bug
- Add template_id column to Glossary model (nullable, indexed)
- Backend: return 409 Conflict if user already imported a template
- Frontend: disable preset cards already imported, show 'Imported' badge
- Fix duplicated text in GlossarySelector source warning (hardcoded FR text removed)
- Complete i18n migration for glossaries page and GlossarySelector
- Add glossaries.presets.alreadyImported key in all 13 locales
2026-06-01 23:39:53 +02:00

619 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { BookOpen, Plus, Loader2, Check, ChevronDown, X, Globe, ChevronRight } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch';
import { SUPPORTED_LANGUAGES } from '../glossaries/types';
import { languages as API_LANGUAGES } from '@/lib/api';
interface GlossaryOption {
id: string;
name: string;
source_language: string;
target_language: string;
terms_count: number;
}
interface TemplateOption {
id: string;
name: string;
description: string;
source_lang: string;
target_lang: string;
terms_count: number;
}
interface GlossarySelectorProps {
sourceLang: string;
targetLang: string;
isPro: boolean;
mode: string;
glossaryId: string | null;
onChange: (id: string | null) => void;
disabled?: boolean;
}
export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossaryId, onChange, disabled }: GlossarySelectorProps) {
const { t } = useI18n();
const [glossaries, setGlossaries] = useState<GlossaryOption[]>([]);
const [templates, setTemplates] = useState<TemplateOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isGlossaryEnabled, setIsGlossaryEnabled] = useState(!!glossaryId);
const [selectedGlossaryDetail, setSelectedGlossaryDetail] = useState<any>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const [importingId, setImportingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [filterByLang, setFilterByLang] = useState(sourceLang !== 'auto');
useEffect(() => {
setFilterByLang(sourceLang !== 'auto');
}, [sourceLang]);
// Form states for adding term
const [newSource, setNewSource] = useState('');
const [newTarget, setNewTarget] = useState('');
const [isAddingTerm, setIsAddingTerm] = useState(false);
const fetchData = useCallback(async () => {
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const [glossaryRes, templateRes] = await Promise.all([
fetch(`${API_BASE}/api/v1/glossaries?per_page=100`, { headers }),
fetch(`${API_BASE}/api/v1/glossaries/templates/list`, { headers }),
]);
if (glossaryRes.ok) {
const data = await glossaryRes.json();
setGlossaries(data.data || []);
}
if (templateRes.ok) {
const data = await templateRes.json();
setTemplates(data.data || []);
}
} catch {
// ignore
} finally {
setIsLoading(false);
}
}, []);
const fetchGlossaryDetail = useCallback(async (id: string) => {
setIsLoadingDetail(true);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/v1/glossaries/${id}`, { headers });
if (res.ok) {
const data = await res.json();
setSelectedGlossaryDetail(data.data || null);
}
} catch {
// ignore
} finally {
setIsLoadingDetail(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
// Synchronize glossary detail and enablement state with props
useEffect(() => {
if (glossaryId) {
setIsGlossaryEnabled(true);
fetchGlossaryDetail(glossaryId);
} else {
setSelectedGlossaryDetail(null);
}
}, [glossaryId, fetchGlossaryDetail]);
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen]);
const handleImportTemplate = async (template: TemplateOption) => {
const existing = glossaries.find(
g => g.name.toLowerCase().includes(template.name.toLowerCase().split('/')[0].trim())
);
if (existing) {
onChange(existing.id);
setIsGlossaryEnabled(true);
setIsOpen(false);
return;
}
const token = localStorage.getItem('token');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
setImportingId(template.id);
setError(null);
try {
const res = await fetch(`${API_BASE}/api/v1/glossaries/import?template_id=${encodeURIComponent(template.id)}`, {
method: 'POST',
headers,
});
if (res.ok) {
const data = await res.json();
const newId = data.data?.id;
await fetchData();
if (newId) {
onChange(newId);
setIsGlossaryEnabled(true);
}
setIsOpen(false);
} else {
const errData = await res.json().catch(() => null);
setError(errData?.message || t('translate.glossary.importFailed').replace('{status}', String(res.status)));
}
} catch {
setError(t('translate.glossary.networkError'));
} finally {
setImportingId(null);
}
};
const handleAddTerm = async (e: React.FormEvent) => {
e.preventDefault();
if (!glossaryId || !newSource.trim() || !newTarget.trim()) return;
setIsAddingTerm(true);
setError(null);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) headers['Authorization'] = `Bearer ${token}`;
// Fetch latest terms
const detailRes = await fetch(`${API_BASE}/api/v1/glossaries/${glossaryId}`, { headers });
let currentTerms = [];
if (detailRes.ok) {
const detailData = await detailRes.json();
currentTerms = detailData.data?.terms || [];
}
const mappedTerms = currentTerms.map((t: any) => ({
source: t.source,
target: t.target,
translations: t.translations || {}
}));
// Append
const updatedTerms = [...mappedTerms, { source: newSource.trim(), target: newTarget.trim(), translations: {} }];
const res = await fetch(`${API_BASE}/api/v1/glossaries/${glossaryId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({
terms: updatedTerms
})
});
if (res.ok) {
setNewSource('');
setNewTarget('');
// Refresh details
fetchGlossaryDetail(glossaryId);
fetchData();
} else {
const errData = await res.json().catch(() => null);
setError(errData?.message || t('translate.glossary.addTermError'));
}
} catch {
setError(t('translate.glossary.networkError'));
} finally {
setIsAddingTerm(false);
}
};
const getFlag = useCallback((code: string) => {
return API_LANGUAGES.find(l => l.code === code)?.flag ??
SUPPORTED_LANGUAGES.find(l => l.code === code)?.flag ??
code.toUpperCase();
}, []);
const sourceFlag = useMemo(() => sourceLang === 'auto' ? '' : getFlag(sourceLang), [sourceLang, getFlag]);
const targetFlag = useMemo(() => getFlag(targetLang), [targetLang, getFlag]);
// A glossary is compatible with the target language if:
// - its target_language exactly matches, OR
// - it's a multilingual glossary (target_language === 'multi')
const isCompatible = useCallback((gTargetLang: string) => {
return gTargetLang === targetLang || gTargetLang === 'multi';
}, [targetLang]);
const filteredGlossaries = useMemo(() => {
if (!filterByLang || sourceLang === 'auto') {
return glossaries;
}
return glossaries
.filter(g => g.source_language === sourceLang)
.sort((a, b) => {
// Compatible glossaries first, then incompatible
const aOk = isCompatible(a.target_language) ? 0 : 1;
const bOk = isCompatible(b.target_language) ? 0 : 1;
return aOk - bOk;
});
}, [glossaries, filterByLang, sourceLang, isCompatible]);
const filteredTemplates = useMemo(() => {
if (!filterByLang || sourceLang === 'auto') {
return templates;
}
return templates.filter(t => t.source_lang === sourceLang);
}, [templates, filterByLang, sourceLang]);
const selected = glossaries.find(g => g.id === glossaryId);
return (
<div
className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3 text-left"
ref={containerRef}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<BookOpen size={14} className="text-brand-accent shrink-0" />
<span className="text-xs font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white truncate">
{t('translate.glossary.title') || 'Glossaire & Terminologie'}
</span>
<span className="text-[10px] text-brand-dark/40 dark:text-white/40 shrink-0 font-medium font-mono">
({sourceFlag || 'AUTO'}{targetFlag})
</span>
</div>
{/* Active switch slider */}
{isPro && mode === 'llm' && (
<button
type="button"
disabled={disabled}
onClick={() => {
const nextVal = !isGlossaryEnabled;
setIsGlossaryEnabled(nextVal);
if (!nextVal) {
onChange(null);
} else if (!glossaryId) {
// Auto-select first compatible glossary (exact target match or multilingual)
const matching = filteredGlossaries.find(g => isCompatible(g.target_language));
if (matching) {
onChange(matching.id);
}
}
}}
className={cn(
"w-8 h-4 rounded-full relative transition-colors",
isGlossaryEnabled ? 'bg-brand-accent' : 'bg-brand-dark/10 dark:bg-white/10',
disabled && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"w-3.5 h-3.5 bg-white rounded-full absolute top-0.5 shadow transition-all",
isGlossaryEnabled ? 'left-[13px]' : 'left-0.5'
)} />
</button>
)}
</div>
{mode === 'classic' ? (
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center">
<span className="text-xs font-black uppercase opacity-45 block">
{t('translate.glossary.classicMode') || 'Moteur neutre sans glossaire (IA uniquement)'}
</span>
</div>
) : !isPro ? (
<div className="p-2.5 rounded-lg bg-brand-dark/5 dark:bg-white/5 text-center">
<p className="text-xs text-brand-dark/50 dark:text-white/45 leading-relaxed font-light">
{t('translate.glossary.proOnly') || 'Passez Pro pour appliquer vos glossaires terminologiques.'}
</p>
</div>
) : isGlossaryEnabled ? (
<div className="space-y-3 animate-fade-in">
{/* Help Info text */}
<p className="text-[10.5px] text-brand-dark/60 dark:text-white/40 leading-normal font-light">
{t('translate.glossary.helpText') || 'Le glossaire force la traduction de termes précis. Choisissez un glossaire dont la langue source correspond à la langue d\'origine de votre document.'}
</p>
{/* Mismatch Warning — source language */}
{selected && sourceLang !== 'auto' && selected.source_language !== sourceLang && (
<div className="flex items-start gap-1.5 p-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 text-[10px] leading-normal font-medium animate-fade-in">
<span className="shrink-0 text-amber-500"></span>
<span>
<strong>{t('translate.glossary.sourceWarning')}</strong> <strong>{getFlag(selected.source_language)} {selected.source_language.toUpperCase()}</strong>, {t('translate.glossary.sourceWarningBut')} <strong>{getFlag(sourceLang)} {sourceLang.toUpperCase()}</strong>.
</span>
</div>
)}
{/* Mismatch Warning — target language (skip for multilingual glossaries) */}
{selected && selected.target_language !== 'multi' && selected.target_language && selected.target_language !== targetLang && (
<div className="flex items-start gap-1.5 p-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 text-[10px] leading-normal font-medium animate-fade-in">
<span className="shrink-0">🎯</span>
<span>
<strong>{t('translate.glossary.targetWarning') || 'Incompatibilité de cible :'}</strong> <strong>{getFlag(selected.target_language)} {selected.target_language.toUpperCase()}</strong>, {t('translate.glossary.targetWarningBut') || 'mais votre document cible'} <strong>{targetFlag} {targetLang.toUpperCase()}</strong>. {t('translate.glossary.targetWarningEnd') || 'Les termes risquent de ne pas être pertinents.'}
</span>
</div>
)}
{/* Select Glossary Trigger button */}
<div className="relative">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full bg-white dark:bg-[#1a1a1a] border border-black/5 dark:border-white/5 hover:border-black/10 dark:hover:border-white/10 py-2.5 px-3 rounded-lg flex items-center justify-between shadow-sm transition-all cursor-pointer",
isOpen && "border-brand-accent dark:border-brand-accent",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<div className="text-left min-w-0 flex-1 pr-2">
<span className="text-xs font-bold text-brand-dark dark:text-white tracking-tight block truncate">
{selected ? selected.name : (isLoading ? t('translate.glossary.loading') || 'Chargement...' : t('translate.glossary.selectPlaceholder') || 'Sélectionner un glossaire...')}
</span>
<span className="text-[10px] font-extrabold uppercase tracking-wider text-brand-dark/40 dark:text-white/40 block mt-0.5">
{selected
? `${getFlag(selected.source_language)}${selected.target_language === 'multi' ? `🌐 ${t('translate.glossary.multilingual') || 'MULTILINGUE'}` : getFlag(selected.target_language || targetLang)}${selected.terms_count} ${t('translate.glossary.terms') || 'termes'}`
: (filteredGlossaries.length > 0 || filteredTemplates.length > 0 ? t('translate.glossary.select') || 'Sélectionnez un glossaire' : t('translate.glossary.noGlossaryAvailable') || 'Aucun glossaire disponible')
}
</span>
</div>
<ChevronDown size={14} className={cn("text-brand-dark/30 dark:text-white/30 shrink-0 transition-transform duration-200", isOpen && "rotate-180")} />
</button>
{/* Error message */}
{error && (
<p className="text-xs text-red-500 pl-1 mt-1 font-medium">{error}</p>
)}
{/* Selector Dropdown list */}
{isOpen && !disabled && (
<div className="absolute top-[102%] left-0 right-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-1.5 z-40 max-h-64 overflow-y-auto animate-fade-in animate-in fade-in zoom-in-95 duration-100">
{/* Filter toggle header */}
{sourceLang !== 'auto' && (glossaries.length > 0 || templates.length > 0) && (
<div className="px-2 py-1.5 border-b border-black/[0.03] dark:border-white/[0.03] flex justify-between items-center mb-1">
<span className="text-[10px] font-black text-brand-dark/40 dark:text-white/40 uppercase tracking-widest">
{t('translate.glossary.filterByLang') || 'Filtrer par langue'} ({sourceFlag})
</span>
<button
type="button"
onClick={() => setFilterByLang(!filterByLang)}
className={cn(
"text-[10px] font-bold px-2 py-0.5 rounded transition-colors cursor-pointer",
filterByLang ? "bg-brand-accent/10 text-brand-accent" : "bg-brand-dark/10 dark:bg-white/10 text-brand-dark/50 dark:text-white/50"
)}
>
{filterByLang ? t('translate.glossary.active') || 'Actif' : t('translate.glossary.inactive') || 'Inactif'}
</button>
</div>
)}
{/* Custom Glossaries Section */}
{filteredGlossaries.length > 0 && (
<div className="mb-2">
<div className="px-2 py-1 text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest">
{t('translate.glossary.myGlossaries') || 'Mes Glossaires'}
</div>
{filteredGlossaries.map(g => {
const flag = getFlag(g.source_language);
const isSelected = g.id === glossaryId;
return (
<button
key={g.id}
type="button"
onClick={() => {
onChange(isSelected ? null : g.id);
setIsOpen(false);
}}
className={cn(
"w-full text-left px-2.5 py-2 rounded-lg transition-colors flex items-center justify-between cursor-pointer",
isSelected
? 'bg-brand-accent/5 text-brand-accent'
: 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70'
)}
>
<div className="text-left min-w-0 flex-1 pr-2">
<span className="text-xs font-bold block leading-none truncate">{g.name}</span>
<span className="text-[10px] uppercase tracking-wider text-brand-dark/40 dark:text-white/45 font-bold block mt-1">
{flag} {g.target_language === 'multi' ? `🌐 ${t('translate.glossary.multilingual') || 'MULTILINGUE'}` : getFlag(g.target_language || targetLang)} {g.terms_count} {t('translate.glossary.terms') || 'termes'}
</span>
</div>
{isSelected && <Check size={12} className="text-brand-accent shrink-0" />}
</button>
);
})}
</div>
)}
{/* Templates Section */}
{filteredTemplates.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest border-t border-black/[0.03] dark:border-white/[0.03] pt-2 mt-1">
{t('translate.glossary.availableTemplates') || 'Modèles disponibles'}
</div>
<div className="grid grid-cols-1 gap-0.5">
{filteredTemplates.map(tmpl => {
const isImporting = importingId === tmpl.id;
const existingGlossary = glossaries.find(
g => g.name.toLowerCase().includes(tmpl.name.toLowerCase().split('/')[0].trim())
);
const isAlreadySelected = existingGlossary?.id === glossaryId;
const flag = getFlag(tmpl.source_lang);
const tFlag = tmpl.target_lang === 'multi' ? `🌐 ${t('translate.glossary.multilingual') || 'MULTILINGUE'}` : getFlag(tmpl.target_lang);
return (
<button
key={tmpl.id}
type="button"
disabled={isImporting || disabled}
onClick={() => {
if (existingGlossary) {
onChange(isAlreadySelected ? null : existingGlossary.id);
setIsOpen(false);
} else {
handleImportTemplate(tmpl);
}
}}
className={cn(
"w-full text-left px-2.5 py-2 rounded-lg transition-colors flex items-center justify-between cursor-pointer",
isAlreadySelected
? 'bg-brand-accent/5 text-brand-accent'
: 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70'
)}
>
<div className="text-left min-w-0 flex-1 pr-2">
<span className="text-xs font-bold block leading-none truncate">
{isImporting ? t('translate.glossary.importing') || 'Importation...' : tmpl.name.split('/')[0].trim()}
</span>
<span className="text-[10px] uppercase tracking-wider text-brand-dark/40 dark:text-white/45 font-bold block mt-1">
{flag} {tFlag} {tmpl.terms_count} {t('translate.glossary.terms') || 'termes'} {existingGlossary ? t('translate.glossary.imported') || '(Importé)' : ''}
</span>
</div>
{isImporting ? (
<Loader2 size={12} className="animate-spin text-brand-accent shrink-0" />
) : isAlreadySelected ? (
<Check size={12} className="text-brand-accent shrink-0" />
) : null}
</button>
);
})}
</div>
</div>
)}
{/* Empty State */}
{filteredGlossaries.length === 0 && filteredTemplates.length === 0 && (
<div className="px-2.5 py-4 text-center">
<p className="text-xs text-brand-dark/45 dark:text-white/45 italic mb-3 font-light">
{t('translate.glossary.noGlossaryForSource') || 'Aucun glossaire ni modèle pour la langue source'} {sourceFlag || sourceLang.toUpperCase()}.
</p>
<div className="flex flex-col gap-1.5">
<a
href={`/dashboard/glossaries?new=true&source=${sourceLang}`}
className="w-full py-2 px-2 bg-brand-dark dark:bg-white text-white dark:text-brand-dark hover:opacity-90 rounded-lg text-xs font-bold uppercase tracking-wider block text-center transition-opacity cursor-pointer"
>
{t('translate.glossary.createGlossary') || 'Créer un glossaire'} {sourceLang === 'auto' ? '' : sourceLang.toUpperCase()}
</a>
{filterByLang && (glossaries.length > 0 || templates.length > 0) && (
<button
type="button"
onClick={() => setFilterByLang(false)}
className="w-full py-2 px-2 bg-brand-muted dark:bg-white/5 hover:bg-brand-muted/70 text-brand-dark dark:text-white rounded-lg text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer"
>
{t('translate.glossary.showAll') || 'Afficher tous les glossaires'}
</button>
)}
</div>
</div>
)}
</div>
)}
</div>
{/* Dynamic Terms Preview block */}
{selected && (
<div className="bg-white/80 dark:bg-[#1a1a1a]/40 rounded-lg p-2.5 border border-black/[0.02] dark:border-white/[0.02] space-y-2">
<div className="flex justify-between items-center border-b border-black/[0.02] dark:border-white/[0.02] pb-1">
<span className="text-xs font-bold uppercase tracking-wider text-brand-dark/30 dark:text-white/30 block">
{t('translate.glossary.activePreview') || 'Aperçu des correspondances actives :'}
</span>
<span className="text-xs font-bold text-brand-dark/40 dark:text-white/40">
{selectedGlossaryDetail?.terms?.length || selected.terms_count} {t('translate.glossary.total') || 'au total'}
</span>
</div>
{isLoadingDetail ? (
<div className="flex items-center justify-center py-3 gap-1.5 text-xs text-brand-dark/40 dark:text-white/30 font-light">
<Loader2 size={12} className="animate-spin text-brand-accent" /> {t('translate.glossary.loading') || 'Chargement...'}
</div>
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
<div className="grid grid-cols-1 gap-1 max-h-[120px] overflow-y-auto pr-1">
{selectedGlossaryDetail.terms.slice(0, 4).map((t: any, i: number) => {
const translations = t.translations || {};
const displayTarget = translations[targetLang] || t.target;
return (
<div key={t.id || i} className="flex justify-between items-center bg-brand-muted/30 dark:bg-white/5 px-2 py-1.5 rounded-md text-xs">
<span className="font-semibold text-brand-dark/75 dark:text-white/70 truncate max-w-[220px]">
{t.source} {displayTarget}
</span>
<span className="inline-block w-1.5 h-1.5 bg-brand-accent rounded-full shrink-0" />
</div>
);
})}
{selectedGlossaryDetail.terms.length > 4 && (
<span className="text-[10px] text-brand-dark/40 dark:text-white/40 block text-right font-medium mt-1">
+ {selectedGlossaryDetail.terms.length - 4} {t('translate.glossary.moreTerms') || 'autres termes'}
</span>
)}
</div>
) : (
<p className="py-3 text-xs text-brand-dark/40 dark:text-white/30 italic text-center font-light">{t('translate.glossary.noTerms') || 'Aucun terme dans ce glossaire.'}</p>
)}
</div>
)}
{/* Ultra-neat Quick Term Adder */}
{selected && (
<form onSubmit={handleAddTerm} className="grid grid-cols-2 gap-2">
<input
type="text"
required
placeholder={t('translate.glossary.sourceTerm') || 'Terme Source'}
value={newSource}
onChange={(e) => setNewSource(e.target.value)}
disabled={isAddingTerm || disabled}
className="w-full bg-white dark:bg-[#1a1a1a] border border-black/5 dark:border-white/5 rounded-lg px-2.5 py-1.5 text-xs font-semibold text-brand-dark dark:text-white placeholder:text-brand-dark/30 outline-none focus:border-brand-accent min-w-0"
/>
<div className="flex gap-1.5">
<input
type="text"
required
placeholder={t('translate.glossary.translation') || 'Traduction'}
value={newTarget}
onChange={(e) => setNewTarget(e.target.value)}
disabled={isAddingTerm || disabled}
className="flex-1 bg-white dark:bg-[#1a1a1a] border border-black/5 dark:border-white/5 rounded-lg px-2.5 py-1.5 text-xs font-semibold text-brand-dark dark:text-white placeholder:text-brand-dark/30 outline-none focus:border-brand-accent min-w-0"
/>
<button
type="submit"
disabled={isAddingTerm || disabled || !newSource.trim() || !newTarget.trim()}
className="px-3 bg-brand-dark dark:bg-white text-white dark:text-brand-dark rounded-lg flex items-center justify-center disabled:opacity-35 transition-colors cursor-pointer shrink-0"
title={t('translate.glossary.addTerm') || 'Ajouter le terme'}
>
{isAddingTerm ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Plus size={14} />
)}
</button>
</div>
</form>
)}
</div>
) : (
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center animate-fade-in">
<span className="text-xs font-black uppercase opacity-40">{t('translate.glossary.disabledMode') || 'Moteur neutre sans glossaire appliqué'}</span>
</div>
)}
</div>
);
}