All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m5s
- 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
619 lines
29 KiB
TypeScript
619 lines
29 KiB
TypeScript
'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>
|
||
);
|
||
}
|