All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m58s
The /dashboard/glossaries page had two issues: 1. SUPPORTED_LANGUAGES had no 'multi' code, so multilingual glossaries showed '🌐 undefined' instead of '🌐 Multilingue' 2. The Compatible/Autre cible badge compared target_language directly with currentTargetLang, so 'multi' never matched → always showed 'Autre cible'. Now 'multi' is treated as compatible with any target. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
626 lines
30 KiB
TypeScript
626 lines
30 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } 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,
|
||
} from 'lucide-react';
|
||
import Link from 'next/link';
|
||
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 { 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', title: 'IT / Logiciel', desc: 'Développement, infrastructure, DevOps', icon: Monitor, templateId: 'technology' },
|
||
{ key: 'legal', title: 'Juridique / Contrats', desc: 'Droit des affaires, contentieux', icon: Scale, templateId: 'legal' },
|
||
{ key: 'medical', title: 'Médical / Santé', desc: 'Pharmacologie, chirurgie, diagnostic', icon: Stethoscope, templateId: 'medical' },
|
||
{ key: 'finance', title: 'Finance / Comptabilité', desc: 'IFRS, bilans, fiscalité', icon: BarChart3, templateId: 'finance' },
|
||
{ key: 'marketing', title: 'Marketing / Publicité', desc: 'Digital, branding, analytics', icon: Megaphone, templateId: 'marketing' },
|
||
{ key: 'hr', title: 'RH / Ressources Humaines', desc: 'Contrats, politiques, recrutement', icon: Users, templateId: 'hr' },
|
||
{ key: 'scientific', title: 'Scientifique / Recherche', desc: 'Publications, thèses, articles', icon: FlaskConical, templateId: 'scientific' },
|
||
{ key: 'ecommerce', title: 'E-commerce / Vente', desc: 'Boutiques en ligne, catalogues, CRM', icon: ShoppingCart, templateId: 'ecommerce' },
|
||
];
|
||
|
||
export default function GlossariesPage() {
|
||
const { t } = useI18n();
|
||
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 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();
|
||
|
||
useEffect(() => {
|
||
setSystemPrompt(settings.systemPrompt);
|
||
setPromptSaved(false);
|
||
}, [settings.systemPrompt]);
|
||
|
||
const handleSavePrompt = async () => {
|
||
setIsSavingPrompt(true);
|
||
try {
|
||
updateSettings({ systemPrompt });
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
setPromptSaved(true);
|
||
toast({ title: t('context.saved'), description: t('context.savedDesc') });
|
||
setTimeout(() => setPromptSaved(false), 3000);
|
||
} finally { setIsSavingPrompt(false); }
|
||
};
|
||
|
||
const handleClearPrompt = () => {
|
||
updateSettings({ systemPrompt: '' });
|
||
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 ?? preset.title,
|
||
count: String(glossary?.terms?.length ?? 0),
|
||
}),
|
||
});
|
||
} 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[] }) => {
|
||
try {
|
||
await createGlossary(data);
|
||
setCreateDialogOpen(false);
|
||
toast({
|
||
title: t('glossaries.toast.created'),
|
||
description: t('glossaries.toast.createdDesc', { name: data.name }),
|
||
});
|
||
} catch (error) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: t('glossaries.toast.error'),
|
||
description: t('glossaries.toast.errorCreate'),
|
||
});
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const handleImportTemplate = async (templateId: string, name?: string) => {
|
||
try {
|
||
await importTemplate(templateId, name);
|
||
setCreateDialogOpen(false);
|
||
toast({
|
||
title: t('glossaries.toast.imported'),
|
||
description: name
|
||
? t('glossaries.toast.importedDesc', { name })
|
||
: t('glossaries.toast.importedDesc', { name: templateId }),
|
||
});
|
||
} catch (error) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: t('glossaries.toast.error'),
|
||
description: t('glossaries.toast.errorImport'),
|
||
});
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const handleSaveGlossary = async (id: string, data: { name: 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'),
|
||
});
|
||
}
|
||
};
|
||
|
||
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"></div>
|
||
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light">Chargement...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!isPro) {
|
||
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">
|
||
|
||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end mb-10 gap-6">
|
||
<div>
|
||
<span className="accent-pill mb-3 block w-fit font-medium text-[10px] uppercase tracking-widest">
|
||
{t('glossaries.yourGlossaries') || "Vos Glossaires"}
|
||
</span>
|
||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||
{renderTitle(t('glossaries.title') || "Glossaires & Contexte")}
|
||
</h1>
|
||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
||
{t('glossaries.description') || "Gérez vos glossaires et instructions de contexte pour des traductions plus précises."}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setCreateDialogOpen(true)}
|
||
disabled={isCreating}
|
||
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')}
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Comment ça marche ─────────────────────────────────── */}
|
||
<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" />
|
||
<span className="text-xs font-bold uppercase tracking-widest text-brand-accent">Comment ces paramètres sont utilisés</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||
{/* Step 1 */}
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-7 h-7 rounded-full bg-brand-accent text-white flex items-center justify-center text-[11px] font-black shrink-0 mt-0.5">1</div>
|
||
<div>
|
||
<p className="text-xs font-bold text-brand-dark dark:text-white mb-1">Configurez ici</p>
|
||
<p className="text-[11px] text-brand-dark/55 dark:text-white/50 font-light leading-relaxed">
|
||
Rédigez vos instructions de contexte ou créez/importez un glossaire de termes.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{/* Arrow */}
|
||
<div className="hidden sm:flex items-center justify-center">
|
||
<ArrowRight size={18} className="text-brand-accent/40" />
|
||
</div>
|
||
{/* Step 2 */}
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-7 h-7 rounded-full bg-brand-accent text-white flex items-center justify-center text-[11px] font-black shrink-0 mt-0.5">2</div>
|
||
<div>
|
||
<p className="text-xs font-bold text-brand-dark dark:text-white mb-1">Activez dans Traduire</p>
|
||
<p className="text-[11px] text-brand-dark/55 dark:text-white/50 font-light leading-relaxed">
|
||
Sur la page de traduction, dans la colonne de droite, sélectionnez votre glossaire.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 pt-4 border-t border-brand-accent/10 flex items-center justify-between">
|
||
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light">
|
||
⚠️ Les instructions de contexte s'appliquent <strong>automatiquement</strong> à toutes vos traductions IA une fois enregistrées. Les glossaires doivent être <strong>sélectionnés manuellement</strong> sur la page Traduire.
|
||
</p>
|
||
<Link
|
||
href="/dashboard/translate"
|
||
className="ml-4 shrink-0 flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline"
|
||
>
|
||
Aller à Traduire <ExternalLink size={11} />
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-10">
|
||
|
||
{/* ── System Prompt ────────────────────────────────────── */}
|
||
<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} />
|
||
<h3 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||
{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} />
|
||
Non enregistré
|
||
</span>
|
||
) : promptIsActive ? (
|
||
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-3 py-1 rounded-full border border-emerald-500/20">
|
||
<CheckCircle2 size={11} />
|
||
Actif · s'applique à toutes les traductions IA
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/30 dark:text-white/30 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/5 dark:border-white/5">
|
||
Inactif
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Explanation box */}
|
||
<div className="mb-5 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">À quoi ça sert ?</strong> Ces instructions sont envoyées automatiquement à l'IA avant chaque traduction, sans que vous ayez besoin de faire quoi que ce soit sur la page Traduire. Utilisez-les pour guider le style, le registre ou la terminologie générale.
|
||
</p>
|
||
<p className="text-[11px] text-brand-accent/80 dark:text-brand-accent/70 font-medium mt-2 italic">
|
||
Exemple : « Vous traduisez des rapports financiers. Soyez formel, précis et conservez tous les chiffres. »
|
||
</p>
|
||
</div>
|
||
|
||
<textarea
|
||
value={systemPrompt}
|
||
onChange={e => { setSystemPrompt(e.target.value); setPromptSaved(false); }}
|
||
placeholder={t('context.instructions.placeholder')}
|
||
className="w-full h-40 p-4 bg-brand-muted/30 dark:bg-white/[0.02] rounded-xl border border-black/5 dark:border-white/10 text-xs focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30 transition-all outline-none resize-y"
|
||
/>
|
||
<div className="flex justify-between items-center mt-4">
|
||
<p className="text-[10px] text-brand-dark/30 dark:text-white/25 font-light">
|
||
{systemPrompt.length > 0 ? `${systemPrompt.length} caractères` : 'Vide — aucune instruction envoyée à l\'IA'}
|
||
</p>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={handleClearPrompt}
|
||
className="px-5 py-2.5 bg-brand-muted dark:bg-white/5 text-brand-dark/50 dark:text-white/40 rounded-lg text-xs font-bold uppercase tracking-wider hover:text-brand-dark dark:hover:text-white transition-all cursor-pointer"
|
||
>
|
||
<Trash2 size={12} className="inline mr-1.5" />Tout effacer
|
||
</button>
|
||
<button
|
||
onClick={handleSavePrompt}
|
||
disabled={isSavingPrompt || !promptHasUnsavedChanges}
|
||
className="premium-button px-8 py-2.5 text-xs uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold"
|
||
>
|
||
{isSavingPrompt
|
||
? <><Loader2 size={14} className="animate-spin" /> Enregistrement…</>
|
||
: promptSaved
|
||
? <><CheckCircle2 size={14} /> Enregistré</>
|
||
: <><Save size={14} /> {t('context.save')}</>
|
||
}
|
||
</button>
|
||
</div>
|
||
</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">À quoi ça sert ?</strong> Cliquer sur une carte crée un glossaire pré-rempli avec les termes spécialisés du domaine. Ce glossaire apparaîtra dans vos glossaires ci-dessous, et vous pourrez le <strong>sélectionner manuellement</strong> sur la page Traduire pour forcer des traductions de termes précis.
|
||
</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">
|
||
Cliquez sur une carte → glossaire créé → sélectionnez-le dans Traduire
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
{PRESETS.map((p) => {
|
||
const Icon = p.icon;
|
||
const isCreatingThis = creatingPreset === p.key;
|
||
return (
|
||
<button
|
||
key={p.key}
|
||
onClick={() => handleCreatePresetGlossary(p)}
|
||
disabled={!!creatingPreset}
|
||
className="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">Création…</span>}
|
||
</div>
|
||
<div>
|
||
<h4 className="text-xs font-bold text-brand-dark dark:text-white mb-1">
|
||
{p.title}
|
||
</h4>
|
||
<p className="text-[10px] text-brand-dark/45 dark:text-white/45 leading-normal font-light">
|
||
{p.desc}
|
||
</p>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
{/* ── Glossary Grid ──────────────────────────────────────── */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-5">
|
||
<div>
|
||
<h2 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||
Vos <span className="italic">glossaires</span>
|
||
</h2>
|
||
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light mt-1">
|
||
{glossaries.length > 0
|
||
? `${glossaries.length} glossaire${glossaries.length > 1 ? 's' : ''} — cliquez sur une carte pour la modifier`
|
||
: 'Créez votre premier glossaire ou importez un preset ci-dessus'}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{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>Traduction active :</span>
|
||
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
|
||
</span>
|
||
)}
|
||
{glossaries.length > 0 && (
|
||
<Link
|
||
href="/dashboard/translate"
|
||
className="flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline shrink-0"
|
||
>
|
||
<ExternalLink size={12} />
|
||
Aller à Traduire pour activer
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</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>
|
||
</div>
|
||
) : (
|
||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{glossaries.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
|
||
key={glossary.id}
|
||
className={cn(
|
||
'editorial-card p-6 bg-white dark:bg-[#141414] border rounded-2xl shadow-sm group transition-all relative',
|
||
matchesTarget
|
||
? 'border-brand-accent/40 ring-1 ring-brand-accent/20 hover:border-brand-accent/60'
|
||
: mismatch
|
||
? 'border-amber-300/40 dark:border-amber-500/20 hover:border-amber-400/60 opacity-75 hover:opacity-100'
|
||
: '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} /> Compatible
|
||
</div>
|
||
)}
|
||
{mismatch && (
|
||
<div className="absolute top-3 right-3 flex items-center gap-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider">
|
||
<AlertCircle size={9} /> Autre cible
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-start gap-3 mb-5">
|
||
<div className="w-10 h-10 bg-brand-muted dark:bg-white/10 rounded-xl flex items-center justify-center text-brand-accent shrink-0">
|
||
<Library size={18} />
|
||
</div>
|
||
<div className="flex-1 min-w-0 pt-0.5">
|
||
<h3 className="text-sm font-serif font-semibold text-brand-dark dark:text-white tracking-tight leading-snug line-clamp-2">
|
||
{glossary.name}
|
||
</h3>
|
||
<p className="text-[11px] text-brand-dark/40 dark:text-white/40 font-medium flex items-center gap-1 mt-0.5">
|
||
<span>{srcInfo?.flag ?? '🌐'}</span>
|
||
<span>{srcInfo?.label ?? glossary.source_language}</span>
|
||
<span className="text-brand-accent font-bold">→</span>
|
||
<span>{tgtInfo?.flag ?? '🌐'}</span>
|
||
<span>{tgtInfo?.label ?? glossary.target_language}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-between items-center pt-4 border-t border-black/5 dark:border-white/10 text-xs text-brand-dark/40 dark:text-white/40">
|
||
<span className="flex items-center gap-1">
|
||
<Hash size={12} className="text-brand-accent" />
|
||
{termCount} {t('glossaries.defineTerms')}
|
||
</span>
|
||
<span className="flex items-center gap-1 font-mono">
|
||
<Calendar size={12} />
|
||
{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} /> Modifier les termes
|
||
</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} /> Supprimer
|
||
</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>
|
||
</div>
|
||
|
||
{/* Dialogs */}
|
||
<CreateGlossaryDialog
|
||
open={createDialogOpen}
|
||
onOpenChange={setCreateDialogOpen}
|
||
onCreate={handleCreateGlossary}
|
||
onImportTemplate={handleImportTemplate}
|
||
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>
|
||
);
|
||
}
|