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
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m31s
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
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 { API_BASE } from '@/lib/config';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -28,12 +28,13 @@ interface GlossarySelectorProps {
|
|||||||
sourceLang: string;
|
sourceLang: string;
|
||||||
targetLang: string;
|
targetLang: string;
|
||||||
isPro: boolean;
|
isPro: boolean;
|
||||||
|
mode: string;
|
||||||
glossaryId: string | null;
|
glossaryId: string | null;
|
||||||
onChange: (id: string | null) => void;
|
onChange: (id: string | null) => void;
|
||||||
disabled?: boolean;
|
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 { t } = useI18n();
|
||||||
const [glossaries, setGlossaries] = useState<GlossaryOption[]>([]);
|
const [glossaries, setGlossaries] = useState<GlossaryOption[]>([]);
|
||||||
const [templates, setTemplates] = useState<TemplateOption[]>([]);
|
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 filteredGlossaries = langFiltered.length > 0 ? langFiltered : glossaries;
|
||||||
const selected = glossaries.find(g => g.id === glossaryId);
|
const selected = glossaries.find(g => g.id === glossaryId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 text-left" ref={containerRef}>
|
<div
|
||||||
{/* Header with Switch */}
|
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"
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-black/5 dark:border-white/5">
|
ref={containerRef}
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
>
|
||||||
<BookText size={13} className="text-brand-accent shrink-0" />
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark dark:text-white truncate">
|
<div className="flex items-center gap-2">
|
||||||
{t('translate.glossary.title') || 'Glossaire personnel'}
|
<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>
|
||||||
<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})
|
({sourceFlag || 'AUTO'}➔{targetFlag})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPro && (
|
{/* Active switch slider */}
|
||||||
<Switch
|
{isPro && mode === 'llm' && (
|
||||||
checked={isGlossaryEnabled}
|
<button
|
||||||
onCheckedChange={(checked) => {
|
type="button"
|
||||||
setIsGlossaryEnabled(checked);
|
disabled={disabled}
|
||||||
if (!checked) {
|
onClick={() => {
|
||||||
|
const nextVal = !isGlossaryEnabled;
|
||||||
|
setIsGlossaryEnabled(nextVal);
|
||||||
|
if (!nextVal) {
|
||||||
onChange(null);
|
onChange(null);
|
||||||
|
} else if (filteredGlossaries.length > 0 && !glossaryId) {
|
||||||
|
onChange(filteredGlossaries[0].id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
className={cn(
|
||||||
aria-label="Activer le glossaire"
|
"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>
|
</div>
|
||||||
|
|
||||||
{/* Pro limitation message */}
|
{mode === 'classic' ? (
|
||||||
{!isPro && (
|
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center">
|
||||||
<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">
|
<span className="text-[7.5px] font-black uppercase opacity-45 block">
|
||||||
<p className="text-[10px] text-brand-dark/50 dark:text-white/40 leading-relaxed font-light">
|
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.'}
|
{t('translate.glossary.proOnly') || 'Passez Pro pour appliquer vos glossaires terminologiques.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : isGlossaryEnabled ? (
|
||||||
|
<div className="space-y-3 animate-fade-in">
|
||||||
{/* Enabled glossary selector details */}
|
|
||||||
{isPro && isGlossaryEnabled && (
|
{/* Select Glossary Trigger button */}
|
||||||
<div className="space-y-3.5">
|
|
||||||
{/* Dropdown trigger */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-4 py-3 rounded-2xl text-left flex items-center gap-3 transition-all border-2",
|
"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
|
isOpen && "border-brand-accent dark:border-brand-accent",
|
||||||
? "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",
|
|
||||||
disabled && "opacity-50 cursor-not-allowed"
|
disabled && "opacity-50 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selected ? (
|
<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">
|
||||||
<div className="w-5 h-5 rounded-full bg-brand-accent flex items-center justify-center shrink-0">
|
{selected ? selected.name : (isLoading ? "Chargement..." : "Sélectionner un glossaire...")}
|
||||||
<Check size={11} className="text-white" />
|
</span>
|
||||||
</div>
|
<span className="text-[7px] font-extrabold uppercase tracking-wider text-brand-dark/40 dark:text-white/40 block mt-0.5">
|
||||||
<div className="min-w-0 flex-1">
|
{selected
|
||||||
<div className="text-[11px] font-bold truncate text-brand-accent leading-tight">{selected.name}</div>
|
? `${SUPPORTED_LANGUAGES.find(l => l.code === selected.source_language)?.flag || '🌐'} ➜ ${targetFlag} • ${selected.terms_count} termes`
|
||||||
<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>
|
: (filteredGlossaries.length > 0 ? "Aucun glossaire sélectionné" : "Aucun glossaire disponible")
|
||||||
</div>
|
}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
</div>
|
||||||
onClick={(e) => { e.stopPropagation(); onChange(null); }}
|
<ChevronDown size={11} className={cn("text-brand-dark/30 dark:text-white/30 shrink-0 transition-transform duration-200", isOpen && "rotate-180")} />
|
||||||
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")} />
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Error panel */}
|
{/* Error message */}
|
||||||
{error && (
|
{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 && (
|
{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">
|
<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">
|
||||||
{/* Glossary list */}
|
{filteredGlossaries.length > 0 ? (
|
||||||
<div className="max-h-48 overflow-y-auto divide-y divide-brand-dark/5 dark:divide-white/5">
|
filteredGlossaries.map(g => {
|
||||||
{filteredGlossaries.length > 0 ? (
|
const flag = SUPPORTED_LANGUAGES.find(l => l.code === g.source_language)?.flag ?? '';
|
||||||
<div className="py-1">
|
const isSelected = g.id === glossaryId;
|
||||||
{filteredGlossaries.map(g => {
|
return (
|
||||||
const flag = SUPPORTED_LANGUAGES.find(l => l.code === g.source_language)?.flag ?? '';
|
<button
|
||||||
const isSelected = g.id === glossaryId;
|
key={g.id}
|
||||||
return (
|
type="button"
|
||||||
<button
|
onClick={() => {
|
||||||
key={g.id}
|
onChange(isSelected ? null : g.id);
|
||||||
onClick={() => { onChange(isSelected ? null : g.id); if (!isSelected) setIsOpen(false); }}
|
setIsOpen(false);
|
||||||
className={cn(
|
}}
|
||||||
"w-full px-4 py-2.5 text-left flex items-center gap-3 transition-colors",
|
className={cn(
|
||||||
isSelected ? "bg-brand-accent/5" : "hover:bg-brand-muted/40 dark:hover:bg-white/5"
|
"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'
|
||||||
{isSelected ? (
|
: 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70'
|
||||||
<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>
|
<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-[10.5px] font-mono shrink-0">{flag || '🌐'}</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
|
||||||
<div className="min-w-0 flex-1">
|
</span>
|
||||||
<div className={cn("text-[11px] font-bold truncate", isSelected ? "text-brand-accent" : "text-brand-dark/75 dark:text-white/75")}>
|
</div>
|
||||||
{g.name}
|
{isSelected && <Check size={10} className="text-brand-accent shrink-0" />}
|
||||||
</div>
|
</button>
|
||||||
</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>
|
) : (
|
||||||
);
|
<p className="px-3 py-4 text-[8px] text-brand-dark/40 dark:text-white/30 italic text-center font-light">
|
||||||
})}
|
{sourceLang !== 'auto'
|
||||||
</div>
|
? `Aucun glossaire pour ${sourceFlag}➔${targetFlag}`
|
||||||
) : (
|
: "Aucun glossaire disponible"
|
||||||
<p className="px-4 py-4 text-[10px] text-brand-dark/40 dark:text-white/30 italic text-center font-light">
|
}
|
||||||
{sourceLang !== 'auto'
|
</p>
|
||||||
? `${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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Glossary detail section (Preview & Add inline) */}
|
{/* Dynamic Terms Preview block */}
|
||||||
{selected && (
|
{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">
|
<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">
|
||||||
{/* Preview header */}
|
<div className="flex justify-between items-center border-b border-black/[0.02] dark:border-white/[0.02] pb-1">
|
||||||
<div className="flex items-center justify-between text-[9px] font-bold uppercase tracking-wider text-brand-dark/45 dark:text-white/40">
|
<span className="text-[7.5px] font-black uppercase tracking-wider text-brand-dark/30 dark:text-white/30 block">
|
||||||
<span>Aperçu des termes</span>
|
Aperçu des correspondances actives :
|
||||||
<span>{selectedGlossaryDetail?.terms?.length || selected.terms_count} au total</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic scrollable terms preview */}
|
{isLoadingDetail ? (
|
||||||
<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]">
|
<div className="flex items-center justify-center py-3 gap-1.5 text-[8px] text-brand-dark/40 dark:text-white/30 font-light">
|
||||||
{isLoadingDetail ? (
|
<Loader2 size={10} className="animate-spin text-brand-accent" /> Chargement...
|
||||||
<div className="flex items-center justify-center py-4 gap-2 text-[10px] text-brand-dark/40 dark:text-white/30 font-light">
|
</div>
|
||||||
<Loader2 size={11} className="animate-spin text-brand-accent" /> Chargement des termes...
|
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
|
||||||
</div>
|
<div className="grid grid-cols-1 gap-1 max-h-[105px] overflow-y-auto pr-1">
|
||||||
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
|
{selectedGlossaryDetail.terms.slice(0, 4).map((t: any, i: number) => (
|
||||||
selectedGlossaryDetail.terms.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]">
|
||||||
<div key={t.id || i} className="flex justify-between items-center py-1.5 text-[10px]">
|
<span className="font-extrabold text-brand-dark/75 dark:text-white/70 truncate max-w-[220px]">
|
||||||
<span className="font-semibold text-brand-dark/75 dark:text-white/70 truncate pr-2 max-w-[110px]" title={t.source}>
|
{t.source} ➔ {t.target}
|
||||||
{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}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="inline-block w-1.5 h-1.5 bg-brand-accent rounded-full shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
) : (
|
{selectedGlossaryDetail.terms.length > 4 && (
|
||||||
<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>
|
<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
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
{/* 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} />
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
</form>
|
) : (
|
||||||
|
<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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -525,40 +525,48 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Glossary — Pro + LLM mode only */}
|
{/* Glossary selector */}
|
||||||
{config.isPro && config.mode === 'llm' && (
|
<GlossarySelector
|
||||||
<GlossarySelector
|
sourceLang={config.sourceLang}
|
||||||
sourceLang={config.sourceLang}
|
targetLang={config.targetLang}
|
||||||
targetLang={config.targetLang}
|
isPro={config.isPro}
|
||||||
isPro={config.isPro}
|
mode={config.mode}
|
||||||
glossaryId={config.glossaryId}
|
glossaryId={config.glossaryId}
|
||||||
onChange={config.setGlossaryId}
|
onChange={config.setGlossaryId}
|
||||||
disabled={submit.isSubmitting}
|
disabled={submit.isSubmitting}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Translate Images — Office files and LLM mode only */}
|
{/* Translate Images */}
|
||||||
{!isPdf && config.mode === 'llm' && (
|
<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 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 justify-between items-center">
|
||||||
<div className="flex gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-2">
|
||||||
<ImageIcon className="size-4 shrink-0 text-brand-accent mt-0.5" />
|
<ImageIcon className="size-3.5 text-brand-accent shrink-0" />
|
||||||
<div className="flex flex-col text-left">
|
<span className="text-[9px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white">
|
{t('dashboard.translate.translateImages') || "Traduire les images"}
|
||||||
{t('dashboard.translate.translateImages') || 'Traduire les images'}
|
</span>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.translateImages}
|
checked={config.translateImages && config.mode === 'llm'}
|
||||||
onCheckedChange={config.setTranslateImages}
|
onCheckedChange={config.setTranslateImages}
|
||||||
disabled={submit.isSubmitting}
|
disabled={submit.isSubmitting || config.mode === 'classic'}
|
||||||
aria-label={t('dashboard.translate.translateImages')}
|
aria-label={t('dashboard.translate.translateImages') || "Traduire les images"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* PDF mode selector */}
|
||||||
{isPdf && (
|
{isPdf && (
|
||||||
|
|||||||
@@ -1,28 +1,19 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { baseNavItems, proNavItem, getNavItems } from '../app/dashboard/constants';
|
import { baseNavItems } 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
describe('baseNavItems', () => {
|
||||||
it('should have correct structure for base items', () => {
|
it('should have correct structure for base items', () => {
|
||||||
baseNavItems.forEach(item => {
|
baseNavItems.forEach(item => {
|
||||||
expect(item).toHaveProperty('label');
|
expect(item).toHaveProperty('labelKey');
|
||||||
expect(item).toHaveProperty('href');
|
expect(item).toHaveProperty('href');
|
||||||
expect(item).toHaveProperty('icon');
|
expect(item).toHaveProperty('icon');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have proOnly flag on proNavItem', () => {
|
it('should flag glossaries and apiKeys as proOnly', () => {
|
||||||
expect(proNavItem.proOnly).toBe(true);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user