feat(glossary): restructure page with tabs, direct translate redirects and dynamic mapping
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m39s

This commit is contained in:
2026-06-20 18:43:02 +02:00
parent c17dd2c6e1
commit d78f08e24f
2 changed files with 394 additions and 304 deletions

View File

@@ -152,6 +152,7 @@ export default function GlossariesPage() {
const [promptSaved, setPromptSaved] = useState(false); const [promptSaved, setPromptSaved] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [importingPresetId, setImportingPresetId] = useState<string | null>(null); const [importingPresetId, setImportingPresetId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'glossaries' | 'context'>('glossaries');
const isPro = user?.tier === 'pro'; const isPro = user?.tier === 'pro';
const isLoading = isLoadingUser || isLoadingGlossaries; const isLoading = isLoadingUser || isLoadingGlossaries;
@@ -348,337 +349,405 @@ export default function GlossariesPage() {
</div> </div>
</div> </div>
{/* ── Tab Switcher ───────────────────────────────────────── */}
<div className="flex border-b border-black/5 dark:border-white/5 mb-8">
<button
onClick={() => setActiveTab('glossaries')}
className={cn(
"pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer",
activeTab === 'glossaries'
? "border-brand-accent text-brand-dark dark:text-white"
: "border-transparent text-brand-dark/40 dark:text-white/40 hover:text-brand-dark/70 dark:hover:text-white/70"
)}
>
{t('glossaries.tabs.glossaries') || "Glossaires terminologiques"}
</button>
<button
onClick={() => setActiveTab('context')}
className={cn(
"pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer",
activeTab === 'context'
? "border-brand-accent text-brand-dark dark:text-white"
: "border-transparent text-brand-dark/40 dark:text-white/40 hover:text-brand-dark/70 dark:hover:text-white/70"
)}
>
{t('glossaries.tabs.context') || "Consignes de contexte (IA)"}
</button>
</div>
<div className="space-y-10"> <div className="space-y-10">
{activeTab === 'context' ? (
{/* ── System Prompt (Context) ─────────────────────────────── */} /* ── 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"> <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 animate-fade-in">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3 text-brand-accent"> <div className="flex items-center gap-3 text-brand-accent">
<MessageSquare size={18} /> <MessageSquare size={18} />
<h3 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight"> <h3 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
{t('context.instructions.title')} {t('context.instructions.title')}
</h3> </h3>
</div>
{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} />
{t('glossaries.status.unsaved')}
</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} />
{t('glossaries.status.active')}
</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">
{t('glossaries.status.inactive')}
</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">{t('glossaries.instructions.whatForBold')}</strong> {t('glossaries.instructions.whatForDesc')}
</p>
<p className="text-[11px] text-brand-accent/80 dark:text-brand-accent/70 font-medium mt-2 italic">
{t('glossaries.instructions.example')}
</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 ? t('glossaries.instructions.charCount', { count: systemPrompt.length }) : t('glossaries.instructions.emptyHint')}
</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" />{t('glossaries.instructions.clearAll')}
</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" /> {t('glossaries.instructions.saving')}</>
: promptSaved
? <><CheckCircle2 size={14} /> {t('glossaries.instructions.saved')}</>
: <><Save size={14} /> {t('context.save')}</>
}
</button>
</div>
</div>
</section>
{/* ── Nouveau Glossaire / Création Directe ─────────────────────── */}
<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="mb-6">
<div className="flex items-center gap-2 text-brand-accent mb-2">
<Zap size={18} />
<h2 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
{t('glossaries.presets.whatForBold') || 'Créer un nouveau glossaire'}
</h2>
</div>
<p className="text-xs text-brand-dark/50 dark:text-white/40 font-light">
{t('glossaries.presets.whatForDesc') || 'Choisissez un modèle professionnel pré-rempli ou importez vos propres termes.'}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Grille des Modèles (2/3 de largeur) */}
<div className="lg:col-span-2 space-y-3">
<div className="flex items-center gap-1.5 text-brand-accent">
<BookText size={14} />
<span className="text-[10px] font-bold uppercase tracking-wider">{t('context.presets.title') || 'Modèles professionnels'}</span>
</div> </div>
{promptHasUnsavedChanges ? (
{isLoadingTemplates ? ( <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">
<div className="flex items-center justify-center py-12"> <AlertCircle size={11} />
<Loader2 className="size-6 animate-spin text-brand-muted" /> {t('glossaries.status.unsaved')}
</div> </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} />
{t('glossaries.status.active')}
</span>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <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">
{templates.map((template) => { {t('glossaries.status.inactive')}
const Icon = TEMPLATE_ICONS[template.id] || BookText; </span>
const isImported = importedTemplateIds.has(template.id);
const isProcessingThis = importingPresetId === template.id;
return (
<button
key={template.id}
disabled={isImported || isProcessing}
onClick={() => handleImportPreset(template.id, template.name)}
className={cn(
'relative p-4 rounded-xl text-left border transition-all cursor-pointer min-h-[110px] flex flex-col justify-between group',
isImported
? 'bg-emerald-500/5 dark:bg-emerald-500/5 border-emerald-500/20 dark:border-emerald-500/10 opacity-80 cursor-default'
: 'bg-brand-muted/40 dark:bg-white/5 hover:bg-brand-accent/5 dark:hover:bg-brand-accent/10 border-black/5 dark:border-white/5 hover:border-brand-accent/30'
)}
>
<div className="flex items-center justify-between gap-2 w-full">
<div className="p-1.5 bg-brand-accent/10 rounded-lg text-brand-accent group-hover:scale-110 transition-transform">
{isProcessingThis ? <Loader2 size={16} className="animate-spin" /> : <Icon className="size-4" />}
</div>
{isImported ? (
<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') || 'Importé'}
</span>
) : (
<span className="accent-pill !px-2.5 !py-0.5 !text-[9px] lowercase font-light">
{template.terms_count} {t('glossaries.defineTerms') || 'termes'}
</span>
)}
</div>
<div className="mt-3">
<p className="text-xs font-serif font-bold text-brand-dark dark:text-white leading-tight">
{template.name.split(' - ')[0]}
</p>
<p className="text-[10px] text-brand-dark/45 dark:text-white/35 font-light leading-normal mt-1 line-clamp-2">
{template.description}
</p>
</div>
</button>
);
})}
</div>
)} )}
</div> </div>
{/* Fichier & Manuel (1/3 de largeur) */} {/* Explanation box */}
<div className="space-y-5"> <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">
{/* Import Fichier */} <p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed">
<div className="space-y-2.5"> <strong className="font-bold text-brand-dark/80 dark:text-white/80">{t('glossaries.instructions.whatForBold')}</strong> {t('glossaries.instructions.whatForDesc')}
<div className="flex items-center gap-1.5 text-brand-accent"> </p>
<Upload size={14} /> <p className="text-[11px] text-brand-accent/80 dark:text-brand-accent/70 font-medium mt-2 italic">
<span className="text-[10px] font-bold uppercase tracking-wider">{t('glossaries.dialog.tabFile') || 'Importer un fichier'}</span> {t('glossaries.instructions.example')}
</div> </p>
<FileUploadZone <p className="text-[10px] text-brand-accent font-bold mt-2.5 flex items-center gap-1.5 border-t border-black/5 dark:border-white/5 pt-2">
onTermsParsed={handleFileTermsImport} <Info size={11} />
disabled={isProcessing} Remarque : Ces consignes s'appliquent automatiquement à toutes vos traductions réalisées en mode Pro LLM.
t={t} </p>
/> </div>
</div>
{/* Création Manuelle */} <textarea
<div className="space-y-2.5"> value={systemPrompt}
<div className="flex items-center gap-1.5 text-brand-accent"> onChange={e => { setSystemPrompt(e.target.value); setPromptSaved(false); }}
<PenLine size={14} /> placeholder={t('context.instructions.placeholder')}
<span className="text-[10px] font-bold uppercase tracking-wider">{t('glossaries.dialog.tabManual') || 'Création manuelle'}</span> 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> />
<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 ? t('glossaries.instructions.charCount', { count: systemPrompt.length }) : t('glossaries.instructions.emptyHint')}
</p>
<div className="flex gap-3">
<button <button
onClick={() => setCreateDialogOpen(true)} onClick={handleClearPrompt}
disabled={isProcessing} 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"
className="editorial-card w-full flex items-center justify-between p-5 text-left transition-all hover:border-brand-accent/30 hover:bg-brand-muted/30 dark:hover:bg-white/[0.02] cursor-pointer group border-black/5 dark:border-white/5"
> >
<div className="flex items-center gap-3"> <Trash2 size={12} className="inline mr-1.5" />{t('glossaries.instructions.clearAll')}
<div className="w-9 h-9 rounded-lg bg-brand-accent/10 dark:bg-brand-accent/15 flex items-center justify-center text-brand-accent shrink-0 group-hover:scale-110 transition-transform"> </button>
<Plus size={16} /> <button
</div> onClick={handleSavePrompt}
<div> disabled={isSavingPrompt || !promptHasUnsavedChanges}
<p className="text-xs font-serif font-bold text-brand-dark dark:text-white leading-tight">{t('glossaries.createNew') || 'Créer manuellement'}</p> 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"
<p className="text-[10px] text-brand-dark/40 dark:text-white/35 mt-1 font-light leading-tight">{t('glossaries.dialog.createEmpty') || 'À partir de zéro'}</p> >
</div> {isSavingPrompt
</div> ? <><Loader2 size={14} className="animate-spin" /> {t('glossaries.instructions.saving')}</>
<ArrowRight className="size-4 text-brand-dark/30 dark:text-white/20 group-hover:text-brand-accent group-hover:translate-x-1 transition-all" /> : promptSaved
? <><CheckCircle2 size={14} /> {t('glossaries.instructions.saved')}</>
: <><Save size={14} /> {t('context.save')}</>
}
</button> </button>
</div> </div>
</div> </div>
</div> </section>
</section> ) : (
<div className="space-y-12 animate-fade-in">
{/* ── Section 1 : Vos Glossaires (En premier !) ───────────────── */}
<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>
</h2>
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light mt-1">
{glossaries.length > 0
? t('glossaries.grid.countWithAction', { count: glossaries.length, plural: glossaries.length > 1 ? 's' : '' })
: t('glossaries.grid.emptyAction')}
</p>
</div>
<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>
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
</span>
)}
{glossaries.length > 0 && (
<>
<button
onClick={() => setCreateDialogOpen(true)}
className="premium-button px-4 py-2 text-[11px] uppercase tracking-widest !rounded-lg inline-flex items-center gap-1.5 cursor-pointer font-bold shrink-0 shadow-sm"
>
<Plus size={12} />
{t('glossaries.createNew') || "Créer un glossaire"}
</button>
<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} />
{t('glossaries.grid.goToTranslate')}
</Link>
</>
)}
</div>
</div>
{/* ── Your Glossaries ────────────────────────────────────── */} {/* Search bar (only if more than 3 glossaries) */}
<section> {glossaries.length > 3 && (
<div className="flex items-center justify-between mb-5 gap-4"> <div className="relative mb-5">
<div> <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-brand-dark/30 dark:text-white/30" />
<h2 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight"> <input
{t('glossaries.grid.title')} <span className="italic">{t('glossaries.grid.titleHighlight')}</span> type="text"
</h2> value={searchQuery}
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light mt-1"> onChange={e => setSearchQuery(e.target.value)}
{glossaries.length > 0 placeholder={t('glossaries.grid.searchPlaceholder') || "Rechercher un glossaire…"}
? t('glossaries.grid.countWithAction', { count: glossaries.length, plural: glossaries.length > 1 ? 's' : '' }) 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"
: t('glossaries.grid.emptyAction')} />
</p> </div>
</div>
<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>
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
</span>
)} )}
{glossaries.length > 0 && (
<> {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 mb-5">{t('glossaries.emptyDesc')}</p>
<button <button
onClick={() => setCreateDialogOpen(true)} onClick={() => setCreateDialogOpen(true)}
className="premium-button px-4 py-2 text-[11px] uppercase tracking-widest !rounded-lg inline-flex items-center gap-1.5 cursor-pointer font-bold shrink-0 shadow-sm" 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} /> <Plus size={12} />
{t('glossaries.createNew') || "Créer un glossaire"} {t('glossaries.createNew') || "Créer un glossaire"}
</button> </button>
<Link </div>
href="/dashboard/translate" ) : filteredGlossaries.length === 0 ? (
className="flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline shrink-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">
<ExternalLink size={12} /> {t('glossaries.grid.noResults') || "Aucun résultat pour cette recherche."}
{t('glossaries.grid.goToTranslate')} </p>
</Link> </div>
</> ) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{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';
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 text-left w-full flex flex-col justify-between min-h-[200px]',
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'
)}
>
{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')}
</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} /> {t('glossaries.badge.otherTarget')}
</div>
)}
<div className="cursor-pointer flex-1" onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}>
<div className="flex items-start gap-3 mb-4">
<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 group-hover:text-brand-accent transition-colors">
{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 pb-4 text-xs text-brand-dark/40 dark:text-white/40">
<span className="flex items-center gap-1 font-semibold text-brand-dark/65 dark:text-white/65">
<Hash size={12} className="text-brand-accent" />
{termCount} {t('glossaries.defineTerms') || "termes"}
</span>
<span className="flex items-center gap-1 font-mono text-[9px] opacity-75">
<Calendar size={11} />
{new Date(glossary.created_at).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-black/5 dark:border-white/10 mt-auto shrink-0 w-full">
<Link
href={`/dashboard/glossaries/${glossary.id}`}
className="flex-1 text-center py-2 rounded-lg bg-brand-muted/65 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"
>
Détails / Éditer
</Link>
<Link
href={`/dashboard/translate?glossaryId=${glossary.id}`}
className="flex-1 text-center py-2 rounded-lg bg-brand-accent hover:bg-brand-accent/90 text-white text-[10px] font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-1.5"
>
Traduire
</Link>
</div>
</div>
);
})}
</div>
)} )}
</div> </section>
</div>
{/* Search bar (only if more than 3 glossaries) */} {/* ── Section 2 : Créer ou Importer ─────────────────────────── */}
{glossaries.length > 3 && ( <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="relative mb-5"> <div className="mb-6">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-brand-dark/30 dark:text-white/30" /> <div className="flex items-center gap-2 text-brand-accent mb-2">
<input <Zap size={18} />
type="text" <h2 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
value={searchQuery} {t('glossaries.presets.whatForBold') || 'Créer un nouveau glossaire'}
onChange={e => setSearchQuery(e.target.value)} </h2>
placeholder={t('glossaries.grid.searchPlaceholder') || "Rechercher un glossaire…"} </div>
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" <p className="text-xs text-brand-dark/50 dark:text-white/40 font-light">
/> {t('glossaries.presets.whatForDesc') || 'Choisissez un modèle professionnel pré-rempli ou importez vos propres termes.'}
</div> </p>
)}
{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> </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 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">
{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';
const matchesTarget = currentTargetLang && (isMultilingual || glossary.target_language === currentTargetLang);
const mismatch = currentTargetLang && !isMultilingual && glossary.target_language && glossary.target_language !== currentTargetLang;
return (
<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 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
? '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'
)}
>
{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')}
</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} /> {t('glossaries.badge.otherTarget')}
</div>
)}
<div className="flex items-start gap-3 mb-5"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<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"> {/* Grille des Modèles (2/3 de largeur) */}
<Library size={18} /> <div className="lg:col-span-2 space-y-3">
</div> <div className="flex items-center gap-1.5 text-brand-accent">
<div className="flex-1 min-w-0 pt-0.5"> <BookText size={14} />
<h3 className="text-sm font-serif font-semibold text-brand-dark dark:text-white tracking-tight leading-snug line-clamp-2"> <span className="text-[10px] font-bold uppercase tracking-wider">{t('context.presets.title') || 'Modèles professionnels'}</span>
{glossary.name} </div>
</h3>
<p className="text-[11px] text-brand-dark/40 dark:text-white/40 font-medium flex items-center gap-1 mt-0.5"> {isLoadingTemplates ? (
<span>{srcInfo?.flag ?? '🌐'}</span> <div className="flex items-center justify-center py-12">
<span>{srcInfo?.label ?? glossary.source_language}</span> <Loader2 className="size-6 animate-spin text-brand-muted" />
<span className="text-brand-accent font-bold"></span>
<span>{tgtInfo?.flag ?? '🌐'}</span>
<span>{tgtInfo?.label ?? glossary.target_language}</span>
</p>
</div>
</div> </div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{templates.map((template) => {
const Icon = TEMPLATE_ICONS[template.id] || BookText;
const isImported = importedTemplateIds.has(template.id);
const isProcessingThis = importingPresetId === template.id;
<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"> return (
<span className="flex items-center gap-1"> <div
<Hash size={12} className="text-brand-accent" /> key={template.id}
{termCount} {t('glossaries.defineTerms')} className={cn(
</span> 'relative p-4 rounded-xl text-left border transition-all min-h-[110px] flex flex-col justify-between group',
<span className="flex items-center gap-1 font-mono"> isImported
<Calendar size={12} /> ? 'bg-emerald-500/5 dark:bg-emerald-500/5 border-emerald-500/20 dark:border-emerald-500/10 opacity-90'
{new Date(glossary.created_at).toLocaleDateString()} : 'bg-brand-muted/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:border-brand-accent/20 hover:bg-brand-accent/[0.02]'
</span> )}
>
<div className="flex items-center justify-between gap-2 w-full">
<div className="p-1.5 bg-brand-accent/10 rounded-lg text-brand-accent group-hover:scale-115 transition-transform">
{isProcessingThis ? <Loader2 size={16} className="animate-spin" /> : <Icon className="size-4" />}
</div>
{isImported ? (
<div className="flex items-center gap-2">
<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') || 'Importé'}
</span>
{(() => {
const matchingGlossary = glossaries.find((g) => g.template_id === template.id);
return matchingGlossary ? (
<Link
href={`/dashboard/glossaries/${matchingGlossary.id}`}
className="text-[9px] font-bold uppercase tracking-wider text-brand-accent hover:underline flex items-center gap-0.5"
>
Modifier
</Link>
) : null;
})()}
</div>
) : (
<button
disabled={isProcessing}
onClick={() => handleImportPreset(template.id, template.name)}
className="accent-pill !px-3 !py-1 !text-[9px] font-bold uppercase tracking-wider bg-brand-accent text-white hover:bg-brand-accent/90 rounded-md transition-colors cursor-pointer"
>
{t('glossaries.presets.importBtn') || 'Importer'}
</button>
)}
</div>
<div className="mt-3">
<div className="flex justify-between items-baseline gap-2">
<p className="text-xs font-serif font-bold text-brand-dark dark:text-white leading-tight">
{template.name.split(' - ')[0]}
</p>
<span className="text-[9px] text-brand-dark/40 dark:text-white/30 font-semibold font-mono shrink-0">
{template.terms_count} {t('glossaries.defineTerms') || 'termes'}
</span>
</div>
<p className="text-[10px] text-brand-dark/45 dark:text-white/35 font-light leading-normal mt-1 line-clamp-2">
{template.description}
</p>
</div>
</div>
);
})}
</div> </div>
</button> )}
); </div>
})}
</div> {/* Fichier & Manuel (1/3 de largeur) */}
)} <div className="space-y-5">
</section> {/* Import Fichier */}
<div className="space-y-2.5">
<div className="flex items-center gap-1.5 text-brand-accent">
<Upload size={14} />
<span className="text-[10px] font-bold uppercase tracking-wider">{t('glossaries.dialog.tabFile') || 'Importer un fichier'}</span>
</div>
<FileUploadZone
onTermsParsed={handleFileTermsImport}
disabled={isProcessing}
t={t}
/>
</div>
{/* Création Manuelle */}
<div className="space-y-2.5">
<div className="flex items-center gap-1.5 text-brand-accent">
<PenLine size={14} />
<span className="text-[10px] font-bold uppercase tracking-wider">{t('glossaries.dialog.tabManual') || 'Création manuelle'}</span>
</div>
<button
onClick={() => setCreateDialogOpen(true)}
disabled={isProcessing}
className="editorial-card w-full flex items-center justify-between p-5 text-left transition-all hover:border-brand-accent/30 hover:bg-brand-muted/30 dark:hover:bg-white/[0.02] cursor-pointer group border-black/5 dark:border-white/5"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-brand-accent/10 dark:bg-brand-accent/15 flex items-center justify-center text-brand-accent shrink-0 group-hover:scale-110 transition-transform">
<Plus size={16} />
</div>
<div>
<p className="text-xs font-serif font-bold text-brand-dark dark:text-white leading-tight">{t('glossaries.createNew') || 'Créer manuellement'}</p>
<p className="text-[10px] text-brand-dark/40 dark:text-white/35 mt-1 font-light leading-tight">{t('glossaries.dialog.createEmpty') || 'À partir de zéro'}</p>
</div>
</div>
<ArrowRight className="size-4 text-brand-dark/30 dark:text-white/20 group-hover:text-brand-accent group-hover:translate-x-1 transition-all" />
</button>
</div>
</div>
</div>
</section>
</div>
)}
</div> </div>
{/* Dialogs */} {/* Dialogs */}

View File

@@ -83,9 +83,30 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
// Reset glossary selection when source language changes // Reset glossary selection when source language changes
// (multilingual glossaries are compatible with any target language) // (multilingual glossaries are compatible with any target language)
useEffect(() => { useEffect(() => {
// If there is a query param, do not reset it on first load
const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
if (params.get('glossaryId') === glossaryId) return;
setGlossaryId(null); setGlossaryId(null);
}, [sourceLang]); // eslint-disable-line react-hooks/exhaustive-deps }, [sourceLang]); // eslint-disable-line react-hooks/exhaustive-deps
// Pre-select glossary and LLM provider from query parameter
useEffect(() => {
if (typeof window === 'undefined' || isLoadingProviders || availableProviders.length === 0) return;
const params = new URLSearchParams(window.location.search);
const qGlossaryId = params.get('glossaryId');
if (qGlossaryId) {
setGlossaryId(qGlossaryId);
const p = availableProviders.find((ap) => ap.id === provider);
if (!p || p.mode !== 'llm') {
const firstLlm = availableProviders.find((ap) => ap.mode === 'llm');
if (firstLlm) {
setProvider(firstLlm.id);
}
}
}
}, [isLoadingProviders, availableProviders, provider]);
// Fetch available (admin-configured) providers // Fetch available (admin-configured) providers
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();