All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s
Nav: teams/settings/services back out of the nav until product-ready (user decision); teams page was already half-finished (UUID member display is a backend limitation). Payment: explicit confirmation dialog (plan, amount, billing period, Stripe note) before any redirect; ?plan= URL now pre-opens the dialog instead of triggering a silent checkout. Contrast AA sweep (71 replacements): functional micro-labels raised to >=60-65% opacity and >=10px across sidebar, header, pricing, landing; decorative all-caps 'interface' label removed. Design unification: new PageHeader component (accent pill + serif base/accent title) applied to settings, services, reviews, teams; pricing header returned to the editorial voice (serif + accent pill); dead GlossaryCard deleted. i18n residuals: suggestion chips, 'Standard' provider label, notification close, model-combobox strings extracted (+broken bg-surface/border-border- subtle tokens fixed); ~45 new keys EN+FR; 13 dead keys removed; zero missing keys verified. Reviews: icon-only row actions now carry visible text; 'Approve all' is two-step armed-confirm. Accelerators: Ctrl/Cmd+Enter submits; arrow-key navigation in the language combobox; recent-jobs history cap 8->20 with per-job download; source=target config rejected; empty 'Master Quality' badge removed. Cookie consent: reopenable via footer link, emoji replaced with drawn icon. Verified: build exit 0, vitest 9/9, eslint 64 errors = previous level, 0 missing i18n keys.
444 lines
24 KiB
TypeScript
444 lines
24 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
Library, Calendar, Hash, MessageSquare, Save, Trash2, Loader2,
|
|
CheckCircle2, AlertCircle, ArrowRight, Info, Search, Plus,
|
|
Sparkles, Zap
|
|
} from 'lucide-react';
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/navigation';
|
|
import { cn } from '@/lib/utils';
|
|
import { useUser } from '@/app/dashboard/useUser';
|
|
import { useI18n, formatDate } from '@/lib/i18n';
|
|
import { useGlossaries } from './useGlossaries';
|
|
import type { GlossaryListItem } from './types';
|
|
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
|
import { useToast } from '@/components/ui/toast';
|
|
import { SUPPORTED_LANGUAGES } from './types';
|
|
import { useTranslationStore } from '@/lib/store';
|
|
|
|
// ── Chips de suggestions pour les consignes de contexte ─────────────────────
|
|
// Prompt bodies stay in French (LLM content); labels translate with the UI.
|
|
const CONTEXT_SUGGESTIONS: { labelKey: string; value: string }[] = [
|
|
{ labelKey: 'glossaries.suggestion.formal', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' },
|
|
{ labelKey: 'glossaries.suggestion.proprietary', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' },
|
|
{ labelKey: 'glossaries.suggestion.numbers', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' },
|
|
{ labelKey: 'glossaries.suggestion.placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' },
|
|
{ labelKey: 'glossaries.suggestion.technical', value: 'Conserve les termes techniques en langue originale et ne les traduis pas.' },
|
|
{ labelKey: 'glossaries.suggestion.concise', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
|
|
];
|
|
|
|
export default function GlossariesPage() {
|
|
const { t, locale } = useI18n();
|
|
const router = useRouter();
|
|
const { data: user, isLoading: isLoadingUser } = useUser();
|
|
const { glossaries, isLoading: isLoadingGlossaries } = useGlossaries();
|
|
const { toast } = useToast();
|
|
const { settings, updateSettings } = useTranslationStore();
|
|
|
|
const [systemPrompt, setSystemPrompt] = useState(settings.systemPrompt);
|
|
const [isSavingPrompt, setIsSavingPrompt] = useState(false);
|
|
const [promptSaved, setPromptSaved] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [activeTab, setActiveTab] = useState<'glossaries' | 'context'>('glossaries');
|
|
|
|
// Same tier logic as the sidebar: business/enterprise are Pro-grade plans.
|
|
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
|
|
const isLoading = isLoadingUser || isLoadingGlossaries;
|
|
|
|
const currentTargetLang = settings.defaultTargetLanguage;
|
|
const currentTargetInfo = SUPPORTED_LANGUAGES.find(l => l.code === currentTargetLang);
|
|
|
|
const promptHasUnsavedChanges = systemPrompt !== settings.systemPrompt;
|
|
const promptIsActive = !!settings.systemPrompt?.trim();
|
|
|
|
useEffect(() => {
|
|
setSystemPrompt(settings.systemPrompt);
|
|
setPromptSaved(false);
|
|
}, [settings.systemPrompt]);
|
|
|
|
const handleSavePrompt = async () => {
|
|
setIsSavingPrompt(true);
|
|
try {
|
|
updateSettings({ systemPrompt });
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
setPromptSaved(true);
|
|
toast({ title: t('context.saved'), description: t('context.savedDesc') });
|
|
setTimeout(() => setPromptSaved(false), 3000);
|
|
} finally {
|
|
setIsSavingPrompt(false);
|
|
}
|
|
};
|
|
|
|
const handleClearPrompt = () => {
|
|
updateSettings({ systemPrompt: '' });
|
|
setSystemPrompt('');
|
|
};
|
|
|
|
const handleAddSuggestion = (value: string) => {
|
|
const current = systemPrompt.trim();
|
|
const newPrompt = current ? `${current}\n${value}` : value;
|
|
setSystemPrompt(newPrompt);
|
|
};
|
|
|
|
const filteredGlossaries = useMemo(() => {
|
|
if (!searchQuery.trim()) return glossaries;
|
|
const q = searchQuery.toLowerCase();
|
|
return glossaries.filter((g) => g.name.toLowerCase().includes(q));
|
|
}, [glossaries, searchQuery]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-center space-y-4">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto"></div>
|
|
<p className="text-xs text-[#555555] dark:text-white/40 font-light">{t('glossaries.loading')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isPro) {
|
|
return <ProUpgradePrompt />;
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto w-full p-6 lg:p-8">
|
|
|
|
{/* ── Header ────────────────────────────────────────────────── */}
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end mb-10 gap-6">
|
|
<div>
|
|
<span className="accent-pill mb-3 block w-fit font-medium text-[10px] uppercase tracking-widest">
|
|
{t('glossaries.yourGlossaries') || 'Vos Glossaires'}
|
|
</span>
|
|
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
|
{t('glossaries.titleBase')} <span className="italic">{t('glossaries.titleAccent')}</span>
|
|
</h1>
|
|
<p className="text-[#555555] dark:text-white/60 text-sm font-light leading-relaxed">
|
|
{t('glossaries.description') || 'Gérez vos glossaires et instructions de contexte pour des traductions plus précises.'}
|
|
</p>
|
|
</div>
|
|
{/* Bouton principal — unique point d'entrée pour créer */}
|
|
<Link
|
|
href="/dashboard/glossaries/new"
|
|
className="shrink-0 flex items-center gap-2 px-5 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all"
|
|
>
|
|
<Plus size={14} />
|
|
{t('glossaries.new')}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* ── Bandeau informatif ────────────────────────────────────── */}
|
|
<div className="mb-8 flex items-center gap-3 p-4 rounded-xl bg-[#F5F0EA] dark:bg-brand-accent/10 border border-[#D4B896] dark:border-brand-accent/20">
|
|
<div className="w-7 h-7 rounded-full bg-[#8B6F47] text-white flex items-center justify-center shrink-0">
|
|
<Info size={13} />
|
|
</div>
|
|
<p className="text-[11px] text-[#2D2D2D] dark:text-white/70 font-medium leading-relaxed flex-1">
|
|
<strong className="font-bold text-[#1A1A1A] dark:text-white">{t('glossaries.info.title')}</strong>{' '}
|
|
{t('glossaries.info.body')}
|
|
</p>
|
|
<Link
|
|
href="/dashboard/translate"
|
|
className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-[#7A5C35] dark:text-brand-accent hover:underline whitespace-nowrap"
|
|
>
|
|
{t('glossaries.info.cta')} <ArrowRight size={11} />
|
|
</Link>
|
|
</div>
|
|
|
|
{/* ── Onglets ───────────────────────────────────────────────── */}
|
|
<div className="flex border-b border-black/10 dark:border-white/10 mb-8">
|
|
<button
|
|
onClick={() => setActiveTab('glossaries')}
|
|
aria-selected={activeTab === 'glossaries'}
|
|
role="tab"
|
|
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-[#1A1A1A] dark:text-white'
|
|
: 'border-transparent text-[#555555] dark:text-white/40 hover:text-[#1A1A1A] dark:hover:text-white/80'
|
|
)}
|
|
>
|
|
{t('glossaries.tabs.glossaries') || 'Glossaires terminologiques'}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('context')}
|
|
aria-selected={activeTab === 'context'}
|
|
role="tab"
|
|
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-[#1A1A1A] dark:text-white'
|
|
: 'border-transparent text-[#555555] dark:text-white/40 hover:text-[#1A1A1A] dark:hover:text-white/80'
|
|
)}
|
|
>
|
|
{t('glossaries.tabs.context') || 'Consignes de contexte (IA)'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-10">
|
|
{activeTab === 'context' ? (
|
|
|
|
/* ── Onglet Consignes de Contexte ─────────────────────── */
|
|
<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">
|
|
|
|
{/* En-tête section */}
|
|
<div className="flex items-start justify-between mb-6 gap-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center text-[#8B6F47] shrink-0">
|
|
<MessageSquare size={18} />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-base font-serif font-semibold text-[#1A1A1A] dark:text-white tracking-tight">
|
|
{t('glossaries.context.title')}
|
|
</h2>
|
|
<p className="text-[11px] text-[#555555] dark:text-white/50 font-light mt-0.5">
|
|
{t('glossaries.context.desc')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/* Badge d'état */}
|
|
{promptHasUnsavedChanges ? (
|
|
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-700 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10 px-3 py-1.5 rounded-full border border-amber-300 dark:border-amber-500/20">
|
|
<AlertCircle size={11} />
|
|
{t('glossaries.context.unsaved')}
|
|
</span>
|
|
) : promptIsActive ? (
|
|
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10 px-3 py-1.5 rounded-full border border-emerald-300 dark:border-emerald-500/20">
|
|
<CheckCircle2 size={11} />
|
|
{t('glossaries.context.active')}
|
|
</span>
|
|
) : (
|
|
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-[#555555] bg-[#EBEBEB] dark:text-white/30 dark:bg-white/5 px-3 py-1.5 rounded-full border border-[#CCCCCC] dark:border-white/10">
|
|
{t('glossaries.context.inactive')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Chips de suggestions rapides */}
|
|
<div className="mb-5">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Sparkles size={13} className="text-[#8B6F47]" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-[#555555] dark:text-white/40">
|
|
{t('glossaries.context.suggestions')}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{CONTEXT_SUGGESTIONS.map((s) => (
|
|
<button
|
|
key={s.labelKey}
|
|
onClick={() => handleAddSuggestion(s.value)}
|
|
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-[#3D3D3D] dark:text-white/70 bg-[#F0EDE8] dark:bg-white/5 border border-[#D9D5CE] dark:border-white/10 hover:bg-[#E8E2DA] hover:border-[#C5A17A] dark:hover:bg-brand-accent/10 dark:hover:border-brand-accent/30 transition-all cursor-pointer"
|
|
>
|
|
+ {t(s.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Zone de texte */}
|
|
<textarea
|
|
value={systemPrompt}
|
|
onChange={e => { setSystemPrompt(e.target.value); setPromptSaved(false); }}
|
|
placeholder={t('glossaries.context.placeholder')}
|
|
aria-label={t('glossaries.context.title')}
|
|
className="w-full h-44 p-4 bg-[#FAFAF8] dark:bg-white/[0.02] rounded-xl border border-[#D9D6D0] dark:border-white/10 text-[#1A1A1A] dark:text-white text-xs leading-relaxed focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50 transition-all outline-none resize-y placeholder:text-[#AAAAAA] dark:placeholder:text-white/25"
|
|
/>
|
|
|
|
{/* Pied de section */}
|
|
<div className="flex justify-between items-center mt-4">
|
|
<p className="text-[10px] text-[#777777] dark:text-white/25 font-light">
|
|
{systemPrompt.length > 0
|
|
? t('glossaries.context.chars', { count: systemPrompt.length })
|
|
: t('glossaries.context.noPrompt')}
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleClearPrompt}
|
|
disabled={!systemPrompt}
|
|
className="px-5 py-2.5 bg-[#EBEBEB] dark:bg-white/5 text-[#444444] dark:text-white/40 rounded-lg text-xs font-bold uppercase tracking-wider hover:text-[#1A1A1A] hover:bg-[#E0E0E0] dark:hover:text-white transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<Trash2 size={12} className="inline mr-1.5" />
|
|
{t('glossaries.context.clear')}
|
|
</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.context.saving')}</>
|
|
: promptSaved
|
|
? <><CheckCircle2 size={14} /> {t('glossaries.context.saved')}</>
|
|
: <><Save size={14} /> {t('glossaries.context.save')}</>
|
|
}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
) : (
|
|
|
|
/* ── Onglet Glossaires ──────────────────────────────────── */
|
|
<div className="space-y-6 animate-fade-in">
|
|
|
|
{/* En-tête de section + compteur */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-lg font-serif font-medium text-[#1A1A1A] dark:text-white tracking-tight">
|
|
{t('glossaries.grid.title') || 'Vos'}{' '}
|
|
<span className="italic">{t('glossaries.grid.titleHighlight') || 'glossaires'}</span>
|
|
</h2>
|
|
<p className="text-[11px] text-[#555555] dark:text-white/40 font-light mt-1">
|
|
{glossaries.length > 0
|
|
? t('glossaries.countMany', { count: glossaries.length })
|
|
: t('glossaries.countNone')}
|
|
</p>
|
|
</div>
|
|
{currentTargetInfo && (
|
|
<span className="flex items-center gap-1.5 text-[10px] font-bold text-[#444444] dark:text-white/40 bg-[#EBEBEB] dark:bg-white/5 border border-[#CCCCCC] dark:border-white/5 px-3 py-1.5 rounded-full shrink-0">
|
|
<span>{t('glossaries.activeTarget')}</span>
|
|
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Barre de recherche (si > 3 glossaires) */}
|
|
{glossaries.length > 3 && (
|
|
<div className="relative">
|
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#888888] dark:text-white/30" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
placeholder={t('glossaries.searchPlaceholder')}
|
|
aria-label={t('glossaries.searchPlaceholder')}
|
|
className="w-full pl-9 pr-3 py-2.5 text-xs rounded-lg border border-[#D9D6D0] dark:border-white/10 bg-white dark:bg-[#141414] text-[#1A1A1A] dark:text-white placeholder:text-[#AAAAAA] dark:placeholder:text-white/25 focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* État vide */}
|
|
{glossaries.length === 0 ? (
|
|
<div className="editorial-card p-16 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
|
|
<div className="w-14 h-14 bg-[#F0EDE8] dark:bg-white/10 rounded-2xl flex items-center justify-center text-[#8B6F47] mx-auto mb-5">
|
|
<Library size={28} />
|
|
</div>
|
|
<p className="text-base font-serif font-semibold text-[#1A1A1A] dark:text-white mb-2">
|
|
{t('glossaries.empty') || 'Aucun glossaire'}
|
|
</p>
|
|
<p className="text-sm text-[#555555] dark:text-white/40 font-light mb-6 max-w-xs mx-auto">
|
|
{t('glossaries.emptyDesc') || 'Créez votre premier glossaire pour garantir une terminologie cohérente dans vos traductions.'}
|
|
</p>
|
|
<Link
|
|
href="/dashboard/glossaries/new"
|
|
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all"
|
|
>
|
|
<Plus size={12} />
|
|
{t('glossaries.createFirst')}
|
|
</Link>
|
|
</div>
|
|
) : filteredGlossaries.length === 0 ? (
|
|
<div className="p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl text-center">
|
|
<p className="text-sm text-[#555555] dark:text-white/40 font-light">
|
|
{t('glossaries.noResults', { query: searchQuery })}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
{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(
|
|
'bg-white dark:bg-[#141414] border rounded-2xl p-6 shadow-sm group transition-all relative flex flex-col justify-between min-h-[200px]',
|
|
matchesTarget
|
|
? 'border-brand-accent/50 ring-1 ring-brand-accent/20 hover:border-brand-accent/70'
|
|
: mismatch
|
|
? 'border-amber-300/50 dark:border-amber-500/20 hover:border-amber-400/70 opacity-80 hover:opacity-100'
|
|
: 'border-[#E5E3DF] dark:border-white/5 hover:border-[#C5A17A]/40'
|
|
)}
|
|
>
|
|
{/* Badge compatible / autre langue */}
|
|
{matchesTarget && (
|
|
<div className="absolute top-3 right-3 flex items-center gap-1 bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider border border-emerald-300 dark:border-emerald-500/20">
|
|
<CheckCircle2 size={9} /> {t('glossaries.badge.compatible')}
|
|
</div>
|
|
)}
|
|
{mismatch && (
|
|
<div className="absolute top-3 right-3 flex items-center gap-1 bg-amber-100 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider border border-amber-300 dark:border-amber-500/20">
|
|
<AlertCircle size={9} /> {t('glossaries.badge.otherLang')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Corps cliquable → page de détail */}
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}
|
|
className="cursor-pointer flex-1 text-start"
|
|
aria-label={`${t('glossaries.card.edit')} — ${glossary.name}`}
|
|
>
|
|
<div className="flex items-start gap-3 mb-4">
|
|
<div className="w-10 h-10 bg-[#F0EDE8] dark:bg-white/10 rounded-xl flex items-center justify-center text-[#8B6F47] 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-[#1A1A1A] dark:text-white tracking-tight leading-snug line-clamp-2 group-hover:text-[#8B6F47] transition-colors">
|
|
{glossary.name}
|
|
</h3>
|
|
<p className="text-[11px] text-[#555555] dark:text-white/50 font-medium flex items-center gap-1 mt-0.5">
|
|
<span>{srcInfo?.flag ?? '🌐'}</span>
|
|
<span>{srcInfo?.label ?? glossary.source_language}</span>
|
|
<span className="text-[#7A5C35] dark: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">
|
|
<span className="flex items-center gap-1 text-xs font-semibold text-[#333333] dark:text-white/65">
|
|
<Hash size={12} className="text-[#8B6F47] dark:text-brand-accent" />
|
|
{termCount > 1 ? t('glossaries.termsMany', { count: termCount }) : t('glossaries.termsOne')}
|
|
</span>
|
|
<span className="flex items-center gap-1 font-mono text-[10px] text-[#666666] dark:text-white/40">
|
|
<Calendar size={11} />
|
|
{formatDate(new Date(glossary.created_at), locale)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 pt-4 border-t border-[#F0EDE8] dark:border-white/10 mt-auto shrink-0">
|
|
<Link
|
|
href={`/dashboard/glossaries/${glossary.id}`}
|
|
className="flex-none px-3 py-2 rounded-lg bg-[#EBEBEB] dark:bg-white/5 hover:bg-[#E0D9D1] text-[#333333] dark:text-white/50 hover:text-[#1A1A1A] text-[10px] font-bold uppercase tracking-wider transition-all border border-[#CCCCCC] dark:border-white/10"
|
|
>
|
|
{t('glossaries.card.edit')}
|
|
</Link>
|
|
<Link
|
|
href={`/dashboard/translate?glossaryId=${glossary.id}`}
|
|
className="flex-1 text-center py-2 rounded-lg bg-[#1A1A1A] hover:bg-[#333333] text-white text-[10px] font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-1.5"
|
|
>
|
|
{t('glossaries.card.use')} <ArrowRight size={11} />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|