refactor(glossaries): single source of truth + dedicated detail page
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:
Sepehr
2026-06-07 09:38:19 +02:00
parent 23373ab91a
commit e497f2d218
3 changed files with 1192 additions and 276 deletions

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

View File

@@ -1,81 +1,52 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
BookText, Plus, Library, Calendar, Hash,
Zap, Save, Trash2, MessageSquare, Loader2,
Monitor, Scale, Stethoscope, BarChart3,
Megaphone, ShoppingCart, FlaskConical, Users,
CheckCircle2, AlertCircle, ArrowRight, MousePointerClick,
Info, ExternalLink,
MessageSquare, Save, Trash2, Loader2,
CheckCircle2, AlertCircle, ArrowRight, Info, ExternalLink, Search,
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useUser } from '@/app/dashboard/useUser';
import { useI18n } from '@/lib/i18n';
import { useGlossaries, useGlossary } from './useGlossaries';
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
import { useGlossaries } from './useGlossaries';
import type { GlossaryListItem } from './types';
import { ProUpgradePrompt } from './ProUpgradePrompt';
import { CreateGlossaryDialog } from './CreateGlossaryDialog';
import { EditGlossaryDialog } from './EditGlossaryDialog';
import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
import { useToast } from '@/components/ui/toast';
import { SUPPORTED_LANGUAGES } from './types';
import { useTranslationStore } from '@/lib/store';
import { API_BASE } from '@/lib/config';
const PRESETS = [
{ key: 'it', titleKey: 'glossaries.presets.it.title', descKey: 'glossaries.presets.it.desc', icon: Monitor, templateId: 'technology' },
{ key: 'legal', titleKey: 'glossaries.presets.legal.title', descKey: 'glossaries.presets.legal.desc', icon: Scale, templateId: 'legal' },
{ key: 'medical', titleKey: 'glossaries.presets.medical.title', descKey: 'glossaries.presets.medical.desc', icon: Stethoscope, templateId: 'medical' },
{ key: 'finance', titleKey: 'glossaries.presets.finance.title', descKey: 'glossaries.presets.finance.desc', icon: BarChart3, templateId: 'finance' },
{ key: 'marketing', titleKey: 'glossaries.presets.marketing.title', descKey: 'glossaries.presets.marketing.desc', icon: Megaphone, templateId: 'marketing' },
{ key: 'hr', titleKey: 'glossaries.presets.hr.title', descKey: 'glossaries.presets.hr.desc', icon: Users, templateId: 'hr' },
{ key: 'scientific', titleKey: 'glossaries.presets.scientific.title', descKey: 'glossaries.presets.scientific.desc', icon: FlaskConical, templateId: 'scientific' },
{ key: 'ecommerce', titleKey: 'glossaries.presets.ecommerce.title', descKey: 'glossaries.presets.ecommerce.desc', icon: ShoppingCart, templateId: 'ecommerce' },
];
export default function GlossariesPage() {
const { t } = useI18n();
const router = useRouter();
const { data: user, isLoading: isLoadingUser } = useUser();
const {
glossaries,
total,
isLoading: isLoadingGlossaries,
isCreating,
isUpdating,
isDeleting,
isImportingTemplate,
createGlossary,
updateGlossary,
deleteGlossary,
importTemplate,
} = useGlossaries();
const { toast } = useToast();
const { settings, updateSettings } = useTranslationStore();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedGlossary, setSelectedGlossary] = useState<GlossaryListItem | null>(null);
const [glossaryToDelete, setGlossaryToDelete] = useState<{ id: string; name: string } | null>(null);
const [systemPrompt, setSystemPrompt] = useState(settings.systemPrompt);
const [isSavingPrompt, setIsSavingPrompt] = useState(false);
const [promptSaved, setPromptSaved] = useState(false);
const [creatingPreset, setCreatingPreset] = useState<string | null>(null);
const { glossary: fullGlossary, isLoading: isLoadingGlossaryDetail } = useGlossary(
selectedGlossary?.id || null
);
const [searchQuery, setSearchQuery] = useState('');
const isPro = user?.tier === 'pro';
const isLoading = isLoadingUser || isLoadingGlossaries;
// Current translation target from store
const currentTargetLang = settings.defaultTargetLanguage;
const currentTargetInfo = SUPPORTED_LANGUAGES.find(l => l.code === currentTargetLang);
// Track whether prompt has unsaved changes
const promptHasUnsavedChanges = systemPrompt !== settings.systemPrompt;
const promptIsActive = !!settings.systemPrompt?.trim();
@@ -92,7 +63,9 @@ export default function GlossariesPage() {
setPromptSaved(true);
toast({ title: t('context.saved'), description: t('context.savedDesc') });
setTimeout(() => setPromptSaved(false), 3000);
} finally { setIsSavingPrompt(false); }
} finally {
setIsSavingPrompt(false);
}
};
const handleClearPrompt = () => {
@@ -100,56 +73,7 @@ export default function GlossariesPage() {
setSystemPrompt('');
};
const handleCreatePresetGlossary = async (preset: typeof PRESETS[0]) => {
setCreatingPreset(preset.key);
try {
const token = localStorage.getItem('token');
if (!token) return;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
};
const params = new URLSearchParams({ template_id: preset.templateId });
const res = await fetch(`${API_BASE}/api/v1/glossaries/import?${params.toString()}`, {
method: 'POST',
headers,
});
if (res.ok) {
const result = await res.json();
const glossary = result.data;
toast({
title: t('context.presets.created'),
description: t('context.presets.createdDesc', {
name: glossary?.name ?? t(preset.titleKey),
count: String(glossary?.terms?.length ?? 0),
}),
});
} else if (res.status === 409) {
toast({ title: t('glossaries.presets.alreadyImported') });
} else {
toast({ variant: 'destructive', title: t('glossaries.toast.error'), description: t('glossaries.toast.errorCreate') });
}
} catch {
toast({ variant: 'destructive', title: t('glossaries.toast.error'), description: t('glossaries.toast.errorImport') });
} finally {
setCreatingPreset(null);
}
};
const handleEditClick = (id: string) => {
const glossary = glossaries.find((g: GlossaryListItem) => g.id === id);
if (glossary) {
setSelectedGlossary(glossary);
setEditDialogOpen(true);
}
};
const handleDeleteClick = (id: string, name: string) => {
setGlossaryToDelete({ id, name });
setDeleteDialogOpen(true);
};
const handleCreateGlossary = async (data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => {
const handleCreateGlossary = async (data: { name: string; source_language: string; target_language: string; terms: { source: string; target: string; translations?: Record<string, string> }[] }) => {
try {
await createGlossary(data);
setCreateDialogOpen(false);
@@ -187,43 +111,12 @@ export default function GlossariesPage() {
}
};
const handleSaveGlossary = async (id: string, data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => {
try {
await updateGlossary(id, data);
setEditDialogOpen(false);
setSelectedGlossary(null);
toast({
title: t('glossaries.toast.updated'),
description: t('glossaries.toast.updatedDesc', { name: data.name }),
});
} catch (error) {
toast({
variant: 'destructive',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorUpdate'),
});
throw error;
}
};
const handleDeleteConfirm = async () => {
if (!glossaryToDelete) return;
try {
await deleteGlossary(glossaryToDelete.id);
setDeleteDialogOpen(false);
setGlossaryToDelete(null);
toast({
title: t('glossaries.toast.deleted'),
description: t('glossaries.toast.deletedDesc'),
});
} catch (error) {
toast({
variant: 'destructive',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorDelete'),
});
}
};
// Filtered list (search)
const filteredGlossaries = useMemo(() => {
if (!searchQuery.trim()) return glossaries;
const q = searchQuery.toLowerCase();
return glossaries.filter((g) => g.name.toLowerCase().includes(q));
}, [glossaries, searchQuery]);
if (isLoading) {
return (
@@ -240,18 +133,6 @@ export default function GlossariesPage() {
return <ProUpgradePrompt />;
}
const renderTitle = (title: string) => {
const lastSpaceIndex = title.lastIndexOf(' ');
if (lastSpaceIndex === -1) return title;
const firstPart = title.substring(0, lastSpaceIndex);
const lastWord = title.substring(lastSpaceIndex + 1);
return (
<>
{firstPart} <span className="italic">{lastWord}</span>
</>
);
};
return (
<div className="max-w-6xl mx-auto w-full p-6 lg:p-8">
@@ -270,15 +151,15 @@ export default function GlossariesPage() {
</div>
<button
onClick={() => setCreateDialogOpen(true)}
disabled={isCreating}
disabled={isCreating || isImportingTemplate}
className="premium-button px-8 py-3 text-xs uppercase tracking-widest !rounded-xl flex items-center gap-2 disabled:opacity-50 shrink-0 cursor-pointer font-bold"
>
<Plus size={14} />
{t('glossaries.createNew')}
{t('glossaries.createNew') || "Créer un glossaire"}
</button>
</div>
{/* ── Comment ça marche ─────────────────────────────────── */}
{/* ── How it works ───────────────────────────────────────── */}
<div className="mb-10 p-5 rounded-2xl bg-brand-accent/5 dark:bg-brand-accent/10 border border-brand-accent/20 dark:border-brand-accent/15">
<div className="flex items-center gap-2 mb-4">
<Info size={15} className="text-brand-accent shrink-0" />
@@ -325,9 +206,8 @@ export default function GlossariesPage() {
<div className="space-y-10">
{/* ── System Prompt ────────────────────────────────────── */}
{/* ── System Prompt (Context) ─────────────────────────────── */}
<section className="editorial-card p-8 lg:p-10 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
{/* Header with status badge */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3 text-brand-accent">
<MessageSquare size={18} />
@@ -335,7 +215,6 @@ export default function GlossariesPage() {
{t('context.instructions.title')}
</h3>
</div>
{/* Status badge */}
{promptHasUnsavedChanges ? (
<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} />
@@ -396,75 +275,9 @@ export default function GlossariesPage() {
</div>
</section>
{/* ── Professional Presets ─────────────────────────────── */}
<section className="editorial-card p-8 lg:p-10 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
<div className="flex items-center gap-3 mb-3 text-brand-accent">
<Zap size={18} />
<h3 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
{t('context.presets.title')}
</h3>
</div>
{/* Explanation box */}
<div className="mb-6 p-3.5 rounded-xl bg-brand-muted/40 dark:bg-white/[0.03] border border-black/5 dark:border-white/5">
<p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed">
<strong className="font-bold text-brand-dark/80 dark:text-white/80">{t('glossaries.presets.whatForBold')}</strong> {t('glossaries.presets.whatForDesc')}
</p>
<div className="flex items-center gap-2 mt-2.5">
<MousePointerClick size={11} className="text-brand-accent shrink-0" />
<p className="text-[11px] text-brand-accent/80 font-medium">
{t('glossaries.presets.clickHint')}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{(() => {
const importedTemplateIds = new Set(
glossaries
.map((g: GlossaryListItem) => g.template_id)
.filter(Boolean) as string[]
);
return PRESETS.map((p) => {
const Icon = p.icon;
const isCreatingThis = creatingPreset === p.key;
const alreadyImported = importedTemplateIds.has(p.templateId);
return (
<button
key={p.key}
onClick={() => !alreadyImported && handleCreatePresetGlossary(p)}
disabled={!!creatingPreset || alreadyImported}
className="relative p-4 bg-brand-muted/40 dark:bg-white/5 hover:bg-brand-accent/5 dark:hover:bg-brand-accent/10 border border-black/5 dark:border-white/5 rounded-xl text-left transition-all hover:border-brand-accent/30 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed group min-h-[105px] flex flex-col justify-between"
>
<div className="flex items-center justify-between mb-2">
<div className="p-1.5 bg-brand-accent/10 rounded-lg text-brand-accent group-hover:scale-110 transition-transform">
{isCreatingThis ? <Loader2 size={16} className="animate-spin" /> : <Icon size={16} />}
</div>
{isCreatingThis && <span className="text-[10px] text-brand-accent font-bold uppercase">{t('glossaries.presets.creating')}</span>}
{alreadyImported && (
<span className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
<CheckCircle2 size={9} /> {t('glossaries.presets.alreadyImported')}
</span>
)}
</div>
<div>
<h4 className="text-xs font-bold text-brand-dark dark:text-white mb-1">
{t(p.titleKey)}
</h4>
<p className="text-[10px] text-brand-dark/45 dark:text-white/45 leading-normal font-light">
{t(p.descKey)}
</p>
</div>
</button>
);
});
})()}
</div>
</section>
{/* ── Glossary Grid ──────────────────────────────────────── */}
<div>
<div className="flex items-center justify-between mb-5">
{/* ── Your Glossaries ────────────────────────────────────── */}
<section>
<div className="flex items-center justify-between mb-5 gap-4">
<div>
<h2 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight">
{t('glossaries.grid.title')} <span className="italic">{t('glossaries.grid.titleHighlight')}</span>
@@ -475,7 +288,7 @@ export default function GlossariesPage() {
: t('glossaries.grid.emptyAction')}
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 shrink-0">
{currentTargetInfo && (
<span className="flex items-center gap-1.5 text-[10px] font-bold text-brand-dark/50 dark:text-white/40 bg-brand-muted dark:bg-white/5 border border-black/5 dark:border-white/5 px-3 py-1.5 rounded-full">
<span>{t('glossaries.grid.activeTranslation')}</span>
@@ -494,29 +307,57 @@ export default function GlossariesPage() {
</div>
</div>
{/* Search bar (only if more than 3 glossaries) */}
{glossaries.length > 3 && (
<div className="relative mb-5">
<Search size={14} className="absolute left-3 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.grid.searchPlaceholder') || "Rechercher un glossaire…"}
className="w-full pl-9 pr-3 py-2.5 text-xs rounded-lg border border-black/5 dark:border-white/10 bg-white dark:bg-[#141414] focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30"
/>
</div>
)}
{glossaries.length === 0 ? (
<div className="editorial-card p-12 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-xl flex items-center justify-center text-brand-accent mx-auto mb-4">
<Library size={24} />
</div>
<p className="text-base font-serif font-medium text-brand-dark dark:text-white mb-1">{t('glossaries.empty')}</p>
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light">{t('glossaries.emptyDesc')}</p>
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light mb-5">{t('glossaries.emptyDesc')}</p>
<button
onClick={() => setCreateDialogOpen(true)}
className="premium-button px-6 py-2.5 text-[11px] uppercase tracking-widest !rounded-lg inline-flex items-center gap-2 cursor-pointer font-bold"
>
<Plus size={12} />
{t('glossaries.createNew') || "Créer un glossaire"}
</button>
</div>
) : filteredGlossaries.length === 0 ? (
<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">
<p className="text-xs text-brand-dark/50 dark:text-white/50 font-light">
{t('glossaries.grid.noResults') || "Aucun résultat pour cette recherche."}
</p>
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{glossaries.map((glossary: GlossaryListItem) => {
{filteredGlossaries.map((glossary: GlossaryListItem) => {
const termCount = glossary.terms_count ?? 0;
const srcInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language);
const tgtInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language);
const isMultilingual = glossary.target_language === 'multi';
// Does this glossary match the current translation target?
const matchesTarget = currentTargetLang && (isMultilingual || glossary.target_language === currentTargetLang);
const mismatch = currentTargetLang && !isMultilingual && glossary.target_language && glossary.target_language !== currentTargetLang;
return (
<div
<button
key={glossary.id}
onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}
className={cn(
'editorial-card p-6 bg-white dark:bg-[#141414] border rounded-2xl shadow-sm group transition-all relative',
'editorial-card p-6 bg-white dark:bg-[#141414] border rounded-2xl shadow-sm group transition-all relative text-left w-full',
'hover:shadow-md hover:-translate-y-0.5 cursor-pointer',
matchesTarget
? 'border-brand-accent/40 ring-1 ring-brand-accent/20 hover:border-brand-accent/60'
: mismatch
@@ -524,7 +365,6 @@ export default function GlossariesPage() {
: 'border-black/5 dark:border-white/5 hover:border-brand-accent/30'
)}
>
{/* Match / mismatch badge */}
{matchesTarget && (
<div className="absolute top-3 right-3 flex items-center gap-1 bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider">
<CheckCircle2 size={9} /> {t('glossaries.badge.compatible')}
@@ -564,45 +404,12 @@ export default function GlossariesPage() {
{new Date(glossary.created_at).toLocaleDateString()}
</span>
</div>
{/* Action buttons — always visible, unambiguous */}
<div className="flex gap-2 mt-4">
<button
onClick={() => handleEditClick(glossary.id)}
className="flex-1 py-2 px-3 rounded-lg bg-brand-muted/60 dark:bg-white/5 hover:bg-brand-accent/10 dark:hover:bg-brand-accent/15 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center justify-center gap-1.5"
>
<Save size={10} /> {t('glossaries.card.editTerms')}
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(glossary.id, glossary.name);
}}
className="py-2 px-3 rounded-lg bg-red-500/5 hover:bg-red-500 hover:text-white text-red-500 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1.5"
>
<Trash2 size={10} /> {t('glossaries.card.delete')}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── About section ──────────────────────────────────────── */}
<div className="editorial-card p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
<div className="flex items-center gap-3 text-brand-accent mb-4">
<BookText size={18} />
<span className="text-xs font-serif font-medium text-brand-dark dark:text-white tracking-tight">
{t('glossaries.aboutTitle')}
</span>
</div>
<p className="text-xs text-brand-dark/50 dark:text-white/40 font-light leading-relaxed mb-3">{t('glossaries.aboutDesc')}</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60">
{t('glossaries.aboutFormat')}
</p>
</div>
</section>
</div>
{/* Dialogs */}
@@ -614,27 +421,18 @@ export default function GlossariesPage() {
isCreating={isCreating}
isImportingTemplate={isImportingTemplate}
/>
{editDialogOpen && (fullGlossary || !isLoadingGlossaryDetail) && (
<EditGlossaryDialog
open={editDialogOpen}
onOpenChange={(open) => {
setEditDialogOpen(open);
if (!open) setSelectedGlossary(null);
}}
glossary={fullGlossary}
onSave={handleSaveGlossary}
isSaving={isUpdating}
/>
)}
<DeleteGlossaryDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteConfirm}
isDeleting={isDeleting}
glossaryName={glossaryToDelete?.name}
/>
</div>
);
}
function renderTitle(title: string) {
const lastSpaceIndex = title.lastIndexOf(' ');
if (lastSpaceIndex === -1) return title;
const firstPart = title.substring(0, lastSpaceIndex);
const lastWord = title.substring(lastSpaceIndex + 1);
return (
<>
{firstPart} <span className="italic">{lastWord}</span>
</>
);
}

View File

@@ -408,6 +408,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Other target",
"glossaries.card.editTerms": "Edit terms",
"glossaries.card.delete": "Delete",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Webhook Integration",
"apiKeys.webhook.descriptionBefore": "Pass a ",
"apiKeys.webhook.descriptionAfter": " parameter to receive a POST request when your translation is complete.",
@@ -1352,6 +1392,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Rechercher un glossaire…",
"glossaries.grid.noResults": "Aucun résultat pour cette recherche.",
"glossaries.detail.backToList": "Retour aux glossaires",
"glossaries.detail.save": "Enregistrer",
"glossaries.detail.savedTitle": "Enregistré",
"glossaries.detail.savedDesc": "Le glossaire a été mis à jour.",
"glossaries.detail.settingsTitle": "Paramètres",
"glossaries.detail.sourceLang": "Langue source",
"glossaries.detail.targetLang": "Langue cible",
"glossaries.detail.termsTitle": "Termes",
"glossaries.detail.terms": "termes",
"glossaries.detail.searchTerms": "Filtrer…",
"glossaries.detail.noTerms": "Aucun terme pour l'instant.",
"glossaries.detail.addFirstTerm": "Ajouter le premier terme",
"glossaries.detail.addTerm": "Ajouter un terme",
"glossaries.detail.maxReached": "Limite maximale atteinte",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Cible",
"glossaries.detail.sourcePlaceholder": "terme source",
"glossaries.detail.targetPlaceholder": "terme cible",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Exportez vos termes en CSV ou importez-en de nouveaux (remplace la liste actuelle).",
"glossaries.detail.export": "Exporter",
"glossaries.detail.import": "Importer",
"glossaries.detail.dangerTitle": "Zone danger",
"glossaries.detail.dangerDesc": "La suppression est définitive. Tous les termes associés seront perdus.",
"glossaries.detail.deleteGlossary": "Supprimer ce glossaire",
"glossaries.detail.confirmDelete": "Confirmer la suppression ?",
"glossaries.detail.confirm": "Confirmer",
"glossaries.detail.cancel": "Annuler",
"glossaries.detail.notFoundTitle": "Glossaire introuvable",
"glossaries.detail.notFoundDesc": "Ce glossaire n'existe pas ou vous n'y avez pas accès.",
"glossaries.detail.maxTermsTitle": "Limite atteinte",
"glossaries.detail.maxTermsDesc": "Maximum {max} termes par glossaire.",
"glossaries.detail.importEmptyTitle": "Fichier vide",
"glossaries.detail.importEmptyDesc": "Aucun terme détecté dans ce fichier.",
"glossaries.detail.importedTitle": "Importé",
"glossaries.detail.importedDesc": "{count} termes importés.",
"glossaries.detail.importErrorTitle": "Erreur de lecture",
"glossaries.detail.importErrorDesc": "Impossible de lire le fichier.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -2282,6 +2362,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -3167,6 +3287,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -4052,6 +4212,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -4937,6 +5137,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -5822,6 +6062,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -6707,6 +6987,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -7594,6 +7914,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -8478,6 +8838,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -9362,6 +9762,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -10204,6 +10644,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
@@ -11055,6 +11535,46 @@ const messages: Record<Locale, Record<string, string>> = {
"glossaries.badge.otherTarget": "Autre cible",
"glossaries.card.editTerms": "Modifier les termes",
"glossaries.card.delete": "Supprimer",
"glossaries.grid.searchPlaceholder": "Search a glossary…",
"glossaries.grid.noResults": "No results for this search.",
"glossaries.detail.backToList": "Back to glossaries",
"glossaries.detail.save": "Save",
"glossaries.detail.savedTitle": "Saved",
"glossaries.detail.savedDesc": "The glossary has been updated.",
"glossaries.detail.settingsTitle": "Settings",
"glossaries.detail.sourceLang": "Source language",
"glossaries.detail.targetLang": "Target language",
"glossaries.detail.termsTitle": "Terms",
"glossaries.detail.terms": "terms",
"glossaries.detail.searchTerms": "Filter…",
"glossaries.detail.noTerms": "No terms yet.",
"glossaries.detail.addFirstTerm": "Add the first term",
"glossaries.detail.addTerm": "Add a term",
"glossaries.detail.maxReached": "Maximum limit reached",
"glossaries.detail.source": "Source",
"glossaries.detail.target": "Target",
"glossaries.detail.sourcePlaceholder": "source term",
"glossaries.detail.targetPlaceholder": "target term",
"glossaries.detail.csvTitle": "CSV",
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
"glossaries.detail.export": "Export",
"glossaries.detail.import": "Import",
"glossaries.detail.dangerTitle": "Danger zone",
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
"glossaries.detail.deleteGlossary": "Delete this glossary",
"glossaries.detail.confirmDelete": "Confirm deletion?",
"glossaries.detail.confirm": "Confirm",
"glossaries.detail.cancel": "Cancel",
"glossaries.detail.notFoundTitle": "Glossary not found",
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
"glossaries.detail.maxTermsTitle": "Limit reached",
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
"glossaries.detail.importEmptyTitle": "Empty file",
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"apiKeys.webhook.title": "Intégration Webhook",
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",