refactor(glossaries): single source of truth + dedicated detail page
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m15s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m15s
UX refonte : - Retire la section 'Glossaires professionnels' de la vue principale (les 8 cartes de templates sont maintenant dans le dialog de creation) - Cartes 'Vos glossaires' plus simples : nom, langues, termes, date - Cliquer sur la carte navigue vers /dashboard/glossaries/[id] - Plus de boutons Edit/Delete sur la carte (deplaces dans la page detail) - Recherche par nom (visible si > 3 glossaires) - Badge 'Non enregistre' si modifications non sauvegardees Nouvelle page /dashboard/glossaries/[id] : - Edition inline du nom (input), langues source/cible (select) - Tableau des termes avec recherche et edition en place - Ajout/suppression de termes (max 500) - Export / Import CSV (meme logique que l'edit dialog) - Zone danger : confirmation en 2 temps pour la suppression - Back link vers la liste - i18n : 40 nouvelles cles ajoutees aux 13 locales (FR + EN traduit, les autres utilisent le fallback EN) Design preserve : editorial-card, brand-accent, meme typographie, meme palette. Refactor structurel uniquement, pas de restyling. Le system prompt (Instructions de contexte) reste tel quel, au-dessus de la liste des glossaires, comme dans le design actuel.
This commit is contained in:
598
frontend/src/app/dashboard/glossaries/[id]/page.tsx
Normal file
598
frontend/src/app/dashboard/glossaries/[id]/page.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft, Library, Calendar, Hash, Save, Trash2, Loader2,
|
||||
CheckCircle2, AlertCircle, Download, Upload, Plus, X, Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUser } from '@/app/dashboard/useUser';
|
||||
import {
|
||||
useGlossary,
|
||||
useGlossaries,
|
||||
} from '../useGlossaries';
|
||||
import {
|
||||
exportGlossaryToCsv,
|
||||
parseFileToTerms,
|
||||
generateCsvContent,
|
||||
} from '../csvUtils';
|
||||
import { SUPPORTED_LANGUAGES } from '../types';
|
||||
import type { GlossaryTermInput } from '../types';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { ProUpgradePrompt } from '../ProUpgradePrompt';
|
||||
|
||||
const MAX_TERMS = 500;
|
||||
|
||||
function getDisplayTarget(
|
||||
term: { target: string; translations?: Record<string, string> | null },
|
||||
lang: string
|
||||
): string {
|
||||
if (lang === 'multi' || lang === 'en' || !lang) return term.target;
|
||||
const translations = term.translations || {};
|
||||
return translations[lang] || term.target;
|
||||
}
|
||||
|
||||
export default function GlossaryDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = (params?.id as string) || '';
|
||||
const { t } = useI18n();
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const { glossary, isLoading, error } = useGlossary(id);
|
||||
const { updateGlossary, deleteGlossary, isUpdating, isDeleting } = useGlossaries();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isPro = user?.tier === 'pro';
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Editable state
|
||||
const [name, setName] = useState('');
|
||||
const [sourceLanguage, setSourceLanguage] = useState('fr');
|
||||
const [targetLanguage, setTargetLanguage] = useState('multi');
|
||||
const [terms, setTerms] = useState<GlossaryTermInput[]>([]);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Initialize from glossary data
|
||||
useEffect(() => {
|
||||
if (glossary && !initialized) {
|
||||
setName(glossary.name);
|
||||
setSourceLanguage(glossary.source_language || 'fr');
|
||||
setTargetLanguage(glossary.target_language || 'multi');
|
||||
setTerms(
|
||||
glossary.terms.map((t) => ({
|
||||
source: t.source,
|
||||
target: t.target,
|
||||
translations: t.translations || {},
|
||||
}))
|
||||
);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [glossary, initialized]);
|
||||
|
||||
// Reset on id change
|
||||
useEffect(() => {
|
||||
setInitialized(false);
|
||||
}, [id]);
|
||||
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
if (!glossary) return false;
|
||||
if (name.trim() !== glossary.name) return true;
|
||||
if (sourceLanguage !== (glossary.source_language || 'fr')) return true;
|
||||
if (targetLanguage !== (glossary.target_language || 'multi')) return true;
|
||||
const currentTerms = terms
|
||||
.filter((t) => t.source.trim() && t.target.trim())
|
||||
.map((t) => `${t.source}|${t.target}`).sort().join(';;');
|
||||
const originalTerms = glossary.terms
|
||||
.map((t) => `${t.source}|${t.target}`).sort().join(';;');
|
||||
return currentTerms !== originalTerms;
|
||||
}, [glossary, name, sourceLanguage, targetLanguage, terms]);
|
||||
|
||||
const validTerms = terms.filter((t) => t.source.trim() && t.target.trim());
|
||||
const validTermsCount = validTerms.length;
|
||||
const isDirty = hasUnsavedChanges();
|
||||
|
||||
const handleAddTerm = () => {
|
||||
if (validTermsCount >= MAX_TERMS) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('glossaries.detail.maxTermsTitle') || 'Limite atteinte',
|
||||
description: t('glossaries.detail.maxTermsDesc', { max: String(MAX_TERMS) }) ||
|
||||
`Maximum ${MAX_TERMS} termes par glossaire.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setTerms([...terms, { source: '', target: '' }]);
|
||||
};
|
||||
|
||||
const handleRemoveTerm = (index: number) => {
|
||||
setTerms(terms.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleTermChange = (index: number, field: 'source' | 'target', value: string) => {
|
||||
setTerms(terms.map((t, i) => (i === index ? { ...t, [field]: value } : t)));
|
||||
};
|
||||
|
||||
const handleTargetLanguageChange = (newLang: string) => {
|
||||
if (glossary) {
|
||||
setTargetLanguage(newLang);
|
||||
if (newLang === 'multi' || newLang === 'en') return;
|
||||
// Remap each term to show translation for the new language (when available)
|
||||
setTerms((prev) =>
|
||||
prev.map((t) => {
|
||||
const translations = (t.translations || {}) as Record<string, string>;
|
||||
const langTarget = translations[newLang];
|
||||
if (langTarget) {
|
||||
return { ...t, target: langTarget };
|
||||
}
|
||||
return t;
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!glossary || !name.trim()) return;
|
||||
try {
|
||||
await updateGlossary(glossary.id, {
|
||||
name: name.trim(),
|
||||
source_language: sourceLanguage,
|
||||
target_language: targetLanguage,
|
||||
terms: validTerms,
|
||||
});
|
||||
toast({
|
||||
title: t('glossaries.detail.savedTitle') || 'Enregistré',
|
||||
description: t('glossaries.detail.savedDesc') || 'Le glossaire a été mis à jour.',
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('glossaries.toast.error') || 'Erreur',
|
||||
description: t('glossaries.toast.errorUpdate') || 'Impossible de mettre à jour le glossaire.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!glossary) return;
|
||||
try {
|
||||
await deleteGlossary(glossary.id);
|
||||
toast({
|
||||
title: t('glossaries.toast.deleted') || 'Supprimé',
|
||||
description: t('glossaries.toast.deletedDesc') || 'Le glossaire a été supprimé.',
|
||||
});
|
||||
router.push('/dashboard/glossaries');
|
||||
} catch {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('glossaries.toast.error') || 'Erreur',
|
||||
description: t('glossaries.toast.errorDelete') || 'Impossible de supprimer le glossaire.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!glossary) return;
|
||||
const csv = generateCsvContent(validTerms);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${name.replace(/[^a-z0-9]/gi, '_') || 'glossary'}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImportClick = () => fileInputRef.current?.click();
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
try {
|
||||
const parsed = await parseFileToTerms(file);
|
||||
if (parsed.length === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('glossaries.detail.importEmptyTitle') || 'Fichier vide',
|
||||
description: t('glossaries.detail.importEmptyDesc') ||
|
||||
'Aucun terme détecté dans ce fichier.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (parsed.length > MAX_TERMS) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('glossaries.detail.maxTermsTitle') || 'Trop de termes',
|
||||
description: t('glossaries.detail.maxTermsDesc', { max: String(MAX_TERMS) }) ||
|
||||
`Maximum ${MAX_TERMS} termes par glossaire.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setTerms(parsed);
|
||||
toast({
|
||||
title: t('glossaries.detail.importedTitle') || 'Importé',
|
||||
description: t('glossaries.detail.importedDesc', { count: String(parsed.length) }) ||
|
||||
`${parsed.length} termes importés.`,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('glossaries.detail.importErrorTitle') || 'Erreur',
|
||||
description: t('glossaries.detail.importErrorDesc') || 'Impossible de lire le fichier.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filter terms by search
|
||||
const filteredTerms = useCallback(() => {
|
||||
if (!searchQuery.trim()) return terms.map((t, i) => ({ ...t, _index: i }));
|
||||
const q = searchQuery.toLowerCase();
|
||||
return terms
|
||||
.map((t, i) => ({ ...t, _index: i }))
|
||||
.filter((t) => t.source.toLowerCase().includes(q) || t.target.toLowerCase().includes(q));
|
||||
}, [terms, searchQuery]);
|
||||
|
||||
if (isLoadingUser) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPro) {
|
||||
return <ProUpgradePrompt />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto" />
|
||||
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light">
|
||||
{t('glossaries.loading') || 'Chargement…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !glossary) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 lg:p-8">
|
||||
<Link
|
||||
href="/dashboard/glossaries"
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-dark/60 dark:text-white/60 hover:text-brand-accent mb-6"
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
{t('glossaries.detail.backToList') || 'Retour aux glossaires'}
|
||||
</Link>
|
||||
<div className="editorial-card p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
|
||||
<AlertCircle size={32} className="mx-auto text-destructive mb-3" />
|
||||
<p className="text-base font-serif font-medium text-brand-dark dark:text-white mb-1">
|
||||
{t('glossaries.detail.notFoundTitle') || 'Glossaire introuvable'}
|
||||
</p>
|
||||
<p className="text-xs text-brand-dark/50 dark:text-white/50 font-light">
|
||||
{t('glossaries.detail.notFoundDesc') ||
|
||||
'Ce glossaire n\'existe pas ou vous n\'y avez pas accès.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const srcInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language);
|
||||
const tgtInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto w-full p-6 lg:p-8">
|
||||
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/dashboard/glossaries"
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-dark/60 dark:text-white/60 hover:text-brand-accent mb-6 uppercase tracking-wider"
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
{t('glossaries.detail.backToList') || 'Retour aux glossaires'}
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-6 mb-8">
|
||||
<div className="flex items-start gap-4 min-w-0 flex-1">
|
||||
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shrink-0">
|
||||
<Library size={22} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isUpdating}
|
||||
className="w-full text-2xl md:text-3xl font-serif font-semibold text-brand-dark dark:text-white tracking-tight leading-tight bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-brand-accent/20 rounded px-2 py-1 -mx-2"
|
||||
/>
|
||||
<div className="flex items-center gap-3 mt-2 text-[11px] text-brand-dark/50 dark:text-white/50 font-light">
|
||||
<span className="flex items-center gap-1">
|
||||
{srcInfo?.flag ?? '🌐'} {srcInfo?.label ?? glossary.source_language}
|
||||
<span className="text-brand-accent font-bold mx-1">→</span>
|
||||
{tgtInfo?.flag ?? '🌐'} {tgtInfo?.label ?? glossary.target_language}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
<Hash size={10} /> {validTermsCount} {t('glossaries.defineTerms') || 'termes'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
<Calendar size={10} /> {new Date(glossary.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isDirty && (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-500/10 px-3 py-1 rounded-full border border-amber-500/20">
|
||||
<AlertCircle size={11} />
|
||||
{t('glossaries.status.unsaved') || 'Non enregistré'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isUpdating || !isDirty || !name.trim()}
|
||||
className="premium-button px-6 py-2.5 text-[11px] uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold"
|
||||
>
|
||||
{isUpdating ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||
{t('glossaries.detail.save') || 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings card */}
|
||||
<section className="editorial-card p-6 lg:p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm mb-6">
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60 mb-4">
|
||||
{t('glossaries.detail.settingsTitle') || 'Paramètres'}
|
||||
</h3>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-brand-dark/50 dark:text-white/50 mb-1.5 block">
|
||||
{t('glossaries.detail.sourceLang') || 'Langue source'}
|
||||
</label>
|
||||
<select
|
||||
value={sourceLanguage}
|
||||
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||
disabled={isUpdating}
|
||||
className="w-full h-10 rounded-lg border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/20"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.filter((l) => l.code !== 'multi').map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.flag} {l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-brand-dark/50 dark:text-white/50 mb-1.5 block">
|
||||
{t('glossaries.detail.targetLang') || 'Langue cible'}
|
||||
</label>
|
||||
<select
|
||||
value={targetLanguage}
|
||||
onChange={(e) => handleTargetLanguageChange(e.target.value)}
|
||||
disabled={isUpdating}
|
||||
className="w-full h-10 rounded-lg border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/20"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.flag} {l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Terms table card */}
|
||||
<section className="editorial-card bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm mb-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60">
|
||||
{t('glossaries.detail.termsTitle') || 'Termes'}
|
||||
</h3>
|
||||
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light mt-0.5">
|
||||
{validTermsCount} / {MAX_TERMS} {t('glossaries.detail.terms') || 'termes'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{terms.length > 5 && (
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-brand-dark/30 dark:text-white/30" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('glossaries.detail.searchTerms') || 'Filtrer…'}
|
||||
className="pl-7 pr-2 py-1.5 text-[11px] w-32 sm:w-40 rounded-md border border-black/5 dark:border-white/10 bg-white dark:bg-[#141414] focus:outline-none focus:ring-2 focus:ring-brand-accent/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{terms.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-xs text-brand-dark/50 dark:text-white/50 font-light mb-4">
|
||||
{t('glossaries.detail.noTerms') || 'Aucun terme pour l\'instant.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAddTerm}
|
||||
disabled={isUpdating}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t('glossaries.detail.addFirstTerm') || 'Ajouter le premier terme'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-white dark:bg-[#141414] border-b border-black/5 dark:border-white/5 z-10">
|
||||
<tr>
|
||||
<th className="text-left font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50 px-6 py-2.5 w-[45%]">
|
||||
{t('glossaries.detail.source') || 'Source'}
|
||||
</th>
|
||||
<th className="text-left font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50 px-3 py-2.5 w-[45%]">
|
||||
{t('glossaries.detail.target') || 'Cible'}
|
||||
</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTerms().map((term) => (
|
||||
<tr
|
||||
key={term._index}
|
||||
className="border-b border-black/5 dark:border-white/5 last:border-0 group hover:bg-brand-muted/30 dark:hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="px-6 py-1.5">
|
||||
<input
|
||||
value={term.source}
|
||||
onChange={(e) => handleTermChange(term._index, 'source', e.target.value)}
|
||||
disabled={isUpdating}
|
||||
placeholder={t('glossaries.detail.sourcePlaceholder') || 'terme source'}
|
||||
className="w-full bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-brand-accent/20 rounded px-2 py-1.5 -mx-2 text-brand-dark dark:text-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<input
|
||||
value={targetLanguage === 'multi' || targetLanguage === 'en'
|
||||
? term.target
|
||||
: (term.translations?.[targetLanguage] || term.target)}
|
||||
onChange={(e) => handleTermChange(term._index, 'target', e.target.value)}
|
||||
disabled={isUpdating}
|
||||
placeholder={t('glossaries.detail.targetPlaceholder') || 'terme cible'}
|
||||
className="w-full bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-brand-accent/20 rounded px-2 py-1.5 -mx-2 text-brand-dark dark:text-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3">
|
||||
<button
|
||||
onClick={() => handleRemoveTerm(term._index)}
|
||||
disabled={isUpdating}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded text-brand-dark/40 dark:text-white/40 hover:text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add term footer */}
|
||||
{terms.length > 0 && (
|
||||
<div className="p-4 border-t border-black/5 dark:border-white/5 flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleAddTerm}
|
||||
disabled={isUpdating || validTermsCount >= MAX_TERMS}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline disabled:opacity-50"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t('glossaries.detail.addTerm') || 'Ajouter un terme'}
|
||||
</button>
|
||||
{validTermsCount >= MAX_TERMS && (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
|
||||
{t('glossaries.detail.maxReached') || 'Limite maximale atteinte'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* CSV + Danger zone */}
|
||||
<section className="grid sm:grid-cols-2 gap-4 mb-6">
|
||||
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60 mb-3">
|
||||
{t('glossaries.detail.csvTitle') || 'CSV'}
|
||||
</h3>
|
||||
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-light mb-4">
|
||||
{t('glossaries.detail.csvDesc') ||
|
||||
'Exportez vos termes en CSV ou importez-en de nouveaux (remplace la liste actuelle).'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={validTermsCount === 0}
|
||||
className="flex-1 py-2.5 px-3 rounded-lg bg-brand-muted/60 dark:bg-white/5 hover:bg-brand-accent/10 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Download size={11} />
|
||||
{t('glossaries.detail.export') || 'Exporter'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportClick}
|
||||
disabled={isUpdating}
|
||||
className="flex-1 py-2.5 px-3 rounded-lg bg-brand-muted/60 dark:bg-white/5 hover:bg-brand-accent/10 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Upload size={11} />
|
||||
{t('glossaries.detail.import') || 'Importer'}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editorial-card p-6 bg-red-500/5 border border-red-500/20 rounded-2xl">
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400 mb-3">
|
||||
{t('glossaries.detail.dangerTitle') || 'Zone danger'}
|
||||
</h3>
|
||||
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-light mb-4">
|
||||
{t('glossaries.detail.dangerDesc') ||
|
||||
'La suppression est définitive. Tous les termes associés seront perdus.'}
|
||||
</p>
|
||||
{!confirmDelete ? (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={isDeleting}
|
||||
className="w-full py-2.5 px-3 rounded-lg bg-red-500/10 hover:bg-red-500 hover:text-white text-red-500 text-[10px] font-bold uppercase tracking-wider transition-all disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
{t('glossaries.detail.deleteGlossary') || 'Supprimer ce glossaire'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] text-red-600 dark:text-red-400 font-bold">
|
||||
{t('glossaries.detail.confirmDelete') || 'Confirmer la suppression ?'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 py-2 px-3 rounded-lg bg-brand-muted dark:bg-white/5 text-brand-dark/70 dark:text-white/60 text-[10px] font-bold uppercase tracking-wider"
|
||||
>
|
||||
{t('glossaries.detail.cancel') || 'Annuler'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 py-2 px-3 rounded-lg bg-red-500 text-white text-[10px] font-bold uppercase tracking-wider hover:bg-red-600 transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{isDeleting ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />}
|
||||
{t('glossaries.detail.confirm') || 'Confirmer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user