style(translate): redesign glossary and translate images card layout to match mockup compact styling
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m31s

This commit is contained in:
2026-05-31 10:58:22 +02:00
parent a5b18b5a24
commit cd8a57324d
3 changed files with 271 additions and 279 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { BookText, Plus, Loader2, Check, ChevronDown, X, Globe } from 'lucide-react';
import { BookOpen, Plus, Loader2, Check, ChevronDown, X, Globe, ChevronRight } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
@@ -28,12 +28,13 @@ interface GlossarySelectorProps {
sourceLang: string;
targetLang: string;
isPro: boolean;
mode: string;
glossaryId: string | null;
onChange: (id: string | null) => void;
disabled?: boolean;
}
export function GlossarySelector({ sourceLang, targetLang, isPro, glossaryId, onChange, disabled }: GlossarySelectorProps) {
export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossaryId, onChange, disabled }: GlossarySelectorProps) {
const { t } = useI18n();
const [glossaries, setGlossaries] = useState<GlossaryOption[]>([]);
const [templates, setTemplates] = useState<TemplateOption[]>([]);
@@ -227,279 +228,271 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, glossaryId, on
const filteredGlossaries = langFiltered.length > 0 ? langFiltered : glossaries;
const selected = glossaries.find(g => g.id === glossaryId);
return (
<div className="space-y-4 text-left" ref={containerRef}>
{/* Header with Switch */}
<div className="flex items-center justify-between pb-2 border-b border-black/5 dark:border-white/5">
<div className="flex items-center gap-1.5 min-w-0">
<BookText size={13} className="text-brand-accent shrink-0" />
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark dark:text-white truncate">
{t('translate.glossary.title') || 'Glossaire personnel'}
<div
className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3 text-left"
ref={containerRef}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<BookOpen size={12} className="text-brand-accent shrink-0" />
<span className="text-[9px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white truncate">
{t('translate.glossary.title') || 'Glossaire & Terminologie'}
</span>
<span className="text-[9px] text-brand-dark/40 dark:text-white/40 shrink-0 font-medium font-mono">
<span className="text-[7.5px] text-brand-dark/40 dark:text-white/40 shrink-0 font-medium font-mono">
({sourceFlag || 'AUTO'}{targetFlag})
</span>
</div>
{isPro && (
<Switch
checked={isGlossaryEnabled}
onCheckedChange={(checked) => {
setIsGlossaryEnabled(checked);
if (!checked) {
{/* Active switch slider */}
{isPro && mode === 'llm' && (
<button
type="button"
disabled={disabled}
onClick={() => {
const nextVal = !isGlossaryEnabled;
setIsGlossaryEnabled(nextVal);
if (!nextVal) {
onChange(null);
} else if (filteredGlossaries.length > 0 && !glossaryId) {
onChange(filteredGlossaries[0].id);
}
}}
disabled={disabled}
aria-label="Activer le glossaire"
/>
className={cn(
"w-8 h-4 rounded-full relative transition-colors",
isGlossaryEnabled ? 'bg-brand-accent' : 'bg-brand-dark/10 dark:bg-white/10',
disabled && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"w-3.5 h-3.5 bg-white rounded-full absolute top-0.5 shadow transition-all",
isGlossaryEnabled ? 'left-[13px]' : 'left-0.5'
)} />
</button>
)}
</div>
{/* Pro limitation message */}
{!isPro && (
<div className="p-3.5 rounded-2xl bg-brand-muted/20 border border-brand-dark/5 dark:bg-white/5 dark:border-white/5 text-center">
<p className="text-[10px] text-brand-dark/50 dark:text-white/40 leading-relaxed font-light">
{mode === 'classic' ? (
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center">
<span className="text-[7.5px] font-black uppercase opacity-45 block">
Moteur neutre sans glossaire (IA uniquement)
</span>
</div>
) : !isPro ? (
<div className="p-2.5 rounded-lg bg-brand-dark/5 dark:bg-white/5 text-center">
<p className="text-[7.5px] text-brand-dark/50 dark:text-white/45 leading-relaxed font-light">
{t('translate.glossary.proOnly') || 'Passez Pro pour appliquer vos glossaires terminologiques.'}
</p>
</div>
)}
{/* Enabled glossary selector details */}
{isPro && isGlossaryEnabled && (
<div className="space-y-3.5">
{/* Dropdown trigger */}
) : isGlossaryEnabled ? (
<div className="space-y-3 animate-fade-in">
{/* Select Glossary Trigger button */}
<div className="relative">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full px-4 py-3 rounded-2xl text-left flex items-center gap-3 transition-all border-2",
isOpen
? "border-brand-accent bg-brand-muted/20 dark:bg-zinc-800/40"
: "bg-white dark:bg-[#141414] border-brand-dark/10 dark:border-white/10 hover:border-brand-accent/20",
"w-full bg-white dark:bg-[#1a1a1a] border border-black/5 dark:border-white/5 hover:border-black/10 dark:hover:border-white/10 py-2 px-3 rounded-lg flex items-center justify-between shadow-sm transition-all",
isOpen && "border-brand-accent dark:border-brand-accent",
disabled && "opacity-50 cursor-not-allowed"
)}
>
{selected ? (
<>
<div className="w-5 h-5 rounded-full bg-brand-accent flex items-center justify-center shrink-0">
<Check size={11} className="text-white" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-bold truncate text-brand-accent leading-tight">{selected.name}</div>
<div className="text-[9px] text-brand-dark/50 dark:text-white/50 uppercase tracking-wider font-semibold mt-0.5">{selected.terms_count} {t('translate.glossary.terms') || 'termes'}</div>
</div>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onChange(null); }}
className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-brand-dark/45 hover:text-brand-dark hover:bg-brand-muted dark:text-white/45 dark:hover:text-white dark:hover:bg-white/10 transition-colors"
>
<X size={11} />
</button>
</>
) : (
<>
<Globe className="size-4 text-brand-dark/35 dark:text-white/30 shrink-0" />
<span className="text-[11px] text-brand-dark/45 dark:text-white/40 flex-1 leading-none font-medium">
{isLoading ? (
<span className="flex items-center gap-1.5"><Loader2 size={10} className="animate-spin" /> {t('translate.glossary.loading') || 'Chargement...'}</span>
) : filteredGlossaries.length > 0 ? (
t('translate.glossary.selectGlossary') || 'Sélectionner un glossaire…'
) : (
t('translate.glossary.noGlossaries') || 'Aucun glossaire disponible'
)}
</span>
</>
)}
<ChevronDown size={14} className={cn("shrink-0 text-brand-dark/35 dark:text-white/30 transition-transform duration-200", isOpen && "rotate-180")} />
<div className="text-left min-w-0 flex-1 pr-2">
<span className="text-[9px] font-black text-brand-dark dark:text-white tracking-tight block truncate">
{selected ? selected.name : (isLoading ? "Chargement..." : "Sélectionner un glossaire...")}
</span>
<span className="text-[7px] font-extrabold uppercase tracking-wider text-brand-dark/40 dark:text-white/40 block mt-0.5">
{selected
? `${SUPPORTED_LANGUAGES.find(l => l.code === selected.source_language)?.flag || '🌐'}${targetFlag}${selected.terms_count} termes`
: (filteredGlossaries.length > 0 ? "Aucun glossaire sélectionné" : "Aucun glossaire disponible")
}
</span>
</div>
<ChevronDown size={11} className={cn("text-brand-dark/30 dark:text-white/30 shrink-0 transition-transform duration-200", isOpen && "rotate-180")} />
</button>
{/* Error panel */}
{/* Error message */}
{error && (
<p className="text-[10px] text-red-500 pl-1 mt-1 font-medium">{error}</p>
<p className="text-[7.5px] text-red-500 pl-1 mt-1 font-medium">{error}</p>
)}
{/* Dropdown panel */}
{/* Selector Dropdown list */}
{isOpen && !disabled && (
<div className="absolute left-0 right-0 z-30 mt-1 bg-white dark:bg-[#1a1a1a] border border-brand-dark/10 dark:border-white/10 rounded-2xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
{/* Glossary list */}
<div className="max-h-48 overflow-y-auto divide-y divide-brand-dark/5 dark:divide-white/5">
{filteredGlossaries.length > 0 ? (
<div className="py-1">
{filteredGlossaries.map(g => {
const flag = SUPPORTED_LANGUAGES.find(l => l.code === g.source_language)?.flag ?? '';
const isSelected = g.id === glossaryId;
return (
<button
key={g.id}
onClick={() => { onChange(isSelected ? null : g.id); if (!isSelected) setIsOpen(false); }}
className={cn(
"w-full px-4 py-2.5 text-left flex items-center gap-3 transition-colors",
isSelected ? "bg-brand-accent/5" : "hover:bg-brand-muted/40 dark:hover:bg-white/5"
)}
>
{isSelected ? (
<div className="w-4 h-4 rounded-full bg-brand-accent flex items-center justify-center shrink-0">
<Check size={9} className="text-white" />
</div>
) : (
<span className="text-[10.5px] font-mono shrink-0">{flag || '🌐'}</span>
)}
<div className="min-w-0 flex-1">
<div className={cn("text-[11px] font-bold truncate", isSelected ? "text-brand-accent" : "text-brand-dark/75 dark:text-white/75")}>
{g.name}
</div>
</div>
<span className="text-[9px] text-brand-dark/45 dark:text-white/45 font-mono uppercase font-bold">{g.terms_count} {t('translate.glossary.terms') || 't'}</span>
</button>
);
})}
</div>
) : (
<p className="px-4 py-4 text-[10px] text-brand-dark/40 dark:text-white/30 italic text-center font-light">
{sourceLang !== 'auto'
? `${t('translate.glossary.noGlossaryForPair') || 'Aucun glossaire pour'} ${sourceFlag}${targetFlag}`
: (t('translate.glossary.noGlossaries') || 'Aucun glossaire')
}
</p>
)}
</div>
{/* Templates shortcut button */}
{templates.length > 0 && (
<div className="border-t border-brand-dark/5 dark:border-white/5 bg-brand-muted/10 dark:bg-white/[0.01]">
<button
type="button"
onClick={() => { setShowTemplates(!showTemplates); }}
className="w-full px-4 py-2.5 flex items-center gap-2 text-[9px] font-bold uppercase tracking-widest text-brand-dark/45 dark:text-white/40 hover:text-brand-accent transition-colors"
>
<Plus size={11} className="text-brand-accent" />
{t('translate.glossary.fromTemplate') || 'Créer depuis un template'}
<ChevronDown size={11} className={cn("ml-auto transition-transform duration-200", showTemplates && "rotate-180")} />
</button>
{showTemplates && (
<div className="border-t border-brand-dark/5 dark:border-white/5 p-2 grid grid-cols-2 gap-1.5 max-h-32 overflow-y-auto bg-brand-muted/20 dark:bg-transparent">
{templates.map(tmpl => {
const isImporting = importingId === tmpl.id;
const existingGlossary = glossaries.find(
g => g.name.toLowerCase().includes(tmpl.name.toLowerCase().split('/')[0].trim())
);
const isAlreadySelected = existingGlossary?.id === glossaryId;
return (
<button
key={tmpl.id}
onClick={() => {
if (existingGlossary) {
onChange(isAlreadySelected ? null : existingGlossary.id);
setIsOpen(false);
} else if (!isImporting) {
handleImportTemplate(tmpl);
}
}}
disabled={isImporting}
className={cn(
"px-2.5 py-1.5 rounded-xl text-left flex items-center gap-1.5 transition-all border",
isAlreadySelected
? "bg-brand-accent/15 border-brand-accent/25"
: "bg-white dark:bg-[#141414] border-brand-dark/5 dark:border-white/5 hover:border-brand-accent/20",
isImporting && "opacity-60"
)}
>
{isImporting ? (
<Loader2 size={10} className="text-brand-accent animate-spin shrink-0" />
) : isAlreadySelected ? (
<Check size={10} className="text-brand-accent shrink-0" />
) : (
<Plus size={10} className="text-brand-accent/50 shrink-0" />
)}
<span className={cn(
"text-[9px] font-semibold truncate",
isAlreadySelected ? "text-brand-accent" : "text-brand-dark/65 dark:text-white/60"
)}>
{tmpl.name.split('/')[0].trim()}
</span>
</button>
);
})}
</div>
)}
</div>
<div className="absolute top-[102%] left-0 right-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-1.5 z-40 max-h-48 overflow-y-auto animate-fade-in">
{filteredGlossaries.length > 0 ? (
filteredGlossaries.map(g => {
const flag = SUPPORTED_LANGUAGES.find(l => l.code === g.source_language)?.flag ?? '';
const isSelected = g.id === glossaryId;
return (
<button
key={g.id}
type="button"
onClick={() => {
onChange(isSelected ? null : g.id);
setIsOpen(false);
}}
className={cn(
"w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between",
isSelected
? 'bg-brand-accent/5 text-brand-accent'
: 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70'
)}
>
<div className="text-left min-w-0 flex-1 pr-2">
<span className="text-[9px] font-black block leading-none truncate">{g.name}</span>
<span className="text-[7.5px] uppercase tracking-wider text-brand-dark/40 dark:text-white/40 font-bold block mt-1">
{flag || '🌐'} {targetFlag} {g.terms_count} termes
</span>
</div>
{isSelected && <Check size={10} className="text-brand-accent shrink-0" />}
</button>
);
})
) : (
<p className="px-3 py-4 text-[8px] text-brand-dark/40 dark:text-white/30 italic text-center font-light">
{sourceLang !== 'auto'
? `Aucun glossaire pour ${sourceFlag}${targetFlag}`
: "Aucun glossaire disponible"
}
</p>
)}
</div>
)}
</div>
{/* Active Glossary detail section (Preview & Add inline) */}
{/* Dynamic Terms Preview block */}
{selected && (
<div className="p-3.5 rounded-2xl border border-brand-dark/10 dark:border-white/10 bg-brand-muted/20 dark:bg-white/[0.02] space-y-3.5">
{/* Preview header */}
<div className="flex items-center justify-between text-[9px] font-bold uppercase tracking-wider text-brand-dark/45 dark:text-white/40">
<span>Aperçu des termes</span>
<span>{selectedGlossaryDetail?.terms?.length || selected.terms_count} au total</span>
<div className="bg-white/80 dark:bg-[#1a1a1a]/40 rounded-lg p-2.5 border border-black/[0.02] dark:border-white/[0.02] space-y-2">
<div className="flex justify-between items-center border-b border-black/[0.02] dark:border-white/[0.02] pb-1">
<span className="text-[7.5px] font-black uppercase tracking-wider text-brand-dark/30 dark:text-white/30 block">
Aperçu des correspondances actives :
</span>
<span className="text-[7px] font-bold text-brand-dark/40 dark:text-white/40">
{selectedGlossaryDetail?.terms?.length || selected.terms_count} au total
</span>
</div>
{/* Dynamic scrollable terms preview */}
<div className="rounded-xl border border-brand-dark/5 dark:border-white/5 bg-white dark:bg-[#141414] max-h-28 overflow-y-auto px-3 py-1.5 divide-y divide-brand-dark/[0.03] dark:divide-white/[0.03]">
{isLoadingDetail ? (
<div className="flex items-center justify-center py-4 gap-2 text-[10px] text-brand-dark/40 dark:text-white/30 font-light">
<Loader2 size={11} className="animate-spin text-brand-accent" /> Chargement des termes...
</div>
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
selectedGlossaryDetail.terms.map((t: any, i: number) => (
<div key={t.id || i} className="flex justify-between items-center py-1.5 text-[10px]">
<span className="font-semibold text-brand-dark/75 dark:text-white/70 truncate pr-2 max-w-[110px]" title={t.source}>
{t.source}
</span>
<span className="text-[8px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest shrink-0"></span>
<span className="font-light text-brand-dark/65 dark:text-white/60 truncate pl-2 max-w-[110px]" title={t.target}>
{t.target}
{isLoadingDetail ? (
<div className="flex items-center justify-center py-3 gap-1.5 text-[8px] text-brand-dark/40 dark:text-white/30 font-light">
<Loader2 size={10} className="animate-spin text-brand-accent" /> Chargement...
</div>
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
<div className="grid grid-cols-1 gap-1 max-h-[105px] overflow-y-auto pr-1">
{selectedGlossaryDetail.terms.slice(0, 4).map((t: any, i: number) => (
<div key={t.id || i} className="flex justify-between items-center bg-brand-muted/30 dark:bg-white/5 px-2 py-1 rounded-md text-[8px]">
<span className="font-extrabold text-brand-dark/75 dark:text-white/70 truncate max-w-[220px]">
{t.source} {t.target}
</span>
<span className="inline-block w-1.5 h-1.5 bg-brand-accent rounded-full shrink-0" />
</div>
))
) : (
<p className="py-4 text-[10px] text-brand-dark/40 dark:text-white/30 italic text-center font-light">Aucun terme dans ce glossaire.</p>
)}
</div>
{/* Form to add a new term inline */}
<form onSubmit={handleAddTerm} className="pt-2 border-t border-brand-dark/5 dark:border-white/5 flex gap-2">
<input
type="text"
required
placeholder="Source..."
value={newSource}
onChange={(e) => setNewSource(e.target.value)}
disabled={isAddingTerm}
className="flex-1 min-w-0 bg-white dark:bg-[#141414] border border-brand-dark/10 dark:border-white/10 rounded-xl px-2.5 py-1.5 text-[10px] placeholder:text-brand-dark/30 dark:placeholder:text-white/30 focus:border-brand-accent focus:outline-none transition-colors"
/>
<input
type="text"
required
placeholder="Cible..."
value={newTarget}
onChange={(e) => setNewTarget(e.target.value)}
disabled={isAddingTerm}
className="flex-1 min-w-0 bg-white dark:bg-[#141414] border border-brand-dark/10 dark:border-white/10 rounded-xl px-2.5 py-1.5 text-[10px] placeholder:text-brand-dark/30 dark:placeholder:text-white/30 focus:border-brand-accent focus:outline-none transition-colors"
/>
<button
type="submit"
disabled={isAddingTerm || !newSource.trim() || !newTarget.trim()}
className="shrink-0 w-8 h-8 rounded-xl bg-brand-dark dark:bg-white text-white dark:text-brand-dark flex items-center justify-center hover:opacity-90 active:scale-[0.96] transition-all disabled:opacity-30 disabled:cursor-not-allowed shadow-sm"
title="Ajouter le terme au glossaire"
>
{isAddingTerm ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Plus size={14} />
))}
{selectedGlossaryDetail.terms.length > 4 && (
<span className="text-[7px] text-brand-dark/40 dark:text-white/40 block text-right font-medium mt-1">
+ {selectedGlossaryDetail.terms.length - 4} autres termes
</span>
)}
</button>
</form>
</div>
) : (
<p className="py-3 text-[8px] text-brand-dark/40 dark:text-white/30 italic text-center font-light">Aucun terme dans ce glossaire.</p>
)}
</div>
)}
{/* Ultra-neat Quick Term Adder */}
{selected && (
<form onSubmit={handleAddTerm} className="flex gap-1">
<input
type="text"
required
placeholder="Terme Source"
value={newSource}
onChange={(e) => setNewSource(e.target.value)}
disabled={isAddingTerm || disabled}
className="flex-1 bg-white dark:bg-[#1a1a1a] border border-black/5 dark:border-white/5 rounded-lg px-2 py-1 text-[8px] font-semibold text-brand-dark dark:text-white placeholder:text-brand-dark/30 outline-none focus:border-brand-accent"
/>
<input
type="text"
required
placeholder="Traduction"
value={newTarget}
onChange={(e) => setNewTarget(e.target.value)}
disabled={isAddingTerm || disabled}
className="flex-1 bg-white dark:bg-[#1a1a1a] border border-black/5 dark:border-white/5 rounded-lg px-2 py-1 text-[8px] font-semibold text-brand-dark dark:text-white placeholder:text-brand-dark/30 outline-none focus:border-brand-accent"
/>
<button
type="submit"
disabled={isAddingTerm || disabled || !newSource.trim() || !newTarget.trim()}
className="px-2.5 bg-brand-dark dark:bg-white text-white dark:text-brand-dark rounded-lg flex items-center justify-center disabled:opacity-35 transition-colors"
title="Ajouter le terme"
>
{isAddingTerm ? (
<Loader2 size={10} className="animate-spin" />
) : (
<Plus size={10} />
)}
</button>
</form>
)}
{/* Interactive Accordion for Templates Generator */}
{templates.length > 0 && (
<div className="border-t border-black/[0.03] dark:border-white/[0.03] pt-2 mt-1">
<button
type="button"
onClick={() => setShowTemplates(!showTemplates)}
className="w-full flex items-center justify-between text-[8px] font-black uppercase tracking-wider text-brand-dark/40 dark:text-white/40 hover:text-brand-dark dark:hover:text-white transition-colors"
>
<span> Créer ou Charger depuis un Template</span>
<ChevronRight size={10} className={cn("transform transition-transform text-brand-accent", showTemplates ? 'rotate-95' : '')} />
</button>
{showTemplates && (
<div className="grid grid-cols-2 gap-1 mt-2 p-1 bg-white/40 dark:bg-black/10 rounded-lg animate-fade-in max-h-32 overflow-y-auto">
{templates.map(tmpl => {
const isImporting = importingId === tmpl.id;
const existingGlossary = glossaries.find(
g => g.name.toLowerCase().includes(tmpl.name.toLowerCase().split('/')[0].trim())
);
const isAlreadySelected = existingGlossary?.id === glossaryId;
return (
<button
key={tmpl.id}
type="button"
disabled={isImporting || disabled}
onClick={() => {
if (existingGlossary) {
onChange(isAlreadySelected ? null : existingGlossary.id);
} else {
handleImportTemplate(tmpl);
}
}}
className={cn(
"px-2 py-1 rounded text-left transition-all",
isAlreadySelected
? 'bg-brand-accent/10 border-l-2 border-brand-accent'
: 'hover:bg-brand-muted dark:hover:bg-white/5'
)}
>
<span className="text-[7.5px] font-extrabold text-brand-dark dark:text-white block truncate leading-none">
{isImporting ? 'Importation...' : tmpl.name.split('/')[0].trim()}
</span>
<span className="text-[6px] tracking-wider font-bold text-brand-dark/30 dark:text-white/30 block leading-tight uppercase">
{tmpl.terms_count} termes
</span>
</button>
);
})}
</div>
)}
</div>
)}
</div>
) : (
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center animate-fade-in">
<span className="text-[7.5px] font-black uppercase opacity-40">Moteur neutre sans glossaire appliqué</span>
</div>
)}
</div>

View File

@@ -525,40 +525,48 @@ export default function TranslatePage() {
</div>
)}
{/* Glossary — Pro + LLM mode only */}
{config.isPro && config.mode === 'llm' && (
<GlossarySelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
isPro={config.isPro}
glossaryId={config.glossaryId}
onChange={config.setGlossaryId}
disabled={submit.isSubmitting}
/>
)}
{/* Glossary selector */}
<GlossarySelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
isPro={config.isPro}
mode={config.mode}
glossaryId={config.glossaryId}
onChange={config.setGlossaryId}
disabled={submit.isSubmitting}
/>
{/* Translate Images — Office files and LLM mode only */}
{!isPdf && config.mode === 'llm' && (
<div className="flex items-start justify-between rounded-2xl border border-black/5 dark:border-white/5 bg-brand-muted/30 dark:bg-white/5 p-4">
<div className="flex gap-3 min-w-0 flex-1">
<ImageIcon className="size-4 shrink-0 text-brand-accent mt-0.5" />
<div className="flex flex-col text-left">
<span className="text-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white">
{t('dashboard.translate.translateImages') || 'Traduire les images'}
</span>
<span className="text-[8px] text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest mt-1.5 leading-normal">
{t('dashboard.translate.translateImagesDesc') || 'Traduire les textes incrustés'}
</span>
</div>
{/* Translate Images */}
<div className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<ImageIcon className="size-3.5 text-brand-accent shrink-0" />
<span className="text-[9px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white">
{t('dashboard.translate.translateImages') || "Traduire les images"}
</span>
</div>
<Switch
checked={config.translateImages}
checked={config.translateImages && config.mode === 'llm'}
onCheckedChange={config.setTranslateImages}
disabled={submit.isSubmitting}
aria-label={t('dashboard.translate.translateImages')}
disabled={submit.isSubmitting || config.mode === 'classic'}
aria-label={t('dashboard.translate.translateImages') || "Traduire les images"}
/>
</div>
)}
{config.mode === 'classic' ? (
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center">
<span className="text-[7.5px] font-black uppercase opacity-45 block">
Indisponible en mode Standard (IA uniquement)
</span>
</div>
) : (
<div className="px-1">
<span className="text-[7.5px] font-medium text-brand-dark/50 dark:text-white/50 block leading-normal">
{t('dashboard.translate.translateImagesDesc') || "Détecter et traduire automatiquement les textes incrustés dans vos images."}
</span>
</div>
)}
</div>
{/* PDF mode selector */}
{isPdf && (

View File

@@ -1,28 +1,19 @@
import { describe, it, expect } from 'vitest';
import { baseNavItems, proNavItem, getNavItems } from '../app/dashboard/constants';
describe('getNavItems', () => {
it('should return only base items for free users', () => {
const items = getNavItems(false);
expect(items).toHaveLength(2);
expect(items).toEqual(baseNavItems);
});
it('should include pro item for pro users', () => {
const items = getNavItems(true);
expect(items).toHaveLength(3);
expect(items).toContain(proNavItem);
});
import { baseNavItems } from '../app/dashboard/constants';
describe('baseNavItems', () => {
it('should have correct structure for base items', () => {
baseNavItems.forEach(item => {
expect(item).toHaveProperty('label');
expect(item).toHaveProperty('labelKey');
expect(item).toHaveProperty('href');
expect(item).toHaveProperty('icon');
});
});
it('should have proOnly flag on proNavItem', () => {
expect(proNavItem.proOnly).toBe(true);
it('should flag glossaries and apiKeys as proOnly', () => {
const glossaries = baseNavItems.find(item => item.href === '/dashboard/glossaries');
const apiKeys = baseNavItems.find(item => item.href === '/dashboard/api-keys');
expect(glossaries?.proOnly).toBe(true);
expect(apiKeys?.proOnly).toBe(true);
});
});