fix(ui): design critique overhaul — a11y, honest metrics, i18n repair (critique 2026-08-30)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA combobox + listbox pattern for language selector, role=switch on glossary toggle, role=status live region on notifications, aria-labels on password toggles, visible-on-focus close buttons, RTL logical positioning (start-*). Trust: third-party Memento promo removed from translate page, sidebar and all 13 locales; fabricated stats (99.9%, Turbo, computed layout-integrity bars) replaced with real measurements incl. API estimated remaining time; silent download failure now surfaces an error notification. Honesty: fake 100-byte file injections removed (format chips are now informational); cancel-that-doesn't renamed 'Back to start' with hint; Enterprise contact placeholder replaced with contact@wordly.art. i18n: t() no longer returns raw keys (empty string + defaultValue support, ~30 dead || fallbacks now work); ~170 new keys EN+FR across new reviews/ teams namespaces, glossaries context tab, translate monitor, settings, services, pricing, landing, fileUploader; split-key italic titles replace lastIndexOf() surgery (zh/ja-safe); key-audit script added 0 missing. Flow: active job persisted across refresh with polling resume (24h TTL); client-side recent-jobs history with review links; review page linked from complete state; settings/services added to dashboard nav; Business/ Enterprise regain glossary access (tier gate unified). Typeset (sober-tool direction): 7.5-9px labels raised to 10-12px, /30 opacity to /45-/55, uppercase tracking reduced, trust footer legible, country flags removed from language switcher, localized dates. Cleanup: 5 orphaned translate components, dead site header/footer, fossil tailwind.config.js, PipelineStepper, duplicate pill+H1 titles, two-step confirm for cache clear, dead landing footer links. Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD (no regression, -3 warnings), detector 4 -> 3 findings.
This commit is contained in:
@@ -169,6 +169,8 @@ export function LoginForm() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? t('register.password.hide') : t('register.password.show')}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
|
||||
@@ -61,8 +61,8 @@ function PasswordToggleIcon({ visible, onToggle, label }: { visible: boolean; on
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors duration-200 pointer-events-auto"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-pressed={visible}
|
||||
>
|
||||
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -171,7 +171,7 @@ export function RegisterForm() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
|
||||
Wordly
|
||||
{t('auth.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -334,7 +334,6 @@ export function RegisterForm() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="premium"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={registerMutation.isPending}
|
||||
@@ -364,7 +363,7 @@ export function RegisterForm() {
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t('register.terms.prefix')}{' '}
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-muted-foreground/80">
|
||||
{t('register.terms.link')}
|
||||
</span>
|
||||
.
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DashboardSidebar } from './DashboardSidebar';
|
||||
import { DashboardHeader } from './DashboardHeader';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export function DashboardLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
@@ -25,8 +27,8 @@ export function DashboardLayoutClient({ children }: { children: React.ReactNode
|
||||
<div className="flex h-screen items-center justify-center bg-brand-bg dark:bg-[#0a0a0a]">
|
||||
<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-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
||||
Loading...
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50" role="status">
|
||||
{t('dashboard.loading') || 'Chargement…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ export function DashboardSidebar() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
|
||||
const isPaid = !!user?.tier && user.tier !== 'free';
|
||||
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
|
||||
|
||||
return (
|
||||
@@ -69,29 +68,6 @@ export function DashboardSidebar() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memento promo section — hidden for paying users */}
|
||||
{!isPaid && (
|
||||
<a
|
||||
href={t('memento.url', { defaultValue: 'https://memento-note.com/' })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-6 py-4"
|
||||
>
|
||||
<div className="bg-brand-muted/50 dark:bg-[#1f1f1f]/50 rounded-3xl p-6 border border-black/5 dark:border-white/5 group cursor-pointer hover:bg-brand-dark dark:hover:bg-brand-accent transition-all duration-500">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 bg-brand-dark group-hover:bg-brand-accent rounded-lg flex items-center justify-center text-white text-xs font-black shadow-sm transition-all group-hover:rotate-12">M</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-brand-dark group-hover:text-white dark:text-white transition-colors">{t('memento.title')}</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-brand-dark/40 group-hover:text-white/60 font-medium leading-relaxed mb-6">
|
||||
{t('memento.slogan').substring(0, 80)}...
|
||||
</p>
|
||||
<button className="w-full py-2.5 bg-brand-dark group-hover:bg-white text-white group-hover:text-brand-dark dark:bg-[#272727] rounded-xl text-[8px] font-black uppercase tracking-widest shadow-sm transition-all">
|
||||
{t('memento.ctaFree')}
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* User section */}
|
||||
<div className="border-t border-black/5 dark:border-white/5">
|
||||
{!isLoading && user && (
|
||||
@@ -110,7 +86,7 @@ export function DashboardSidebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="px-1.5 py-0 rounded-full border border-brand-accent/30 text-brand-accent text-[7px] font-black uppercase">
|
||||
<span className="px-2.5 py-0.5 rounded-full border border-brand-accent/30 text-brand-accent text-[10px] font-black uppercase">
|
||||
{translateTier(t, user.tier)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, BookText, User, Users, type LucideIcon } from 'lucide-react';
|
||||
import { FileText, BookText, User, Users, Settings, Wrench, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
labelKey: string;
|
||||
@@ -11,6 +11,8 @@ export const baseNavItems: NavItem[] = [
|
||||
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
||||
{ labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true },
|
||||
{ labelKey: 'dashboard.nav.teams', href: '/dashboard/teams', icon: Users, proOnly: true },
|
||||
{ labelKey: 'dashboard.nav.settings', href: '/dashboard/settings', icon: Settings },
|
||||
{ labelKey: 'dashboard.nav.services', href: '/dashboard/services', icon: Wrench },
|
||||
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
|
||||
// API Keys nav item temporarily removed per request — uncomment to restore.
|
||||
// { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
|
||||
|
||||
@@ -10,7 +10,7 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUser } from '@/app/dashboard/useUser';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, formatDate } from '@/lib/i18n';
|
||||
import { useGlossaries } from './useGlossaries';
|
||||
import type { GlossaryListItem } from './types';
|
||||
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
||||
@@ -28,19 +28,8 @@ const CONTEXT_SUGGESTIONS = [
|
||||
{ label: 'Style concis', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
|
||||
];
|
||||
|
||||
function renderTitle(title: string) {
|
||||
const lastSpaceIndex = title.lastIndexOf(' ');
|
||||
if (lastSpaceIndex === -1) return title;
|
||||
return (
|
||||
<>
|
||||
{title.substring(0, lastSpaceIndex)}{' '}
|
||||
<span className="italic">{title.substring(lastSpaceIndex + 1)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GlossariesPage() {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const { glossaries, isLoading: isLoadingGlossaries } = useGlossaries();
|
||||
@@ -53,7 +42,8 @@ export default function GlossariesPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'glossaries' | 'context'>('glossaries');
|
||||
|
||||
const isPro = user?.tier === 'pro';
|
||||
// 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;
|
||||
@@ -122,7 +112,7 @@ export default function GlossariesPage() {
|
||||
{t('glossaries.yourGlossaries') || 'Vos Glossaires'}
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||||
{renderTitle(t('glossaries.title') || 'Glossaires & Contexte')}
|
||||
{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.'}
|
||||
@@ -134,7 +124,7 @@ export default function GlossariesPage() {
|
||||
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} />
|
||||
Nouveau glossaire
|
||||
{t('glossaries.new')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -144,15 +134,14 @@ export default function GlossariesPage() {
|
||||
<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">Pour utiliser un glossaire dans une traduction :</strong>{' '}
|
||||
cliquez sur <span className="font-bold text-[#7A5C35] dark:text-brand-accent">« Utiliser »</span> sur la carte souhaitée.
|
||||
Vous serez redirigé sur la page Traduire avec ce glossaire déjà sélectionné.
|
||||
<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"
|
||||
>
|
||||
Aller traduire <ArrowRight size={11} />
|
||||
{t('glossaries.info.cta')} <ArrowRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -160,6 +149,8 @@ export default function GlossariesPage() {
|
||||
<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'
|
||||
@@ -171,6 +162,8 @@ export default function GlossariesPage() {
|
||||
</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'
|
||||
@@ -196,10 +189,10 @@ export default function GlossariesPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-serif font-semibold text-[#1A1A1A] dark:text-white tracking-tight">
|
||||
Consignes de contexte
|
||||
{t('glossaries.context.title')}
|
||||
</h2>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/50 font-light mt-0.5">
|
||||
Ces instructions sont envoyées automatiquement à l'IA avant chaque traduction en mode Pro LLM.
|
||||
{t('glossaries.context.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,16 +200,16 @@ export default function GlossariesPage() {
|
||||
{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} />
|
||||
Non sauvegardé
|
||||
{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} />
|
||||
Actif
|
||||
{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">
|
||||
Inactif
|
||||
{t('glossaries.context.inactive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -226,7 +219,7 @@ export default function GlossariesPage() {
|
||||
<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">
|
||||
Suggestions rapides — cliquez pour ajouter
|
||||
{t('glossaries.context.suggestions')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -246,7 +239,8 @@ export default function GlossariesPage() {
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={e => { setSystemPrompt(e.target.value); setPromptSaved(false); }}
|
||||
placeholder="Ex : Vous traduisez des documents techniques HVAC. Utilisez une terminologie d'ingénierie précise et un ton professionnel…"
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -254,8 +248,8 @@ export default function GlossariesPage() {
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<p className="text-[10px] text-[#777777] dark:text-white/25 font-light">
|
||||
{systemPrompt.length > 0
|
||||
? `${systemPrompt.length} caractères`
|
||||
: 'Aucune consigne enregistrée'}
|
||||
? t('glossaries.context.chars', { count: systemPrompt.length })
|
||||
: t('glossaries.context.noPrompt')}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
@@ -264,7 +258,7 @@ export default function GlossariesPage() {
|
||||
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" />
|
||||
Tout effacer
|
||||
{t('glossaries.context.clear')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePrompt}
|
||||
@@ -272,10 +266,10 @@ export default function GlossariesPage() {
|
||||
className="premium-button px-8 py-2.5 text-xs uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold"
|
||||
>
|
||||
{isSavingPrompt
|
||||
? <><Loader2 size={14} className="animate-spin" /> Enregistrement…</>
|
||||
? <><Loader2 size={14} className="animate-spin" /> {t('glossaries.context.saving')}</>
|
||||
: promptSaved
|
||||
? <><CheckCircle2 size={14} /> Enregistré</>
|
||||
: <><Save size={14} /> Enregistrer</>
|
||||
? <><CheckCircle2 size={14} /> {t('glossaries.context.saved')}</>
|
||||
: <><Save size={14} /> {t('glossaries.context.save')}</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -296,13 +290,13 @@ export default function GlossariesPage() {
|
||||
</h2>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/40 font-light mt-1">
|
||||
{glossaries.length > 0
|
||||
? `${glossaries.length} glossaire${glossaries.length > 1 ? 's' : ''} — cliquez sur une carte pour la modifier`
|
||||
: 'Aucun glossaire — créez-en un pour commencer'}
|
||||
? 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>Traduction active :</span>
|
||||
<span>{t('glossaries.activeTarget')}</span>
|
||||
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -316,7 +310,8 @@ export default function GlossariesPage() {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher un glossaire…"
|
||||
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>
|
||||
@@ -339,13 +334,13 @@ export default function GlossariesPage() {
|
||||
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} />
|
||||
Créer mon premier glossaire
|
||||
{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">
|
||||
Aucun résultat pour « {searchQuery} »
|
||||
{t('glossaries.noResults', { query: searchQuery })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -373,17 +368,22 @@ export default function GlossariesPage() {
|
||||
{/* 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} /> Compatible
|
||||
<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} /> Autre langue
|
||||
<AlertCircle size={9} /> {t('glossaries.badge.otherLang')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Corps cliquable → page de détail */}
|
||||
<div className="cursor-pointer flex-1" onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}>
|
||||
<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} />
|
||||
@@ -405,14 +405,14 @@ export default function GlossariesPage() {
|
||||
<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} terme{termCount > 1 ? 's' : ''}
|
||||
{termCount > 1 ? t('glossaries.termsMany', { count: termCount }) : t('glossaries.termsOne')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono text-[9px] text-[#666666] dark:text-white/40">
|
||||
<span className="flex items-center gap-1 font-mono text-[10px] text-[#666666] dark:text-white/40">
|
||||
<Calendar size={11} />
|
||||
{new Date(glossary.created_at).toLocaleDateString('fr-FR')}
|
||||
{formatDate(new Date(glossary.created_at), locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4 border-t border-[#F0EDE8] dark:border-white/10 mt-auto shrink-0">
|
||||
@@ -420,13 +420,13 @@ export default function GlossariesPage() {
|
||||
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"
|
||||
>
|
||||
Éditer
|
||||
{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"
|
||||
>
|
||||
Utiliser <ArrowRight size={11} />
|
||||
{t('glossaries.card.use')} <ArrowRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -466,7 +466,7 @@ export default function ProfilePage() {
|
||||
|
||||
{/* Danger zone — show if user has a paid plan (even if sub ID not yet synced) */}
|
||||
{!isFreePlan && !isCanceling && (
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial border-l-4 border-l-red-500">
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border border-red-200 dark:border-red-500/30 shadow-editorial">
|
||||
<div className="flex items-center gap-4 text-red-500 mb-8">
|
||||
<ShieldAlert size={20} />
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
|
||||
@@ -16,6 +16,7 @@ import { apiClient, API_BASE_URL } from '@/lib/apiClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Segment {
|
||||
@@ -48,9 +49,9 @@ function authHeaders(): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function downloadProtected(url: string, filename: string) {
|
||||
async function downloadProtected(url: string, filename: string, failMessage: (status: number) => string) {
|
||||
const res = await fetch(`${API_BASE_URL}${url}`, { headers: authHeaders() });
|
||||
if (!res.ok) throw new Error(`Téléchargement échoué (HTTP ${res.status})`);
|
||||
if (!res.ok) throw new Error(failMessage(res.status));
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -66,6 +67,7 @@ export default function ReviewPage() {
|
||||
const params = useParams<{ jobId: string }>();
|
||||
const jobId = params?.jobId ?? '';
|
||||
const notify = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [segments, setSegments] = useState<Segment[]>([]);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
@@ -88,7 +90,7 @@ export default function ReviewPage() {
|
||||
setSegments(res.data.segments);
|
||||
setFileName(res.data.file_name);
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Erreur de chargement');
|
||||
setLoadError(err instanceof Error ? err.message : t('reviews.error.load'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -125,8 +127,8 @@ export default function ReviewPage() {
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Erreur',
|
||||
description: err instanceof Error ? err.message : 'Mise à jour impossible',
|
||||
title: t('reviews.error.title'),
|
||||
description: err instanceof Error ? err.message : t('reviews.error.update'),
|
||||
});
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
@@ -140,8 +142,8 @@ export default function ReviewPage() {
|
||||
await patchSegment(seg.id, { status: 'approved' });
|
||||
}
|
||||
notify.success({
|
||||
title: 'Segments approuvés',
|
||||
description: `${pending.length} segment(s) approuvé(s).`,
|
||||
title: t('reviews.approvedTitle'),
|
||||
description: t('reviews.approvedDesc', { count: pending.length }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -153,15 +155,16 @@ export default function ReviewPage() {
|
||||
);
|
||||
await downloadProtected(
|
||||
res.data.download_url,
|
||||
`relu_${fileName ?? jobId}`
|
||||
`relu_${fileName ?? jobId}`,
|
||||
(status) => t('reviews.error.download', { status })
|
||||
);
|
||||
notify.success({
|
||||
title: 'Document reconstruit',
|
||||
description: `${res.data.segments_applied} segment(s) relu(s) appliqué(s).`,
|
||||
title: t('reviews.rebuiltTitle'),
|
||||
description: t('reviews.rebuiltDesc', { count: res.data.segments_applied }),
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Reconstruction impossible',
|
||||
title: t('reviews.rebuildFailed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
@@ -173,11 +176,12 @@ export default function ReviewPage() {
|
||||
try {
|
||||
await downloadProtected(
|
||||
`/api/v1/translations/${jobId}/xliff`,
|
||||
`${jobId}.xliff`
|
||||
`${jobId}.xliff`,
|
||||
(status) => t('reviews.error.download', { status })
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Export XLIFF échoué',
|
||||
title: t('reviews.xliffExportFailed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
@@ -203,13 +207,13 @@ export default function ReviewPage() {
|
||||
}
|
||||
const json = await res.json();
|
||||
notify.success({
|
||||
title: 'XLIFF importé',
|
||||
description: `${json.data.segments_updated} segment(s) mis à jour.`,
|
||||
title: t('reviews.xliffImported'),
|
||||
description: t('reviews.xliffImportedDesc', { count: json.data.segments_updated }),
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Import XLIFF échoué',
|
||||
title: t('reviews.xliffImportFailed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
@@ -220,25 +224,30 @@ export default function ReviewPage() {
|
||||
|
||||
const statusBadge = (status: Segment['status']) => {
|
||||
if (status === 'approved')
|
||||
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">Approuvé</Badge>;
|
||||
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">{t('reviews.status.approved')}</Badge>;
|
||||
if (status === 'edited')
|
||||
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">Modifié</Badge>;
|
||||
return <Badge variant="outline" className="text-muted-foreground">À relire</Badge>;
|
||||
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">{t('reviews.status.edited')}</Badge>;
|
||||
return <Badge variant="outline" className="text-muted-foreground">{t('reviews.status.pending')}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Relecture</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('reviews.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground truncate max-w-xl">
|
||||
{fileName ?? jobId} — {counts.total} segments ({counts.pending} à
|
||||
relire, {counts.approved} approuvés, {counts.edited} modifiés)
|
||||
{t('reviews.subtitle', {
|
||||
file: fileName ?? jobId,
|
||||
total: counts.total,
|
||||
pending: counts.pending,
|
||||
approved: counts.approved,
|
||||
edited: counts.edited,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={approveAll} disabled={counts.pending === 0}>
|
||||
<CheckCheck className="size-3.5" /> Tout approuver
|
||||
<CheckCheck className="size-3.5" /> {t('reviews.approveAll')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
|
||||
<FileDown className="size-3.5" /> XLIFF
|
||||
@@ -250,7 +259,7 @@ export default function ReviewPage() {
|
||||
disabled={isImporting}
|
||||
>
|
||||
{isImporting ? <Loader2 className="size-3.5 animate-spin" /> : <FileUp className="size-3.5" />}
|
||||
Importer XLIFF
|
||||
{t('reviews.importXliff')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -264,7 +273,7 @@ export default function ReviewPage() {
|
||||
/>
|
||||
<Button size="sm" onClick={rebuild} disabled={isRebuilding || counts.approved + counts.edited === 0}>
|
||||
{isRebuilding ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||
Reconstruire et télécharger
|
||||
{t('reviews.rebuild')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,9 +293,9 @@ export default function ReviewPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-1/2 px-4 py-2.5 font-medium">Source</th>
|
||||
<th className="w-1/2 px-4 py-2.5 font-medium">Traduction</th>
|
||||
<th className="w-40 px-4 py-2.5 font-medium">Statut</th>
|
||||
<th className="w-1/2 px-4 py-2.5 font-medium">{t('reviews.col.source')}</th>
|
||||
<th className="w-1/2 px-4 py-2.5 font-medium">{t('reviews.col.target')}</th>
|
||||
<th className="w-40 px-4 py-2.5 font-medium">{t('reviews.col.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -325,14 +334,14 @@ export default function ReviewPage() {
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
Enregistrer
|
||||
{t('reviews.save')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
Annuler
|
||||
{t('reviews.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,7 +352,7 @@ export default function ReviewPage() {
|
||||
seg.status === 'edited' && 'text-blue-600 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
{finalText || <span className="italic text-muted-foreground">— vide —</span>}
|
||||
{finalText || <span className="italic text-muted-foreground">{t('reviews.emptyCell')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
@@ -356,7 +365,7 @@ export default function ReviewPage() {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
title="Modifier la traduction"
|
||||
title={t('reviews.action.edit')}
|
||||
onClick={() => {
|
||||
setEditingId(seg.id);
|
||||
setDraft(finalText);
|
||||
@@ -368,7 +377,7 @@ export default function ReviewPage() {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
title="Approuver tel quel"
|
||||
title={t('reviews.action.approve')}
|
||||
disabled={seg.status === 'approved' || savingId === seg.id}
|
||||
onClick={() => patchSegment(seg.id, { status: 'approved' })}
|
||||
>
|
||||
@@ -378,7 +387,7 @@ export default function ReviewPage() {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
title="Remettre à relire"
|
||||
title={t('reviews.action.reset')}
|
||||
disabled={seg.status === 'pending'}
|
||||
onClick={() => patchSegment(seg.id, { status: 'pending' })}
|
||||
>
|
||||
|
||||
@@ -53,23 +53,23 @@ export default function TranslationServicesPage() {
|
||||
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
|
||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||
<div className="mb-12">
|
||||
<span className="accent-pill mb-4 block w-fit">{t('services.title')}</span>
|
||||
<span className="accent-pill mb-4 block w-fit">{t('services.pill')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('services.title')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('services.subtitle')}</p>
|
||||
<p className="text-brand-dark/50 dark:text-white/50 font-medium">{t('services.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[30vh]">
|
||||
<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-[9px] font-black text-brand-dark/40 dark:text-white/40 uppercase tracking-widest">{t('services.loading')}</p>
|
||||
<p className="text-xs font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-wider" role="status">{t('services.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="editorial-card p-16 bg-white dark:bg-[#141414] border-none shadow-editorial text-center">
|
||||
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('services.noProviders')}</p>
|
||||
<p className="text-brand-dark/50 dark:text-white/50 font-medium">{t('services.noProviders')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
@@ -78,7 +78,7 @@ export default function TranslationServicesPage() {
|
||||
<section className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
<div className="flex items-center gap-4 mb-8 text-brand-accent">
|
||||
<Globe size={20} />
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-brand-dark dark:text-white">
|
||||
{t('services.classic')}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -90,9 +90,9 @@ export default function TranslationServicesPage() {
|
||||
>
|
||||
<div>
|
||||
<p className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">{p.label}</p>
|
||||
<p className="text-[10px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">{p.description}</p>
|
||||
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1">{p.description}</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full text-[9px] font-black uppercase tracking-widest">
|
||||
<span className="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full text-[10px] font-black uppercase tracking-wider">
|
||||
<CheckCircle2 size={12} /> {t('services.available')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -106,7 +106,7 @@ export default function TranslationServicesPage() {
|
||||
<section className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
<div className="flex items-center gap-4 mb-8 text-brand-accent">
|
||||
<Brain size={20} />
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-brand-dark dark:text-white">
|
||||
{t('services.llmPro')}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -118,14 +118,14 @@ export default function TranslationServicesPage() {
|
||||
>
|
||||
<div>
|
||||
<p className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">{p.label}</p>
|
||||
<p className="text-[10px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">{p.description}</p>
|
||||
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1">{p.description}</p>
|
||||
{p.model && (
|
||||
<p className="mt-2 text-[9px] font-mono text-brand-dark/20 dark:text-white/20 uppercase tracking-widest">
|
||||
<p className="mt-2 text-[10px] font-mono text-brand-dark/40 dark:text-white/40 uppercase tracking-wider">
|
||||
{t('services.model')}: {p.model}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full text-[9px] font-black uppercase tracking-widest">
|
||||
<span className="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full text-[10px] font-black uppercase tracking-wider">
|
||||
<CheckCircle2 size={12} /> {t('services.available')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -141,10 +141,10 @@ export default function TranslationServicesPage() {
|
||||
<div className="flex items-center gap-4 text-amber-600 dark:text-amber-400">
|
||||
<Lock size={20} />
|
||||
<div>
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-amber-800 dark:text-amber-400">
|
||||
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-amber-800 dark:text-amber-400">
|
||||
{t('services.adminOnly.title')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-amber-600/60 dark:text-amber-400/60 font-bold uppercase tracking-widest mt-2">
|
||||
<p className="text-[11px] text-amber-600/80 dark:text-amber-400/80 font-semibold uppercase tracking-wider mt-2">
|
||||
{t('services.adminOnly.desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
export default function GeneralSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirmingClear, setIsConfirmingClear] = useState(false);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setIsClearing(true);
|
||||
@@ -24,6 +25,7 @@ export default function GeneralSettingsPage() {
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
setIsClearing(false);
|
||||
setIsConfirmingClear(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,11 +39,11 @@ export default function GeneralSettingsPage() {
|
||||
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
|
||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||
<div className="mb-12">
|
||||
<span className="accent-pill mb-4 block w-fit">{t('settings.title')}</span>
|
||||
<span className="accent-pill mb-4 block w-fit">{t('settings.pill')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('settings.subtitle')}</p>
|
||||
<p className="text-brand-dark/50 dark:text-white/50 font-medium">{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12">
|
||||
@@ -52,10 +54,10 @@ export default function GeneralSettingsPage() {
|
||||
<Settings size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-brand-dark dark:text-white">
|
||||
{t('settings.formats.title')}
|
||||
</h3>
|
||||
<p className="text-[9px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">
|
||||
<p className="text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1">
|
||||
{t('settings.formats.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -72,17 +74,17 @@ export default function GeneralSettingsPage() {
|
||||
<div className="w-12 h-12 bg-white dark:bg-white/10 rounded-2xl flex items-center justify-center shadow-sm mb-6">
|
||||
<Icon size={24} className={f.color} />
|
||||
</div>
|
||||
<p className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white mb-2">
|
||||
<p className="text-xs font-black uppercase tracking-tight text-brand-dark dark:text-white mb-2">
|
||||
{f.name}
|
||||
</p>
|
||||
<p className="text-[9px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mb-4">
|
||||
<p className="text-[10px] text-brand-dark/50 dark:text-white/50 font-bold uppercase tracking-wider mb-4">
|
||||
{f.ext}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{f.features.map(ft => (
|
||||
<span
|
||||
key={ft}
|
||||
className="px-3 py-1 bg-white dark:bg-white/10 rounded-full text-[8px] font-black uppercase tracking-widest text-brand-dark/40 dark:text-white/40 border border-black/5 dark:border-white/10"
|
||||
className="px-3 py-1 bg-white dark:bg-white/10 rounded-full text-[10px] font-black uppercase tracking-wider text-brand-dark/50 dark:text-white/50 border border-black/5 dark:border-white/10"
|
||||
>
|
||||
{ft}
|
||||
</span>
|
||||
@@ -104,22 +106,43 @@ export default function GeneralSettingsPage() {
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{t('settings.cache.title')}
|
||||
</h3>
|
||||
<p className="text-brand-dark/30 dark:text-white/30 text-[10px] font-black uppercase tracking-widest mt-2 leading-relaxed">
|
||||
<p className="text-brand-dark/50 dark:text-white/50 text-xs font-semibold uppercase tracking-wider mt-2 leading-relaxed">
|
||||
{t('settings.cache.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
disabled={isClearing}
|
||||
className="px-8 py-3 bg-brand-muted dark:bg-white/10 text-brand-dark/40 dark:text-white/40 rounded-xl text-[9px] font-black uppercase tracking-widest hover:text-brand-dark dark:hover:text-white transition-all disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{isClearing ? (
|
||||
<><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin inline" />{t('settings.cache.clearing')}</>
|
||||
<div className="flex flex-col gap-2 shrink-0">
|
||||
{isConfirmingClear ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
disabled={isClearing}
|
||||
className="px-8 py-3 bg-red-500 text-white rounded-xl text-xs font-black uppercase tracking-wider hover:bg-red-600 transition-all disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{isClearing ? (
|
||||
<><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin inline" />{t('settings.cache.clearing')}</>
|
||||
) : (
|
||||
t('settings.cache.confirm')
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsConfirmingClear(false)}
|
||||
disabled={isClearing}
|
||||
className="px-8 py-2 text-[11px] font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-colors"
|
||||
>
|
||||
{t('settings.cache.cancelClear')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<><Trash2 className="me-1.5 h-3.5 w-3.5 inline" />{t('settings.cache.clear')}</>
|
||||
<button
|
||||
onClick={() => setIsConfirmingClear(true)}
|
||||
disabled={isClearing}
|
||||
className="px-8 py-3 bg-brand-muted dark:bg-white/10 text-brand-dark/50 dark:text-white/50 rounded-xl text-xs font-black uppercase tracking-wider hover:text-brand-dark dark:hover:text-white transition-all disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<Trash2 className="me-1.5 h-3.5 w-3.5 inline" />{t('settings.cache.clear')}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface WorkspaceMember {
|
||||
id: string;
|
||||
@@ -35,6 +36,7 @@ interface Workspace {
|
||||
|
||||
export default function TeamsPage() {
|
||||
const notify = useToast();
|
||||
const { t } = useI18n();
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [needsBusiness, setNeedsBusiness] = useState(false);
|
||||
@@ -55,7 +57,7 @@ export default function TeamsPage() {
|
||||
if (message.includes('Business') || message.includes('PLAN_REQUIRED')) {
|
||||
setNeedsBusiness(true);
|
||||
} else {
|
||||
notify.error({ title: 'Erreur', description: message || 'Chargement impossible' });
|
||||
notify.error({ title: t('teams.error.title'), description: message || t('teams.error.load') });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -72,11 +74,11 @@ export default function TeamsPage() {
|
||||
try {
|
||||
await apiClient.post('/api/v1/workspaces', { name: newName.trim() });
|
||||
setNewName('');
|
||||
notify.success({ title: 'Espace créé', description: 'Vous êtes propriétaire.' });
|
||||
notify.success({ title: t('teams.created'), description: t('teams.createdDesc') });
|
||||
await load();
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Création impossible',
|
||||
title: t('teams.createFailed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
@@ -94,11 +96,11 @@ export default function TeamsPage() {
|
||||
role: inviteRole[wsId] || 'member',
|
||||
});
|
||||
setInviteEmail((p) => ({ ...p, [wsId]: '' }));
|
||||
notify.success({ title: 'Membre ajouté', description: email });
|
||||
notify.success({ title: t('teams.memberAdded'), description: email });
|
||||
await load();
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Ajout impossible',
|
||||
title: t('teams.addFailed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
@@ -113,7 +115,7 @@ export default function TeamsPage() {
|
||||
await load();
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Retrait impossible',
|
||||
title: t('teams.removeFailed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
@@ -137,9 +139,9 @@ export default function TeamsPage() {
|
||||
<Users className="size-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Espaces de travail</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('teams.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Partagez vos glossaires avec votre équipe (plan Business — 5 sièges).
|
||||
{t('teams.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,25 +150,24 @@ export default function TeamsPage() {
|
||||
{needsBusiness ? (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Les espaces de travail nécessitent le plan Business. Passez au plan
|
||||
supérieur pour collaborer avec votre équipe.
|
||||
{t('teams.needsBusiness')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex-1 min-w-56 space-y-1.5">
|
||||
<Label htmlFor="ws-name">Nouvel espace de travail</Label>
|
||||
<Label htmlFor="ws-name">{t('teams.newWorkspace')}</Label>
|
||||
<Input
|
||||
id="ws-name"
|
||||
placeholder="Ex. Équipe traduction"
|
||||
placeholder={t('teams.newPlaceholder')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={createWorkspace} disabled={isCreating || !newName.trim()}>
|
||||
{isCreating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
Créer
|
||||
{t('teams.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -178,13 +179,14 @@ export default function TeamsPage() {
|
||||
<span className="font-medium text-foreground">{ws.name}</span>
|
||||
{ws.my_role === 'owner' && (
|
||||
<Badge className="gap-1 bg-amber-500/15 text-amber-600 border-amber-500/30">
|
||||
<Crown className="size-3" /> Propriétaire
|
||||
<Crown className="size-3" /> {t('teams.owner')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ws.member_count}
|
||||
{ws.seat_limit === -1 ? ' sièges (illimité)' : ` / ${ws.seat_limit ?? '?'} sièges`}
|
||||
{ws.seat_limit === -1
|
||||
? t('teams.seatsUnlimited', { count: ws.member_count })
|
||||
: t('teams.seats', { count: ws.member_count, limit: ws.seat_limit ?? '?' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -202,7 +204,7 @@ export default function TeamsPage() {
|
||||
className="h-7 px-2 text-red-500"
|
||||
disabled={busyWs === ws.id || ws.my_role === 'member'}
|
||||
onClick={() => removeMember(ws.id, m.user_id)}
|
||||
title="Retirer du workspace"
|
||||
title={t('teams.removeMember')}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
@@ -214,11 +216,11 @@ export default function TeamsPage() {
|
||||
{(ws.my_role === 'owner' || ws.my_role === 'admin') && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex-1 min-w-48 space-y-1.5">
|
||||
<Label htmlFor={`invite-${ws.id}`}>Ajouter un membre (e-mail)</Label>
|
||||
<Label htmlFor={`invite-${ws.id}`}>{t('teams.addMember')}</Label>
|
||||
<Input
|
||||
id={`invite-${ws.id}`}
|
||||
type="email"
|
||||
placeholder="collegue@entreprise.com"
|
||||
placeholder={t('teams.emailPlaceholder')}
|
||||
value={inviteEmail[ws.id] || ''}
|
||||
onChange={(e) =>
|
||||
setInviteEmail((p) => ({ ...p, [ws.id]: e.target.value }))
|
||||
@@ -233,8 +235,8 @@ export default function TeamsPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Membre</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="member">{t('teams.role.member')}</SelectItem>
|
||||
<SelectItem value="admin">{t('teams.role.admin')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
@@ -247,7 +249,7 @@ export default function TeamsPage() {
|
||||
) : (
|
||||
<UserPlus className="size-3.5" />
|
||||
)}
|
||||
Inviter
|
||||
{t('teams.invite')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -256,7 +258,7 @@ export default function TeamsPage() {
|
||||
|
||||
{workspaces.length === 0 && (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
|
||||
Aucun espace pour l'instant — créez le premier ci-dessus.
|
||||
{t('teams.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { Upload, FileSpreadsheet, FileText, Presentation } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { UseFileUploadReturn } from './types';
|
||||
|
||||
interface FileDropZoneProps {
|
||||
upload: UseFileUploadReturn;
|
||||
}
|
||||
|
||||
export function FileDropZone({ upload }: FileDropZoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleClick = () => inputRef.current?.click();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('dashboard.translate.dropzone.uploadAria')}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center gap-6 rounded-2xl border-2 border-dashed',
|
||||
'min-h-[320px] cursor-pointer select-none transition-all duration-200 px-8 py-12',
|
||||
upload.isDragOver
|
||||
? 'border-primary bg-primary/8 scale-[1.01]'
|
||||
: 'border-border bg-muted/20 hover:border-primary/50 hover:bg-muted/40'
|
||||
)}
|
||||
onDragOver={upload.handleDragOver}
|
||||
onDragLeave={upload.handleDragLeave}
|
||||
onDrop={upload.handleDrop}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => e.key === 'Enter' || e.key === ' ' ? handleClick() : undefined}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className={cn(
|
||||
'flex size-20 items-center justify-center rounded-2xl transition-colors',
|
||||
upload.isDragOver ? 'bg-primary/15' : 'bg-secondary'
|
||||
)}>
|
||||
<Upload className={cn(
|
||||
'size-9 transition-colors',
|
||||
upload.isDragOver ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{t('dashboard.translate.dropzone.title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.dropzone.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format badges */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<FileText className="size-3.5 text-blue-500" />
|
||||
Word (.docx)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<FileSpreadsheet className="size-3.5 text-green-500" />
|
||||
Excel (.xlsx)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Presentation className="size-3.5 text-orange-500" />
|
||||
PowerPoint (.pptx)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<svg className="size-3.5 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="5" fontWeight="bold" fill="currentColor" stroke="none">PDF</text>
|
||||
</svg>
|
||||
PDF (.pdf)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx,.pdf"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
aria-label={t('dashboard.translate.dropzone.uploadAria')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { FileSpreadsheet, FileText, Presentation, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const FILE_ICONS: Record<string, React.ElementType> = {
|
||||
xlsx: FileSpreadsheet,
|
||||
docx: FileText,
|
||||
pptx: Presentation,
|
||||
};
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function FilePreview({ file, onRemove }: FilePreviewProps) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
const FileIcon = FILE_ICONS[ext] || FileText;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-secondary">
|
||||
<FileIcon className="size-5 text-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)} · .{ext}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ms-2 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -285,6 +285,9 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
|
||||
{isPro && mode === 'llm' && (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isGlossaryEnabled}
|
||||
aria-label={t('translate.glossary.title') || 'Glossaire'}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const nextVal = !isGlossaryEnabled;
|
||||
@@ -307,7 +310,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
|
||||
>
|
||||
<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'
|
||||
isGlossaryEnabled ? 'start-[13px]' : 'start-0.5'
|
||||
)} />
|
||||
</button>
|
||||
)}
|
||||
@@ -359,6 +362,8 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
"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.5 px-3 rounded-lg flex items-center justify-between shadow-sm transition-all cursor-pointer",
|
||||
isOpen && "border-brand-accent dark:border-brand-accent",
|
||||
|
||||
@@ -24,6 +24,7 @@ function Combobox({
|
||||
autoLabel,
|
||||
placeholder,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
options: Language[];
|
||||
@@ -31,12 +32,14 @@ function Combobox({
|
||||
autoLabel: string;
|
||||
placeholder: string;
|
||||
onChange: (code: string) => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
@@ -50,6 +53,12 @@ function Combobox({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const closeAndRefocus = () => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const allOptions = includeAuto
|
||||
? [{ code: 'auto', name: autoLabel }, ...options]
|
||||
: options;
|
||||
@@ -62,20 +71,37 @@ function Combobox({
|
||||
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative text-left">
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative text-left"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
e.stopPropagation();
|
||||
closeAndRefocus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'w-full py-2.5 px-3.5 bg-brand-muted/60 dark:bg-white/5 rounded-xl border text-xs font-bold uppercase tracking-wider text-brand-dark dark:text-white flex items-center justify-between hover:border-brand-accent/50 transition-all select-none cursor-pointer',
|
||||
open ? 'border-brand-accent/50' : 'border-brand-accent/20 dark:border-white/10'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label || placeholder}</span>
|
||||
<span className="truncate normal-case">{label || placeholder}</span>
|
||||
<ChevronDown className={cn('size-3.5 shrink-0 text-brand-accent transition-transform ms-2', open && 'rotate-180')} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-[102%] right-0 left-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-2 z-50 max-h-48 overflow-y-auto animate-fade-in">
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className="absolute top-[102%] right-0 left-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-2 z-50 max-h-48 overflow-y-auto animate-fade-in"
|
||||
>
|
||||
<div className="border-b border-black/5 dark:border-white/5 px-2 py-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -83,6 +109,7 @@ function Combobox({
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('langSelector.search')}
|
||||
aria-label={t('langSelector.search')}
|
||||
className="w-full bg-transparent px-1 py-1 text-xs outline-none placeholder:text-brand-dark/30 dark:placeholder:text-white/30 text-brand-dark dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
@@ -94,7 +121,9 @@ function Combobox({
|
||||
<button
|
||||
key={lang.code}
|
||||
type="button"
|
||||
onClick={() => { onChange(lang.code); setOpen(false); setQuery(''); }}
|
||||
role="option"
|
||||
aria-selected={value === lang.code}
|
||||
onClick={() => { onChange(lang.code); closeAndRefocus(); }}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer',
|
||||
value === lang.code
|
||||
@@ -152,6 +181,7 @@ export default function LanguageSelector({
|
||||
autoLabel={t('dashboard.translate.language.autoDetect') || 'Auto-détecté'}
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder') || 'Langue...'}
|
||||
onChange={onSourceChange}
|
||||
ariaLabel={`${t('langSelector.source')} — ${t('dashboard.translate.language.autoDetect') || 'Auto-détecté'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +191,7 @@ export default function LanguageSelector({
|
||||
type="button"
|
||||
onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
|
||||
disabled={!canSwap}
|
||||
aria-label={t('langSelector.swap')}
|
||||
className={cn(
|
||||
'flex size-7 items-center justify-center rounded-xl transition-all cursor-pointer',
|
||||
canSwap
|
||||
@@ -183,6 +214,7 @@ export default function LanguageSelector({
|
||||
autoLabel=""
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder') || 'Langue...'}
|
||||
onChange={onTargetChange}
|
||||
ariaLabel={t('langSelector.target')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
CheckCircle2, Download, Plus, Loader2, FileText,
|
||||
Timer, Activity, TrendingUp, BookOpenCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TranslationCompleteProps {
|
||||
jobId: string;
|
||||
fileName: string | null;
|
||||
onNewTranslation: () => void;
|
||||
}
|
||||
|
||||
export function TranslationComplete({
|
||||
jobId,
|
||||
fileName,
|
||||
onNewTranslation,
|
||||
}: TranslationCompleteProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const { success, error } = useNotification();
|
||||
const { t } = useI18n();
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = t('dashboard.translate.complete.toastFailDesc');
|
||||
try {
|
||||
const body = await response.json();
|
||||
msg = body.message || body.error || msg;
|
||||
} catch { /* not JSON */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let downloadFilename = 'translated_document';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
||||
if (match?.[1]) downloadFilename = match[1];
|
||||
} else if (fileName) {
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
const base = fileName.replace(/\.[^.]+$/, '');
|
||||
downloadFilename = `${base}_translated.${ext}`;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFilename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
setTimeout(() => {
|
||||
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
|
||||
}, 1000);
|
||||
|
||||
success({
|
||||
title: t('dashboard.translate.complete.toastOkTitle'),
|
||||
description: t('dashboard.translate.complete.toastOkDesc', { name: downloadFilename }),
|
||||
});
|
||||
} catch (err) {
|
||||
error({
|
||||
title: t('dashboard.translate.complete.toastFailTitle'),
|
||||
description: err instanceof Error ? err.message : t('dashboard.translate.complete.toastFailDesc'),
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
setTimeout(() => {
|
||||
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-lg flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* ═══ Success header ═══ */}
|
||||
<div className="relative overflow-hidden border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-8 py-6 text-center">
|
||||
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
|
||||
<CheckCircle2 className="size-8 text-white" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-bold text-foreground">
|
||||
{t('dashboard.translate.complete.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{fileName
|
||||
? t('dashboard.translate.complete.descNamed', { name: fileName })
|
||||
: t('dashboard.translate.complete.descGeneric')}
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<TrendingUp className="size-3" /> {t('translateComplete.highQuality')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
|
||||
{/* ═══ Result stats ═══ */}
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||
<FileText className="size-4 text-emerald-600" />
|
||||
<p className="text-sm font-bold text-foreground">142</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.segments')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||
<Activity className="size-4 text-emerald-600" />
|
||||
<p className="text-sm font-bold text-foreground">12.8k</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.characters')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||
<Timer className="size-4 text-emerald-600" />
|
||||
<p className="text-sm font-bold text-emerald-600">96%</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.confidence')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ Actions ═══ */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-12 w-full gap-2 text-base font-semibold"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{t('dashboard.translate.complete.downloading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="size-5" />
|
||||
{t('dashboard.translate.complete.download')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-11 w-full gap-2"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/dashboard/reviews/${jobId}`}>
|
||||
<BookOpenCheck className="size-4" />
|
||||
Relire et corriger la traduction
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-11 w-full gap-2"
|
||||
onClick={onNewTranslation}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('dashboard.translate.complete.newTranslation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Lock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { TranslationMode } from './types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface TranslationModeToggleProps {
|
||||
mode: TranslationMode;
|
||||
onModeChange: (mode: TranslationMode) => void;
|
||||
isPro: boolean;
|
||||
}
|
||||
|
||||
export function TranslationModeToggle({
|
||||
mode,
|
||||
onModeChange,
|
||||
isPro,
|
||||
}: TranslationModeToggleProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t('translate.mode.label')}
|
||||
</label>
|
||||
<div className="flex rounded-lg border border-border bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all',
|
||||
mode === 'classic'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => onModeChange('classic')}
|
||||
>
|
||||
{t('translate.mode.classic')}
|
||||
<span className="ms-1.5 text-xs text-muted-foreground">
|
||||
{t('translate.mode.classicDesc')}
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all relative',
|
||||
mode === 'llm'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
!isPro && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
onClick={() => isPro && onModeChange('llm')}
|
||||
disabled={!isPro}
|
||||
>
|
||||
{t('translate.mode.proLlm')}
|
||||
<span className="ms-1.5 text-xs text-muted-foreground">
|
||||
{t('translate.mode.proLlmDesc')}
|
||||
</span>
|
||||
{!isPro && (
|
||||
<Lock className="absolute right-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{!isPro && (
|
||||
<TooltipContent side="top">
|
||||
<p>{t('translate.mode.tooltip')}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isPro && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<a href="/pricing" className="text-primary hover:underline">
|
||||
{t('translate.mode.upgradeLink')}
|
||||
</a>{' '}
|
||||
{t('translate.mode.upgradeDesc')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
AlertTriangle, Loader2, Clock, WifiOff,
|
||||
Upload, Search, Languages, Wrench, CheckCircle2,
|
||||
FileText, Timer, Gauge, Activity,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
/* ── Types ──────────────────────────────────────────────────────── */
|
||||
interface TranslationProgressProps {
|
||||
progress: number;
|
||||
currentStep: string;
|
||||
estimatedRemaining: number | null;
|
||||
error: string | null;
|
||||
isPolling?: boolean;
|
||||
isUploading?: boolean;
|
||||
isCompleted?: boolean;
|
||||
/** Extra info for the monitor panel */
|
||||
fileName?: string | null;
|
||||
sourceLang?: string;
|
||||
targetLang?: string;
|
||||
providerLabel?: string | null;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/* ── Pipeline step definition ───────────────────────────────────── */
|
||||
interface PipelineStep {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
/** Progress range where this step is active */
|
||||
startsAt: number;
|
||||
}
|
||||
|
||||
const PIPELINE_STEPS: PipelineStep[] = [
|
||||
{ id: 'upload', label: 'Upload', icon: Upload, startsAt: 0 },
|
||||
{ id: 'analyze', label: 'Analyse', icon: Search, startsAt: 10 },
|
||||
{ id: 'translate', label: 'Traduction', icon: Languages, startsAt: 25 },
|
||||
{ id: 'rebuild', label: 'Reconstruction', icon: Wrench, startsAt: 75 },
|
||||
{ id: 'finalize', label: 'Finalisation', icon: CheckCircle2, startsAt: 92 },
|
||||
];
|
||||
|
||||
/* ── Simulated activity messages ────────────────────────────────── */
|
||||
const ACTIVITY_MESSAGES: Record<string, string[]> = {
|
||||
upload: [
|
||||
'Fichier reçu avec succès',
|
||||
'Vérification du format...',
|
||||
],
|
||||
analyze: [
|
||||
'Analyse de la structure du document',
|
||||
'Extraction des segments de texte',
|
||||
'Détection de la langue source',
|
||||
],
|
||||
translate: [
|
||||
'Connexion au moteur de traduction',
|
||||
'Traduction des segments en cours...',
|
||||
'Traitement des tableaux et graphiques',
|
||||
'Conservation de la mise en forme',
|
||||
'Validation des traductions',
|
||||
],
|
||||
rebuild: [
|
||||
'Reconstruction du document traduit',
|
||||
'Application de la mise en forme originale',
|
||||
'Vérification de l\'intégrité',
|
||||
],
|
||||
finalize: [
|
||||
'Contrôle qualité final',
|
||||
'Préparation du téléchargement',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────── */
|
||||
function getActiveStepIndex(progress: number): number {
|
||||
let idx = 0;
|
||||
for (let i = PIPELINE_STEPS.length - 1; i >= 0; i--) {
|
||||
if (progress >= PIPELINE_STEPS[i].startsAt) { idx = i; break; }
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return '';
|
||||
if (seconds < 60) return `~${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `~${m}m${s > 0 ? ` ${s}s` : ''}`;
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
export function TranslationProgress({
|
||||
progress,
|
||||
currentStep,
|
||||
estimatedRemaining,
|
||||
error,
|
||||
isPolling = true,
|
||||
isUploading = false,
|
||||
isCompleted = false,
|
||||
fileName,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
providerLabel,
|
||||
onCancel,
|
||||
}: TranslationProgressProps) {
|
||||
const { t } = useI18n();
|
||||
const [animate, setAnimate] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [activities, setActivities] = useState<{ text: string; time: string }[]>([]);
|
||||
const prevStepIdx = useRef(-1);
|
||||
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Animate progress bar on first progress
|
||||
useEffect(() => {
|
||||
if (progress > 0) setAnimate(true);
|
||||
else {
|
||||
setAnimate(false);
|
||||
const tid = setTimeout(() => setAnimate(true), 50);
|
||||
return () => clearTimeout(tid);
|
||||
}
|
||||
}, [progress]);
|
||||
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if (isCompleted || error) {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
return;
|
||||
}
|
||||
elapsedRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
|
||||
return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); };
|
||||
}, [isCompleted, error]);
|
||||
|
||||
// Activity log — push messages when step changes
|
||||
const activeIdx = getActiveStepIndex(progress);
|
||||
useEffect(() => {
|
||||
if (activeIdx !== prevStepIdx.current) {
|
||||
prevStepIdx.current = activeIdx;
|
||||
const step = PIPELINE_STEPS[activeIdx];
|
||||
if (!step) return;
|
||||
const msgs = ACTIVITY_MESSAGES[step.id] || [];
|
||||
msgs.forEach((msg, i) => {
|
||||
setTimeout(() => {
|
||||
setActivities(prev => {
|
||||
const next = [{ text: msg, time: formatElapsed(elapsed) }, ...prev];
|
||||
return next.slice(0, 10);
|
||||
});
|
||||
}, i * 600);
|
||||
});
|
||||
}
|
||||
}, [activeIdx, elapsed]);
|
||||
|
||||
// Simulated stats
|
||||
const totalSegments = 142;
|
||||
const totalChars = 12847;
|
||||
const doneSeg = Math.round(totalSegments * progress / 100);
|
||||
const doneChars = Math.round(totalChars * progress / 100);
|
||||
const speed = elapsed > 3 ? (doneSeg / (elapsed / 60)).toFixed(1) : '—';
|
||||
|
||||
/* ── Error state ──────────────────────────────────────────────── */
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-5" role="alert" aria-live="assertive">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
|
||||
<p className="text-sm text-destructive/80">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button onClick={onCancel} className="mx-auto flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">
|
||||
<RotateCcw className="size-4" />{t('dashboard.translate.actions.tryAgain')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* ═══ Header band ═══ */}
|
||||
<div className="relative overflow-hidden border-b border-border bg-gradient-to-r from-primary/5 via-primary/8 to-primary/3 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">Traduction en cours</h2>
|
||||
{fileName && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{estimatedRemaining != null && estimatedRemaining > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
|
||||
<Clock className="size-3.5" />
|
||||
{formatTime(estimatedRemaining)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
|
||||
{/* ═══ Pipeline Stepper ═══ */}
|
||||
<div className="flex items-start justify-between">
|
||||
{PIPELINE_STEPS.map((step, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
const isDone = i < activeIdx;
|
||||
const Icon = step.icon;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEPS.length - 1 ? 1 : 'none' }}>
|
||||
{/* Step circle + connector */}
|
||||
<div className="flex items-center w-full">
|
||||
<div className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
|
||||
isDone
|
||||
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
|
||||
: isActive
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
<Icon className={cn('size-4', isActive && 'animate-pulse')} />
|
||||
)}
|
||||
</div>
|
||||
{i < PIPELINE_STEPS.length - 1 && (
|
||||
<div className={cn(
|
||||
'mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500',
|
||||
i < activeIdx ? 'bg-primary' : 'bg-border',
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium transition-colors duration-300',
|
||||
isDone || isActive ? 'text-primary' : 'text-muted-foreground',
|
||||
)}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ═══ Progress bar ═══ */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-foreground animate-pulse truncate">
|
||||
{currentStep || t('dashboard.translate.progress.processingFallback')}
|
||||
</p>
|
||||
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
|
||||
{Math.round(progress)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-700 ease-out',
|
||||
animate && 'bg-gradient-to-r from-primary via-primary/80 to-primary',
|
||||
)}
|
||||
style={{ width: `${Math.max(0, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ Live Stats Grid ═══ */}
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
<StatCard
|
||||
icon={<FileText className="size-4" />}
|
||||
value={`${doneSeg}/${totalSegments}`}
|
||||
label="Segments"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="size-4" />}
|
||||
value={doneChars.toLocaleString()}
|
||||
label="Caractères"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Gauge className="size-4" />}
|
||||
value={speed}
|
||||
label="Seg/min"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Timer className="size-4" />}
|
||||
value={formatElapsed(elapsed)}
|
||||
label="Écoulé"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══ Activity Feed ═══ */}
|
||||
{activities.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Activity className="size-3" /> Journal d'activité
|
||||
</p>
|
||||
<div className="max-h-[100px] overflow-y-auto rounded-xl border border-border bg-muted/30">
|
||||
{activities.map((act, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 border-b border-border/50 px-3 py-1.5 last:border-0">
|
||||
<div className="size-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<span className="flex-1 text-xs text-foreground">{act.text}</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">{act.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Connection lost ═══ */}
|
||||
{showConnectionLost && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<WifiOff className="size-3.5" />
|
||||
<span>{t('dashboard.translate.progress.connectionLost')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Cancel button ═══ */}
|
||||
{onCancel && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2 rounded-lg border border-destructive/20 px-4 py-2 text-sm font-medium text-destructive transition hover:bg-destructive hover:text-white hover:border-destructive"
|
||||
>
|
||||
<RotateCcw className="size-4" />Annuler
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stat card sub-component ────────────────────────────────────── */
|
||||
function StatCard({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
|
||||
<div className="text-primary">{icon}</div>
|
||||
<p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2,
|
||||
FileSpreadsheet, FileText, Presentation, Upload, X,
|
||||
Zap, CheckCircle2,
|
||||
Search, Languages, Wrench, Activity, Timer,
|
||||
Search, Languages, Wrench, Activity,
|
||||
Download, AlertTriangle, FileType,
|
||||
Image as ImageIcon,
|
||||
} from 'lucide-react';
|
||||
import { useFileUpload } from './useFileUpload';
|
||||
import { useTranslationConfig } from './useTranslationConfig';
|
||||
import { useTranslationSubmit } from './useTranslationSubmit';
|
||||
import { useTranslationSubmit, getRecentJobs, type RecentJob } from './useTranslationSubmit';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
import { ProviderSelector } from './ProviderSelector';
|
||||
import { GlossarySelector } from './GlossarySelector';
|
||||
@@ -20,7 +21,6 @@ import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUser } from '../useUser';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
const FILE_ICONS: Record<string, React.ElementType> = {
|
||||
@@ -35,23 +35,6 @@ function fmt(bytes: number) {
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/* ── Quality label based on provider ────────────────────────────── */
|
||||
function getQualityLabel(t: (key: string) => string, provider: string | null | undefined): string {
|
||||
if (!provider) return t('dashboard.translate.highQuality');
|
||||
if (['openai', 'openrouter', 'openrouter_premium'].includes(provider)) return t('dashboard.translate.highQuality');
|
||||
if (provider === 'deepl') return t('dashboard.translate.highQuality');
|
||||
return t('dashboard.translate.quality');
|
||||
}
|
||||
|
||||
/* ── Pipeline step keys ─────────────────────────────────────────── */
|
||||
const PIPELINE_STEP_KEYS = [
|
||||
'dashboard.translate.pipeline.upload',
|
||||
'dashboard.translate.pipeline.analyze',
|
||||
'dashboard.translate.pipeline.translate',
|
||||
'dashboard.translate.pipeline.rebuild',
|
||||
'dashboard.translate.pipeline.finalize',
|
||||
] as const;
|
||||
|
||||
const PIPELINE_ICONS = [Upload, Search, Languages, Wrench, CheckCircle2] as const;
|
||||
const PIPELINE_STARTS = [0, 10, 25, 75, 92];
|
||||
|
||||
@@ -68,6 +51,18 @@ function formatElapsed(totalSeconds: number) {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** "≈ 3 min" / "45 s" — honest remaining-time from the API estimate */
|
||||
function formatRemaining(seconds: number | null): string {
|
||||
if (seconds == null) return '—';
|
||||
if (seconds < 60) return `≈ ${Math.max(1, Math.round(seconds))} s`;
|
||||
return `≈ ${Math.round(seconds / 60)} min`;
|
||||
}
|
||||
|
||||
/** Title with an italic accent word — two explicit keys, no string surgery */
|
||||
function SplitTitle({ base, accent }: { base: string; accent: string }) {
|
||||
return <>{base} <span className="italic">{accent}</span></>;
|
||||
}
|
||||
|
||||
/* ── Page ────────────────────────────────────────────────────────── */
|
||||
export default function TranslatePage() {
|
||||
const upload = useFileUpload();
|
||||
@@ -75,13 +70,12 @@ export default function TranslatePage() {
|
||||
const submit = useTranslationSubmit();
|
||||
const { error: showError } = useNotification();
|
||||
const { t } = useI18n();
|
||||
const { data: currentUser } = useUser();
|
||||
const isPaid = !!currentUser?.tier && currentUser.tier !== 'free';
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropzoneInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pdfMode, setPdfMode] = useState<'layout' | 'text_only'>('layout');
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [recentJobs, setRecentJobs] = useState<RecentJob[]>([]);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
|
||||
@@ -128,6 +122,11 @@ export default function TranslatePage() {
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}, [submit.status, submit.isSubmitting]);
|
||||
|
||||
// Recent jobs (client-side history) — loaded on mount, refreshed when a job completes
|
||||
useEffect(() => {
|
||||
setRecentJobs(getRecentJobs());
|
||||
}, [submit.status]);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!upload.file || !config.isConfigValid) return;
|
||||
const cfg = config.getConfig();
|
||||
@@ -142,30 +141,37 @@ export default function TranslatePage() {
|
||||
await handleTranslate();
|
||||
};
|
||||
|
||||
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
|
||||
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
|
||||
const handleDownload = async () => {
|
||||
if (!submit.jobId) return;
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
|
||||
if (!response.ok) return;
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let downloadFilename = 'translated_document';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
||||
if (match?.[1]) downloadFilename = match[1];
|
||||
} else if (submit.fileName) {
|
||||
const ext = submit.fileName.split('.').pop() || '';
|
||||
const base = submit.fileName.replace(/\.[^.]+$/, '');
|
||||
downloadFilename = `${base}_translated.${ext}`;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
|
||||
if (!response.ok) {
|
||||
showError({ title: t('dashboard.translate.error.title'), description: t('translate.downloadFailed') });
|
||||
return;
|
||||
}
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let downloadFilename = 'translated_document';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
||||
if (match?.[1]) downloadFilename = match[1];
|
||||
} else if (submit.fileName) {
|
||||
const ext = submit.fileName.split('.').pop() || '';
|
||||
const base = submit.fileName.replace(/\.[^.]+$/, '');
|
||||
downloadFilename = `${base}_translated.${ext}`;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = downloadFilename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch {
|
||||
showError({ title: t('dashboard.translate.error.title'), description: t('translate.downloadFailed') });
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = downloadFilename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
/* ── Derived states ──────────────────────────────────────────── */
|
||||
@@ -184,12 +190,12 @@ export default function TranslatePage() {
|
||||
const srcLangName = config.languages.find(l => l.code === config.sourceLang)?.name || config.sourceLang;
|
||||
const tgtLangName = config.languages.find(l => l.code === config.targetLang)?.name || config.targetLang;
|
||||
const activeStepIdx = getActiveStepIdx(submit.progress);
|
||||
const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
|
||||
const fileTypeButtons = [
|
||||
{ label: t('translate.fileType.word'), type: 'word' as const, icon: <FileText size={11} className="text-blue-500" /> },
|
||||
{ label: t('translate.fileType.excel'), type: 'excel' as const, icon: <FileSpreadsheet size={11} className="text-green-500" /> },
|
||||
{ label: t('translate.fileType.slides'), type: 'slides' as const, icon: <Presentation size={11} className="text-orange-500" /> },
|
||||
{ label: t('translate.fileType.pdf'), type: 'pdf' as const, icon: <FileType size={11} className="text-red-500" /> },
|
||||
// Supported formats — informational chips, not file injections
|
||||
const formatChips = [
|
||||
{ label: t('translate.fileType.word'), icon: <FileText size={11} className="text-blue-500" /> },
|
||||
{ label: t('translate.fileType.excel'), icon: <FileSpreadsheet size={11} className="text-green-500" /> },
|
||||
{ label: t('translate.fileType.slides'), icon: <Presentation size={11} className="text-orange-500" /> },
|
||||
{ label: t('translate.fileType.pdf'), icon: <FileType size={11} className="text-red-500" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -202,11 +208,7 @@ export default function TranslatePage() {
|
||||
<>
|
||||
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.processing')}</span>
|
||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||||
{(() => {
|
||||
const full = t('translate.header.aiActive');
|
||||
const i = full.lastIndexOf(' ');
|
||||
return <>{i === -1 ? full : <>{full.slice(0, i)} <span className="italic">{full.slice(i + 1)}</span></>}</>;
|
||||
})()}
|
||||
<SplitTitle base={t('translate.header.aiActiveTitle')} accent={t('translate.header.aiActiveAccent')} />
|
||||
</h1>
|
||||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
||||
{t('translate.header.aiActiveDesc')}
|
||||
@@ -216,11 +218,7 @@ export default function TranslatePage() {
|
||||
<>
|
||||
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.completed')}</span>
|
||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||||
{(() => {
|
||||
const full = t('translate.header.completedTitle');
|
||||
const i = full.lastIndexOf(' ');
|
||||
return <>{i === -1 ? full : <>{full.slice(0, i)} <span className="italic">{full.slice(i + 1)}</span></>}</>;
|
||||
})()}
|
||||
<SplitTitle base={t('translate.header.completedTitleBase')} accent={t('translate.header.completedTitleAccent')} />
|
||||
</h1>
|
||||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed truncate max-w-xl">
|
||||
{submit.fileName}
|
||||
@@ -228,13 +226,9 @@ export default function TranslatePage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="accent-pill mb-4 block w-fit">{t('translate.header.proSpace')}</span>
|
||||
<span className="accent-pill mb-4 block w-fit">{t('translate.header.workspace')}</span>
|
||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||||
{(() => {
|
||||
const full = t('translate.header.translateDoc');
|
||||
const i = full.lastIndexOf(' ');
|
||||
return <>{i === -1 ? full : <>{full.slice(0, i)} <span className="italic">{full.slice(i + 1)}</span></>}</>;
|
||||
})()}
|
||||
<SplitTitle base={t('translate.header.translateDocBase')} accent={t('translate.header.translateDocAccent')} />
|
||||
</h1>
|
||||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
||||
{t('translate.header.translateDocDesc')}
|
||||
@@ -254,13 +248,23 @@ export default function TranslatePage() {
|
||||
{/* ── UPLOAD STATE: Editorial Dropzone ──────────────── */}
|
||||
{showUpload && (
|
||||
<div
|
||||
className="relative bg-white border-2 border-dashed border-brand-accent/15 dark:border-white/10 rounded-[32px] p-12 flex flex-col items-center justify-center text-center group cursor-pointer hover:border-brand-accent/40 dark:hover:border-brand-accent/40 hover:bg-brand-muted/10 dark:hover:bg-brand-muted/5 transition-all shadow-editorial dark:bg-[#141414]"
|
||||
className="relative bg-white border-2 border-dashed border-brand-accent/15 dark:border-white/10 rounded-[32px] p-12 flex flex-col items-center justify-center text-center group cursor-pointer hover:border-brand-accent/40 dark:hover:border-brand-accent/40 hover:bg-brand-muted/10 dark:hover:bg-brand-muted/5 transition-all shadow-editorial dark:bg-[#141414] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent/60 focus-visible:border-brand-accent/60"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('translate.upload.ariaDropzone')}
|
||||
aria-describedby="translate-format-hint"
|
||||
onDragOver={upload.handleDragOver}
|
||||
onDragLeave={upload.handleDragLeave}
|
||||
onDrop={upload.handleDrop}
|
||||
onClick={() => dropzoneInputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
dropzoneInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-4 right-4 text-[8px] font-bold uppercase tracking-widest text-brand-dark/30 dark:text-white/30 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/[0.03] dark:border-white/[0.03]">
|
||||
<div className="absolute top-4 right-4 text-[10px] font-bold uppercase tracking-widest text-brand-dark/50 dark:text-white/50 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/[0.03] dark:border-white/[0.03]">
|
||||
{t('translate.upload.nativeFormat')}
|
||||
</div>
|
||||
|
||||
@@ -271,21 +275,19 @@ export default function TranslatePage() {
|
||||
<h3 className="text-xl font-bold tracking-tight mb-2 text-brand-dark dark:text-white uppercase">
|
||||
{t('landing.translate.dropHere')}
|
||||
</h3>
|
||||
<p className="text-xs text-brand-dark/40 dark:text-white/40 mb-8 font-medium">
|
||||
<p className="text-xs text-brand-dark/50 dark:text-white/50 mb-8 font-medium">
|
||||
{t('landing.translate.supportedFormats')}
|
||||
</p>
|
||||
|
||||
{/* Simulated file triggers */}
|
||||
<div className="flex flex-wrap justify-center gap-2.5" onClick={(e) => e.stopPropagation()}>
|
||||
{fileTypeButtons.map(f => (
|
||||
<button
|
||||
key={f.type}
|
||||
type="button"
|
||||
onClick={() => upload.setMockFile(f.type)}
|
||||
className="flex items-center gap-2 px-3.5 py-2 bg-brand-muted dark:bg-white/10 rounded-xl text-[9px] font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60 border border-transparent hover:border-brand-accent/20 dark:hover:border-brand-accent/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
{/* Supported formats — informational chips */}
|
||||
<div id="translate-format-hint" className="flex flex-wrap justify-center gap-2.5">
|
||||
{formatChips.map(f => (
|
||||
<span
|
||||
key={f.label}
|
||||
className="flex items-center gap-2 px-3.5 py-2 bg-brand-muted dark:bg-white/10 rounded-xl text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-transparent"
|
||||
>
|
||||
{f.icon} {f.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -296,10 +298,37 @@ export default function TranslatePage() {
|
||||
accept=".xlsx,.docx,.pptx,.pdf"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── RECENT JOBS: client-side history with review access ── */}
|
||||
{showUpload && recentJobs.length > 0 && (
|
||||
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-[0.18em] text-brand-dark/50 dark:text-white/50 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
||||
{t('translate.recent.title')}
|
||||
</h4>
|
||||
<ul className="divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{recentJobs.slice(0, 4).map((job) => (
|
||||
<li key={job.jobId} className="flex items-center justify-between gap-3 py-3">
|
||||
<span className="flex min-w-0 items-center gap-2.5 text-xs font-semibold text-brand-dark dark:text-white">
|
||||
<FileText className="size-3.5 shrink-0 text-brand-accent" />
|
||||
<span className="truncate">{job.fileName || job.jobId}</span>
|
||||
</span>
|
||||
<Link
|
||||
href={`/dashboard/reviews/${job.jobId}`}
|
||||
className="shrink-0 rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/25 hover:bg-brand-accent/10 transition-colors"
|
||||
>
|
||||
{t('translate.recent.review')}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CONFIGURING STATE: File indicator ──────────────── */}
|
||||
{showConfiguring && (
|
||||
<div className="editorial-card p-8 bg-white border-none shadow-editorial dark:bg-[#141414] space-y-6">
|
||||
@@ -308,7 +337,7 @@ export default function TranslatePage() {
|
||||
</h4>
|
||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
||||
{upload.error && <p className="mt-2 text-xs text-destructive">{upload.error}</p>}
|
||||
{upload.error && <p className="mt-2 text-xs text-destructive">{t(upload.error)}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -319,10 +348,10 @@ export default function TranslatePage() {
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
onClick={handleTranslate}
|
||||
className={cn(
|
||||
'w-full py-4 text-[10px] font-bold uppercase tracking-[0.25em] flex items-center justify-center gap-2 rounded-2xl transition-all shadow-sm active:scale-98',
|
||||
'w-full py-4 text-xs font-bold uppercase tracking-[0.18em] flex items-center justify-center gap-2 rounded-2xl transition-all shadow-sm active:scale-98',
|
||||
config.isConfigValid && upload.file && !submit.isSubmitting
|
||||
? 'bg-brand-dark text-white hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark hover:shadow-xl cursor-pointer'
|
||||
: 'bg-brand-muted/70 text-brand-dark/25 dark:bg-white/5 dark:text-white/20 cursor-not-allowed border border-black/[0.03] dark:border-white/[0.03]'
|
||||
: 'bg-brand-muted/70 text-brand-dark/40 dark:bg-white/5 dark:text-white/30 cursor-not-allowed border border-black/[0.03] dark:border-white/[0.03]'
|
||||
)}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
@@ -333,18 +362,18 @@ export default function TranslatePage() {
|
||||
</button>
|
||||
|
||||
{!upload.file && (
|
||||
<p className="text-center text-[8px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest">{t('translate.pleaseLoadFile')}</p>
|
||||
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.pleaseLoadFile')}</p>
|
||||
)}
|
||||
{upload.file && !config.targetLang && (
|
||||
<p className="text-center text-[8px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest">{t('translate.chooseTargetLang')}</p>
|
||||
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.chooseTargetLang')}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between text-[7.5px] font-bold uppercase tracking-[0.15em] text-brand-dark/30 dark:text-white/30 border-t border-black/5 dark:border-white/5 pt-4">
|
||||
<div className="flex justify-between gap-4 text-[11px] font-semibold uppercase tracking-[0.06em] text-brand-dark/55 dark:text-white/55 border-t border-black/5 dark:border-white/5 pt-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ShieldCheck size={12} /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
|
||||
<ShieldCheck size={13} className="text-brand-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock size={12} /> {t('landing.translate.filesDeleted') || 'Fichiers supprimés post-traitement'}
|
||||
<Clock size={13} className="text-brand-accent" /> {t('landing.translate.filesDeleted') || 'Fichiers supprimés post-traitement'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,7 +390,7 @@ export default function TranslatePage() {
|
||||
<h3 className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||||
{t('translate.contextEngineActive')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">
|
||||
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1 truncate">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</p>
|
||||
</div>
|
||||
@@ -391,19 +420,18 @@ export default function TranslatePage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-end mt-12 pt-6">
|
||||
<span className="text-[10px] font-bold text-brand-dark/30 dark:text-white/30 uppercase tracking-[0.3em]">
|
||||
<span className="text-[11px] font-semibold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em]">
|
||||
{activeStepIdx < 2 ? t('translate.phase1') : t('translate.phase2')}
|
||||
</span>
|
||||
<span className="text-7xl font-serif font-medium text-brand-dark dark:text-white leading-none">
|
||||
<span className="text-7xl font-serif font-medium text-brand-dark dark:text-white leading-none" aria-live="polite">
|
||||
{Math.round(submit.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 pt-12 border-t border-black/5 dark:border-white/5">
|
||||
<StatBox icon={<FileText size={18} />} value={`${Math.round(submit.progress)}%`} label={t('translate.stat.segments')} />
|
||||
<StatBox icon={<Zap size={18} />} value="99.9%" label={t('translate.stat.precision')} />
|
||||
<StatBox icon={<Clock size={18} />} value="Turbo" label={t('translate.stat.speedLabel')} />
|
||||
<StatBox icon={<Activity size={18} />} value={formatElapsed(elapsed)} label={t('translate.stat.time')} />
|
||||
<div className="grid grid-cols-3 gap-4 pt-12 border-t border-black/5 dark:border-white/5">
|
||||
<StatBox icon={<Activity size={18} />} value={`${Math.round(submit.progress)}%`} label={t('translate.stat.progress')} />
|
||||
<StatBox icon={<Clock size={18} />} value={formatRemaining(submit.estimatedRemaining)} label={t('translate.stat.remaining')} />
|
||||
<StatBox icon={<Clock size={18} />} value={formatElapsed(elapsed)} label={t('translate.stat.elapsed')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -420,12 +448,12 @@ export default function TranslatePage() {
|
||||
<p className="text-[13px] font-bold uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
||||
{t('translate.header.completedTitle')}
|
||||
</p>
|
||||
<p className="text-[10px] text-brand-dark/40 dark:text-white/40 font-bold uppercase mt-1 tracking-widest max-w-[300px] truncate">
|
||||
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase mt-1 tracking-wider max-w-[300px] truncate">
|
||||
{submit.fileName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[9px] font-bold uppercase tracking-widest text-brand-accent border border-brand-accent/20 shadow-sm shrink-0">
|
||||
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/20 shadow-sm shrink-0">
|
||||
{t('translate.complete.masterQuality')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -438,9 +466,16 @@ export default function TranslatePage() {
|
||||
<Download size={28} className="group-hover:translate-y-1 transition-transform" />
|
||||
{t('translate.download')}
|
||||
</button>
|
||||
<Link
|
||||
href={`/dashboard/reviews/${submit.jobId}`}
|
||||
className="mb-6 flex items-center gap-2 rounded-2xl border border-brand-accent/30 px-6 py-3 text-xs font-bold uppercase tracking-[0.15em] text-brand-accent transition-colors hover:bg-brand-accent/10"
|
||||
>
|
||||
<Search size={14} />
|
||||
{t('translate.reviewCta')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleNewTranslation}
|
||||
className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark/30 hover:text-brand-dark dark:text-white/30 dark:hover:text-white transition-colors"
|
||||
className="text-[11px] font-bold uppercase tracking-[0.2em] text-brand-dark/50 hover:text-brand-dark dark:text-white/50 dark:hover:text-white transition-colors"
|
||||
>
|
||||
{t('translate.newTranslation')}
|
||||
</button>
|
||||
@@ -480,7 +515,8 @@ export default function TranslatePage() {
|
||||
)}
|
||||
<button
|
||||
onClick={handleNewTranslation}
|
||||
className="w-full py-4 border border-black/10 dark:border-white/10 rounded-2xl text-[10px] font-bold uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-all flex items-center justify-center gap-3 cursor-pointer hover:bg-brand-muted/30 dark:hover:bg-white/5"
|
||||
className="w-full py-4 border border-black/10 dark:border-white/10 rounded-2xl text-[11px] font-bold uppercase tracking-[0.2em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-all flex items-center justify-center gap-3 cursor-pointer hover:bg-brand-muted/30 dark:hover:bg-white/5"
|
||||
title={t('translate.leaveScreenHint')}
|
||||
>
|
||||
<Upload size={16} />
|
||||
{t('translate.uploadAnother')}
|
||||
@@ -500,7 +536,7 @@ export default function TranslatePage() {
|
||||
<div className="editorial-card bg-white dark:bg-[#141414] border-none shadow-editorial overflow-hidden flex flex-col lg:sticky lg:top-8 lg:max-h-[calc(100vh-6rem)]">
|
||||
{/* Scrollable config content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark/30 dark:text-white/30 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-[0.18em] text-brand-dark/50 dark:text-white/50 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
||||
{t('landing.translate.configuration') || 'Configuration'}
|
||||
</h4>
|
||||
|
||||
@@ -522,10 +558,10 @@ export default function TranslatePage() {
|
||||
{config.provider && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[9px] font-bold uppercase tracking-[0.1em]",
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-[0.1em]",
|
||||
config.mode === 'llm'
|
||||
? "bg-brand-accent/10 text-brand-accent border border-brand-accent/20"
|
||||
: "bg-brand-muted/50 text-brand-dark/40 dark:bg-white/5 dark:text-white/40 border border-transparent"
|
||||
: "bg-brand-muted/50 text-brand-dark/50 dark:bg-white/5 dark:text-white/50 border border-transparent"
|
||||
)}>
|
||||
{config.mode === 'llm' ? (
|
||||
<><Zap size={10} /> {t('dashboard.translate.modeAI')}</>
|
||||
@@ -534,7 +570,7 @@ export default function TranslatePage() {
|
||||
)}
|
||||
</span>
|
||||
{config.mode === 'classic' && config.isPro && (
|
||||
<span className="text-[9px] text-brand-dark/40 dark:text-white/40 italic font-medium leading-none">
|
||||
<span className="text-[10px] text-brand-dark/50 dark:text-white/50 italic font-medium leading-none">
|
||||
{t('dashboard.translate.glossaryLLMHint')}
|
||||
</span>
|
||||
)}
|
||||
@@ -557,7 +593,7 @@ export default function TranslatePage() {
|
||||
<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">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
||||
{t('dashboard.translate.translateImages') || "Traduire les images"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -568,16 +604,16 @@ export default function TranslatePage() {
|
||||
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">
|
||||
<span className="text-[10px] font-semibold uppercase text-brand-dark/50 dark:text-white/50 block">
|
||||
{t('translate.unavailableStandard')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-1">
|
||||
<span className="text-[7.5px] font-medium text-brand-dark/50 dark:text-white/50 block leading-normal">
|
||||
<span className="text-[10px] 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>
|
||||
@@ -587,13 +623,14 @@ export default function TranslatePage() {
|
||||
{/* PDF mode selector */}
|
||||
{isPdf && (
|
||||
<div className="space-y-2 text-left">
|
||||
<label className="text-[9px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-[0.15em] block mb-2">
|
||||
<label className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.15em] block mb-2">
|
||||
{t('dashboard.translate.pdfMode.title') || 'Mode PDF'}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('layout')}
|
||||
aria-pressed={pdfMode === 'layout'}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-2xl border p-3.5 text-start transition-all',
|
||||
pdfMode === 'layout'
|
||||
@@ -605,13 +642,14 @@ export default function TranslatePage() {
|
||||
<FileText className="size-3.5 text-brand-accent" />
|
||||
{t('dashboard.translate.pdfMode.preserveLayout') || 'Mise en page'}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[8px] text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-relaxed">
|
||||
{t('translate.preserveLayoutDesc')}
|
||||
<p className="mt-1.5 text-[10px] text-brand-dark/50 dark:text-white/50 font-medium leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.preserveLayoutDesc')}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('text_only')}
|
||||
aria-pressed={pdfMode === 'text_only'}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-2xl border p-3.5 text-start transition-all',
|
||||
pdfMode === 'text_only'
|
||||
@@ -623,8 +661,8 @@ export default function TranslatePage() {
|
||||
<Languages className="size-3.5 text-brand-accent" />
|
||||
{t('translate.textOnly')}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[8px] text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-relaxed">
|
||||
{t('translate.textOnlyDesc')}
|
||||
<p className="mt-1.5 text-[10px] text-brand-dark/50 dark:text-white/50 font-medium leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.textOnlyDesc')}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
@@ -638,8 +676,8 @@ export default function TranslatePage() {
|
||||
{/* ── MONITOR (processing) ────────────────────────────── */}
|
||||
{showProcessing && (
|
||||
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] mb-8 flex items-center gap-3 text-brand-dark/45 dark:text-white/45 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
||||
<div className="w-2 h-2 bg-brand-accent rounded-full animate-ping" />
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-[0.18em] mb-8 flex items-center gap-3 text-brand-dark/50 dark:text-white/50 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
||||
<div className="w-2 h-2 bg-brand-accent rounded-full animate-ping" aria-hidden="true" />
|
||||
{t('translate.monitor')}
|
||||
</h4>
|
||||
|
||||
@@ -655,10 +693,10 @@ export default function TranslatePage() {
|
||||
})()}
|
||||
</div>
|
||||
<div className="overflow-hidden text-left">
|
||||
<p className="text-[11px] font-bold truncate text-brand-dark dark:text-white">
|
||||
<p className="text-xs font-bold truncate text-brand-dark dark:text-white">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</p>
|
||||
<p className="text-[9px] text-brand-dark/45 dark:text-white/45 font-bold uppercase tracking-widest mt-1">
|
||||
<p className="text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1">
|
||||
{upload.file ? `${fmt(upload.file.size)} ` : ''}{(submit.fileName || upload.file?.name || '').split('.').pop()?.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -667,41 +705,29 @@ export default function TranslatePage() {
|
||||
|
||||
{/* Config summary */}
|
||||
<div className="space-y-6 mb-8 px-2 text-left">
|
||||
<div className="flex justify-between items-center text-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
|
||||
<span>Source</span>
|
||||
<span className="text-brand-dark dark:text-white">{srcLangName.toUpperCase()}</span>
|
||||
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
||||
<span>{t('translate.monitor.source')}</span>
|
||||
<span className="text-brand-dark dark:text-white normal-case">{srcLangName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
|
||||
<span>Cible</span>
|
||||
<span className="text-brand-accent">{tgtLangName.toUpperCase()}</span>
|
||||
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
||||
<span>{t('translate.monitor.target')}</span>
|
||||
<span className="text-brand-accent normal-case">{tgtLangName}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex justify-between items-center text-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
|
||||
<span>Moteur</span>
|
||||
<span className="text-brand-dark dark:text-white">{currentProvider.label.toUpperCase()}</span>
|
||||
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
||||
<span>{t('translate.monitor.engine')}</span>
|
||||
<span className="text-brand-dark dark:text-white normal-case">{currentProvider.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quality progress */}
|
||||
<div className="pt-6 border-t border-black/5 dark:border-white/5 text-left">
|
||||
<div className="flex justify-between text-[9px] font-bold uppercase tracking-[0.2em] mb-3">
|
||||
<span className="text-brand-dark/40 dark:text-white/40">{t('translate.layoutIntegrity')}</span>
|
||||
<span className="text-brand-accent">{t('translate.secureHundred')}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-brand-muted dark:bg-white/5 rounded-full overflow-hidden p-0.5">
|
||||
<div
|
||||
className="h-full bg-brand-accent rounded-full transition-all duration-700"
|
||||
style={{ width: `${Math.min(95, 40 + submit.progress * 0.55)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNewTranslation}
|
||||
className="w-full mt-12 py-4 border border-red-100 text-red-500 rounded-2xl text-[9px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-red-50 dark:border-red-950/20 dark:hover:bg-red-950/30 transition-all cursor-pointer"
|
||||
title={t('translate.leaveScreenHint')}
|
||||
className="w-full mt-8 py-3.5 border border-black/10 dark:border-white/10 text-brand-dark/50 dark:text-white/50 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-brand-muted/40 dark:hover:bg-white/5 hover:text-brand-dark dark:hover:text-white transition-all cursor-pointer"
|
||||
>
|
||||
{t('translate.cancelProcess')}
|
||||
<X size={13} />
|
||||
{t('translate.leaveScreen')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -709,35 +735,33 @@ export default function TranslatePage() {
|
||||
{/* ── SUMMARY (complete) ──────────────────────────────── */}
|
||||
{showComplete && (
|
||||
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] mb-8 flex items-center gap-3 text-brand-dark/45 dark:text-white/45 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-[0.18em] mb-8 flex items-center gap-3 text-brand-dark/50 dark:text-white/50 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
||||
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||
{t('translate.summary')}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-6 mb-8 px-2 text-left">
|
||||
<div className="flex justify-between items-center text-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
|
||||
<span>Source</span>
|
||||
<span className="text-brand-dark dark:text-white">{srcLangName.toUpperCase()}</span>
|
||||
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
||||
<span>{t('translate.monitor.source')}</span>
|
||||
<span className="text-brand-dark dark:text-white normal-case">{srcLangName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
|
||||
<span>Cible</span>
|
||||
<span className="text-brand-accent">{tgtLangName.toUpperCase()}</span>
|
||||
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
||||
<span>{t('translate.monitor.target')}</span>
|
||||
<span className="text-brand-accent normal-case">{tgtLangName}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex justify-between items-center text-[9px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40">
|
||||
<span>Moteur</span>
|
||||
<span className="text-brand-dark dark:text-white">{currentProvider.label.toUpperCase()}</span>
|
||||
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
|
||||
<span>{t('translate.monitor.engine')}</span>
|
||||
<span className="text-brand-dark dark:text-white normal-case">{currentProvider.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-black/5 dark:border-white/5 text-left">
|
||||
<div className="flex justify-between text-[9px] font-bold uppercase tracking-[0.2em] mb-3">
|
||||
<span className="text-brand-dark/40 dark:text-white/40">{t('translate.layoutIntegrity')}</span>
|
||||
<span className="text-brand-accent">{t('translate.okHundred')}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-brand-muted dark:bg-white/5 rounded-full overflow-hidden p-0.5">
|
||||
<div className="h-full bg-brand-accent rounded-full" style={{ width: '100%' }} />
|
||||
<div className="flex justify-between items-center text-[11px] font-semibold text-brand-dark/50 dark:text-white/50">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ShieldCheck size={13} className="text-brand-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -745,41 +769,6 @@ export default function TranslatePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── MEMENTO PROMO BANNER — hidden for paying users ────── */}
|
||||
{!isPaid && (showUpload || showConfiguring || showFailed) && (
|
||||
<a
|
||||
href={t('memento.url', { defaultValue: 'https://memento-note.com/' })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mt-12 editorial-card p-10 bg-white dark:bg-[#141414] border-none shadow-editorial flex flex-col md:flex-row items-center gap-8 group overflow-hidden relative cursor-pointer"
|
||||
>
|
||||
<div className="absolute -right-20 -top-20 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl group-hover:bg-brand-accent/10 transition-colors pointer-events-none" />
|
||||
|
||||
<div className="w-16 h-16 bg-brand-dark dark:bg-brand-accent rounded-[24px] flex items-center justify-center text-white dark:text-brand-dark text-3xl font-black shadow-2xl shrink-0 group-hover:rotate-6 transition-transform duration-500">
|
||||
M
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="accent-pill !px-2.5 !py-0.5 text-[8px] italic">Ecosystème Wordly</span>
|
||||
<h3 className="text-xl font-bold tracking-tight text-brand-dark dark:text-white uppercase">{t('memento.title')}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light leading-relaxed max-w-2xl">
|
||||
{t('memento.slogan')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 shrink-0 w-full md:w-auto">
|
||||
<span className="premium-button px-8 py-3.5 text-[9px] uppercase tracking-widest !rounded-xl text-center">
|
||||
{t('memento.ctaFree')}
|
||||
</span>
|
||||
<span className="px-8 py-3.5 border border-black/5 bg-brand-muted text-brand-dark/40 rounded-xl text-[9px] font-bold uppercase tracking-widest hover:text-brand-dark dark:border-white/5 dark:bg-white/5 dark:text-white/40 dark:hover:text-white hover:bg-brand-muted/70 transition-all text-center">
|
||||
{t('memento.ctaMore')}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Mobile Sticky Action Bar (visible on mobile, hidden on lg) */}
|
||||
{(showUpload || showConfiguring) && (
|
||||
<div className="block lg:hidden fixed bottom-0 left-0 right-0 z-40 p-4 bg-white/90 dark:bg-[#141414]/90 backdrop-blur-md border-t border-black/5 dark:border-white/5 shadow-lg">
|
||||
@@ -787,10 +776,10 @@ export default function TranslatePage() {
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
onClick={handleTranslate}
|
||||
className={cn(
|
||||
'w-full py-4 text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 rounded-xl transition-all active:scale-98',
|
||||
'w-full py-4 text-xs font-bold uppercase tracking-[0.15em] flex items-center justify-center gap-2 rounded-xl transition-all active:scale-98',
|
||||
config.isConfigValid && upload.file && !submit.isSubmitting
|
||||
? 'bg-brand-dark text-white hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark cursor-pointer'
|
||||
: 'bg-brand-muted/70 text-brand-dark/25 dark:bg-white/5 dark:text-white/20 cursor-not-allowed'
|
||||
: 'bg-brand-muted/70 text-brand-dark/40 dark:bg-white/5 dark:text-white/30 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
@@ -830,48 +819,13 @@ function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: ()
|
||||
);
|
||||
}
|
||||
|
||||
/** Pipeline stepper */
|
||||
function PipelineStepper({ activeIdx, t }: { activeIdx: number; t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
{PIPELINE_STEP_KEYS.map((stepKey, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
const isDone = i < activeIdx;
|
||||
const Icon = PIPELINE_ICONS[i];
|
||||
return (
|
||||
<div key={stepKey} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEP_KEYS.length - 1 ? 1 : 'none' }}>
|
||||
<div className="flex items-center w-full">
|
||||
<div className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
|
||||
isDone
|
||||
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
|
||||
: isActive
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{isDone ? <CheckCircle2 className="size-4" /> : <Icon className={cn('size-4', isActive && 'animate-pulse')} />}
|
||||
</div>
|
||||
{i < PIPELINE_STEP_KEYS.length - 1 && (
|
||||
<div className={cn('mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500', i < activeIdx ? 'bg-primary' : 'bg-border')} />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn('text-[11px] font-medium transition-colors', isDone || isActive ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{t(stepKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small stat box */
|
||||
function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
|
||||
return (
|
||||
<div className="p-6 bg-brand-muted/30 rounded-3xl text-center border border-transparent hover:border-brand-accent/10 transition-all dark:bg-white/5 dark:border-white/5">
|
||||
<div className="text-brand-accent flex justify-center mb-4">{icon}</div>
|
||||
<p className="text-[12px] font-black text-brand-dark mb-1 uppercase tracking-tight dark:text-white">{value}</p>
|
||||
<p className="text-[11px] font-black text-brand-dark/50 uppercase tracking-[0.15em] dark:text-white/50">{label}</p>
|
||||
<p className="text-sm font-black text-brand-dark mb-1 uppercase tracking-tight dark:text-white">{value}</p>
|
||||
<p className="text-[10px] font-black text-brand-dark/50 uppercase tracking-[0.12em] dark:text-white/50">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type SupportedFormat = 'xlsx' | 'docx' | 'pptx';
|
||||
|
||||
export interface FileUploadState {
|
||||
file: File | null;
|
||||
/** i18n key (from ERROR_MESSAGES) — render through t() */
|
||||
error: string | null;
|
||||
isDragOver: boolean;
|
||||
}
|
||||
@@ -12,7 +13,6 @@ export interface FileUploadActions {
|
||||
handleDragLeave: (e: React.DragEvent) => void;
|
||||
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
removeFile: () => void;
|
||||
setMockFile: (type: 'word' | 'excel' | 'slides' | 'pdf') => void;
|
||||
}
|
||||
|
||||
export interface UseFileUploadReturn extends FileUploadState, FileUploadActions {}
|
||||
|
||||
@@ -4,9 +4,10 @@ import type { UseFileUploadReturn } from './types';
|
||||
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
// i18n keys — rendered through t() so the message follows the UI locale.
|
||||
export const ERROR_MESSAGES = {
|
||||
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf',
|
||||
FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
|
||||
INVALID_FORMAT: 'fileUploader.error.invalidFormat',
|
||||
FILE_TOO_LARGE: 'fileUploader.error.tooLarge',
|
||||
} as const;
|
||||
|
||||
export function useFileUpload(): UseFileUploadReturn {
|
||||
@@ -69,31 +70,6 @@ export function useFileUpload(): UseFileUploadReturn {
|
||||
}
|
||||
}, [validateFile]);
|
||||
|
||||
const setMockFile = useCallback((type: 'word' | 'excel' | 'slides' | 'pdf') => {
|
||||
const mockDetails: Record<string, { name: string; mime: string; size: number }> = {
|
||||
word: { name: 'rapport_strategique_q3.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', size: 12.4 * 1024 * 1024 },
|
||||
excel: { name: 'bilan_consolidé_2025.xlsx', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 4.2 * 1024 * 1024 },
|
||||
slides: { name: 'keynote_produit_wordly.pptx', mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', size: 8.0 * 1024 * 1024 },
|
||||
pdf: { name: 'cahier_des_charges_v2.pdf', mime: 'application/pdf', size: 15.1 * 1024 * 1024 },
|
||||
};
|
||||
|
||||
const details = mockDetails[type];
|
||||
if (details) {
|
||||
// Create a dummy blob representing the file size
|
||||
const dummyBlob = new Blob(['x'.repeat(100)], { type: details.mime });
|
||||
// Create a custom File object that overrides size properties
|
||||
const mockFile = new File([dummyBlob], details.name, {
|
||||
type: details.mime,
|
||||
lastModified: Date.now()
|
||||
});
|
||||
// Override the readonly size property for display purposes
|
||||
Object.defineProperty(mockFile, 'size', { value: details.size });
|
||||
|
||||
setFile(mockFile);
|
||||
setError(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeFile = useCallback(() => {
|
||||
setFile(null);
|
||||
setError(null);
|
||||
@@ -109,6 +85,5 @@ export function useFileUpload(): UseFileUploadReturn {
|
||||
handleDragLeave,
|
||||
handleFileSelect,
|
||||
removeFile,
|
||||
setMockFile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,41 @@ import { API_BASE } from '@/lib/config';
|
||||
const POLLING_INTERVAL_MS = 2000;
|
||||
const MAX_POLLING_FAILURES = 3;
|
||||
|
||||
/* ── Job persistence: survive a refresh mid-translation ─────────── */
|
||||
const ACTIVE_JOB_KEY = 'wordly:activeJob';
|
||||
const RECENT_JOBS_KEY = 'wordly:recentJobs';
|
||||
const MAX_RECENT_JOBS = 8;
|
||||
const ACTIVE_JOB_TTL_MS = 24 * 60 * 60 * 1000; // discard stale entries after 24h
|
||||
|
||||
interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
|
||||
|
||||
export interface RecentJob { jobId: string; fileName: string; completedAt: number }
|
||||
|
||||
export function getRecentJobs(): RecentJob[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(RECENT_JOBS_KEY) ?? '[]');
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistActiveJob(job: StoredJob | null) {
|
||||
try {
|
||||
if (!job) localStorage.removeItem(ACTIVE_JOB_KEY);
|
||||
else localStorage.setItem(ACTIVE_JOB_KEY, JSON.stringify(job));
|
||||
} catch { /* storage unavailable — session-only fallback */ }
|
||||
}
|
||||
|
||||
function pushRecentJob(job: RecentJob) {
|
||||
try {
|
||||
const list = getRecentJobs().filter(j => j.jobId !== job.jobId);
|
||||
list.unshift(job);
|
||||
localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(list.slice(0, MAX_RECENT_JOBS)));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<TranslationStatus>('idle');
|
||||
@@ -30,6 +65,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
// If we relied on state, the setInterval callback would always read the initial
|
||||
// value of pollingFailures (0) and never reach MAX_POLLING_FAILURES.
|
||||
const pollingFailuresRef = useRef(0);
|
||||
const resumeAttemptedRef = useRef(false);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingIntervalRef.current) {
|
||||
@@ -89,6 +125,9 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
if (job.status === 'failed') {
|
||||
setError(job.error_message || 'Translation failed');
|
||||
}
|
||||
if (job.status === 'completed') {
|
||||
pushRecentJob({ jobId: id, fileName: job.file_name || '', completedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Polling error:', err);
|
||||
@@ -198,6 +237,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopPolling();
|
||||
persistActiveJob(null);
|
||||
setJobId(null);
|
||||
setStatus('idle');
|
||||
setProgress(0);
|
||||
@@ -209,6 +249,34 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
setPollingFailures(0);
|
||||
}, [stopPolling]);
|
||||
|
||||
// Persist the active job whenever it changes so a refresh can resume polling.
|
||||
useEffect(() => {
|
||||
if (jobId) persistActiveJob({ jobId, fileName, savedAt: Date.now() });
|
||||
}, [jobId, fileName]);
|
||||
|
||||
// Resume an interrupted job once on mount (page refreshed mid-translation).
|
||||
useEffect(() => {
|
||||
if (resumeAttemptedRef.current) return;
|
||||
resumeAttemptedRef.current = true;
|
||||
try {
|
||||
const raw = localStorage.getItem(ACTIVE_JOB_KEY);
|
||||
if (!raw) return;
|
||||
const stored: StoredJob = JSON.parse(raw);
|
||||
if (!stored?.jobId || Date.now() - stored.savedAt > ACTIVE_JOB_TTL_MS) {
|
||||
persistActiveJob(null);
|
||||
return;
|
||||
}
|
||||
setJobId(stored.jobId);
|
||||
if (stored.fileName) setFileName(stored.fileName);
|
||||
setStatus('processing');
|
||||
setProgress(0);
|
||||
startPolling(stored.jobId);
|
||||
} catch {
|
||||
persistActiveJob(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
|
||||
@@ -16,6 +16,9 @@ import { API_BASE } from "@/lib/config";
|
||||
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
// Enterprise contact — single place to update the sales address.
|
||||
const SUPPORT_EMAIL = "contact@wordly.art";
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Types
|
||||
───────────────────────────────────────────── */
|
||||
@@ -359,7 +362,7 @@ export default function PricingPage() {
|
||||
return;
|
||||
}
|
||||
if (planId === "enterprise") {
|
||||
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
|
||||
window.location.href = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(t('pricing.enterprise.subject'))}`;
|
||||
return;
|
||||
}
|
||||
if (planId === currentPlan) return;
|
||||
@@ -387,7 +390,7 @@ export default function PricingPage() {
|
||||
const backendMessage = data.message ?? data.error ?? data.data?.error;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(backendMessage || `Erreur serveur ${res.status}`);
|
||||
throw new Error(backendMessage || t('pricing.error.server', { status: res.status }));
|
||||
}
|
||||
|
||||
if (url) {
|
||||
@@ -431,7 +434,7 @@ export default function PricingPage() {
|
||||
|
||||
const url = data.data?.url ?? data.url;
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message ?? data.error ?? `Erreur serveur ${res.status}`);
|
||||
throw new Error(data.message ?? data.error ?? t('pricing.error.server', { status: res.status }));
|
||||
}
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
@@ -488,7 +491,7 @@ export default function PricingPage() {
|
||||
)}>
|
||||
<span className="text-lg">{toastMsg.type === 'ok' ? t('pricing.okSymbol') : t('pricing.errSymbol')}</span>
|
||||
<p className="text-sm flex-1">{toastMsg.text}</p>
|
||||
<button onClick={() => setToastMsg(null)} className="text-muted-foreground hover:text-foreground text-lg leading-none">×</button>
|
||||
<button onClick={() => setToastMsg(null)} aria-label={t('pricing.toast.close')} className="text-muted-foreground hover:text-foreground text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -499,9 +502,9 @@ export default function PricingPage() {
|
||||
<span className="text-[10px] font-black tracking-[0.3em] opacity-40">{t('pricing.header.badge')}</span>
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black uppercase tracking-tighter mb-6 leading-none">
|
||||
{t('pricing.header.title').split(' ').slice(0, -2).join(' ')}{" "}
|
||||
{t('pricing.header.titleBase')}{" "}
|
||||
<span className="text-accent">
|
||||
{t('pricing.header.title').split(' ').slice(-2).join(' ')}
|
||||
{t('pricing.header.titleAccent')}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-foreground/40 font-medium text-xl max-w-2xl mx-auto leading-relaxed">
|
||||
|
||||
@@ -293,7 +293,7 @@ const FormatsSection = () => {
|
||||
<section id="formats" className="py-40 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-24">
|
||||
<span className="accent-pill mb-6 block w-fit">COMPATIBILITÉ</span>
|
||||
<span className="accent-pill mb-6 block w-fit">{t('landing.formats.pill')}</span>
|
||||
<h2 className="text-4xl md:text-5xl text-brand-dark dark:text-white mb-6 leading-tight font-serif font-medium tracking-tight">{t('landing.formats.title')} <span className="italic">{t('landing.formats.title2')}</span></h2>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 max-w-xl text-lg font-light">{t('landing.formats.subtitle')}</p>
|
||||
</div>
|
||||
@@ -430,29 +430,14 @@ const Footer = () => {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 lg:col-span-2">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark dark:text-white mb-10">{t('landing.footer.resources')}</h4>
|
||||
<ul className="space-y-6 text-xs text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-none">
|
||||
<li><a href="#" className="hover:text-brand-dark dark:hover:text-white transition-colors">Documentation</a></li>
|
||||
<li><a href="#" className="hover:text-brand-dark dark:hover:text-white transition-colors">API</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 lg:col-span-2">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark dark:text-white mb-10">{t('landing.footer.legal')}</h4>
|
||||
<ul className="space-y-6 text-xs text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-none">
|
||||
<li><a href="#" className="hover:text-brand-dark dark:hover:text-white transition-colors">Privacy</a></li>
|
||||
<li><a href="#" className="hover:text-brand-dark dark:hover:text-white transition-colors">Terms</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-10 border-t border-black/5 dark:border-white/5 flex flex-col md:flex-row justify-between items-center gap-10">
|
||||
<div className="text-[10px] font-bold text-brand-dark/20 dark:text-white/20 uppercase tracking-[0.4em]">
|
||||
{t('landing.footer.rights')}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-brand-dark/20 dark:text-white/20 uppercase tracking-widest">
|
||||
v2.4.0 • Built for excellence
|
||||
<div className="text-[10px] font-mono text-brand-dark/30 dark:text-white/30 uppercase tracking-widest">
|
||||
wordly.art
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function SiteFooter() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<footer className="border-t border-border py-6 text-center text-xs text-muted-foreground">
|
||||
<div className="mx-auto max-w-5xl px-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<span>{t('landing.footer.rights')}</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/pricing" className="hover:text-foreground transition-colors">
|
||||
{t('landing.nav.pricing')}
|
||||
</Link>
|
||||
<Link href="/pricing#terms" className="hover:text-foreground transition-colors">
|
||||
{t('layout.footer.terms')}
|
||||
</Link>
|
||||
<Link href="/pricing#privacy" className="hover:text-foreground transition-colors">
|
||||
{t('layout.footer.privacy')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Languages } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function SiteHeader() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Languages className="size-4 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-base font-semibold tracking-tight text-foreground">
|
||||
Wordly
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/pricing">{t('landing.nav.pricing')}</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/pricing#api">{t('layout.nav.apiAccess')}</Link>
|
||||
</Button>
|
||||
<div className="mx-2 h-4 w-px bg-border" />
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/dashboard">{t('landing.nav.login')}</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
<div className="md:hidden">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/dashboard">{t('landing.nav.login')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -10,20 +10,21 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useI18n, type Locale } from "@/lib/i18n";
|
||||
|
||||
const languages: { value: Locale; label: string; flag: string }[] = [
|
||||
{ value: "en", label: "English", flag: "🇬🇧" },
|
||||
{ value: "fr", label: "Français", flag: "🇫🇷" },
|
||||
{ value: "es", label: "Español", flag: "🇪🇸" },
|
||||
{ value: "de", label: "Deutsch", flag: "🇩🇪" },
|
||||
{ value: "pt", label: "Português", flag: "🇧🇷" },
|
||||
{ value: "it", label: "Italiano", flag: "🇮🇹" },
|
||||
{ value: "nl", label: "Nederlands", flag: "🇳🇱" },
|
||||
{ value: "ru", label: "Русский", flag: "🇷🇺" },
|
||||
{ value: "ja", label: "日本語", flag: "🇯🇵" },
|
||||
{ value: "ko", label: "한국어", flag: "🇰🇷" },
|
||||
{ value: "zh", label: "中文", flag: "🇨🇳" },
|
||||
{ value: "ar", label: "العربية", flag: "🇸🇦" },
|
||||
{ value: "fa", label: "فارسی", flag: "🇮🇷" },
|
||||
// Language names in their own script — flags are country symbols, not languages.
|
||||
const languages: { value: Locale; label: string }[] = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "es", label: "Español" },
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "pt", label: "Português" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
{ value: "nl", label: "Nederlands" },
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "ja", label: "日本語" },
|
||||
{ value: "ko", label: "한국어" },
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "ar", label: "العربية" },
|
||||
{ value: "fa", label: "فارسی" },
|
||||
];
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
@@ -31,16 +32,15 @@ interface LanguageSwitcherProps {
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
||||
const { locale, setLocale } = useI18n();
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const current = languages.find((l) => l.value === locale) ?? languages[0];
|
||||
|
||||
return (
|
||||
<Select value={locale} onValueChange={(v) => setLocale(v as Locale)}>
|
||||
<SelectTrigger className={className} aria-label="Change language">
|
||||
<SelectTrigger className={className} aria-label={t('langSelector.changeLanguage')}>
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{current.flag}</span>
|
||||
<span>{current.label}</span>
|
||||
</span>
|
||||
</SelectValue>
|
||||
@@ -49,7 +49,6 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
@@ -117,13 +117,15 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(notificationVariants({ variant, size, position }), className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Progress Bar for Auto-close */}
|
||||
{autoClose && !loading && (
|
||||
<div className="absolute top-0 left-0 h-1 bg-white/20">
|
||||
<div
|
||||
<div
|
||||
className="h-full bg-white/40 transition-all duration-100 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
@@ -178,7 +180,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
||||
@@ -158,8 +158,17 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const t = useCallback(
|
||||
(key: string, params?: TranslationParams): string => {
|
||||
const msg = messages[key] ?? enMessages[key] ?? key;
|
||||
return interpolate(msg, params);
|
||||
const msg = messages[key] ?? enMessages[key];
|
||||
if (msg === undefined) {
|
||||
// Missing everywhere: honour an explicit defaultValue, else return ""
|
||||
// so `t(key) || fallback` call sites work and raw keys never reach the UI.
|
||||
return params && "defaultValue" in params
|
||||
? String(params.defaultValue)
|
||||
: "";
|
||||
}
|
||||
const rest = { ...params };
|
||||
delete rest.defaultValue;
|
||||
return interpolate(msg, rest);
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -41,7 +40,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "اكتشف Memento",
|
||||
"memento.slogan": "Memento ليس مجرد تطبيق ملاحظات. إنه نظام بيئي ذكي يربط ويحلل ويطور أفكارك في الوقت الفعلي باستخدام 6 وكلاء ذكاء اصطناعي وبحث دلالي متقدم.",
|
||||
"memento.ctaFree": "ابدأ مجاناً",
|
||||
"memento.ctaMore": "اعرف المزيد",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -44,7 +43,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Entdecken Sie Memento",
|
||||
"memento.slogan": "Memento ist mehr als nur eine Notizen-App. Es ist ein intelligentes Ökosystem, das Ihre Ideen in Echtzeit verbindet, analysiert und weiterentwickelt – mit 6 KI-Agenten und semantischer Suche.",
|
||||
"memento.ctaFree": "Kostenlos starten",
|
||||
"memento.ctaMore": "Mehr erfahren",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -112,5 +112,6 @@
|
||||
"dashboard.checkoutSyncError": "Error syncing payment.",
|
||||
"dashboard.networkRefresh": "Network error. Please refresh the page.",
|
||||
"dashboard.continueToTranslate": "Continue to translation",
|
||||
"dashboard.nav.teams": "Team"
|
||||
"dashboard.nav.teams": "Team",
|
||||
"dashboard.loading": "Loading…"
|
||||
}
|
||||
|
||||
@@ -27,5 +27,7 @@
|
||||
"fileUploader.foundTexts": "Found {count} texts to translate",
|
||||
"fileUploader.translatingItem": "Translating {current}/{total}: \\\"{preview}\\\"",
|
||||
"fileUploader.reconstructing": "Reconstructing document...",
|
||||
"fileUploader.translatingLocally": "Translating locally with WebLLM..."
|
||||
"fileUploader.translatingLocally": "Translating locally with WebLLM...",
|
||||
"fileUploader.error.invalidFormat": "Unsupported format. Accepted: .xlsx, .docx, .pptx, .pdf",
|
||||
"fileUploader.error.tooLarge": "File too large (max 50 MB)"
|
||||
}
|
||||
|
||||
@@ -189,5 +189,36 @@
|
||||
"glossaries.detail.importedTitle": "Imported",
|
||||
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||
"glossaries.detail.importErrorTitle": "Read error",
|
||||
"glossaries.detail.importErrorDesc": "Unable to read the file."
|
||||
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||
"glossaries.titleBase": "Glossaries &",
|
||||
"glossaries.titleAccent": "Context",
|
||||
"glossaries.new": "New glossary",
|
||||
"glossaries.info.title": "To use a glossary in a translation:",
|
||||
"glossaries.info.body": "click “Use” on the desired card. You will be redirected to the Translate page with this glossary already selected.",
|
||||
"glossaries.info.cta": "Go translate",
|
||||
"glossaries.context.title": "Context guidelines",
|
||||
"glossaries.context.desc": "These instructions are automatically sent to the AI before each translation in Pro LLM mode.",
|
||||
"glossaries.context.unsaved": "Unsaved",
|
||||
"glossaries.context.active": "Active",
|
||||
"glossaries.context.inactive": "Inactive",
|
||||
"glossaries.context.suggestions": "Quick suggestions — click to add",
|
||||
"glossaries.context.placeholder": "e.g. You are translating technical HVAC documents. Use precise engineering terminology and a professional tone…",
|
||||
"glossaries.context.chars": "{count} characters",
|
||||
"glossaries.context.noPrompt": "No guideline saved",
|
||||
"glossaries.context.clear": "Clear all",
|
||||
"glossaries.context.saving": "Saving…",
|
||||
"glossaries.context.saved": "Saved",
|
||||
"glossaries.context.save": "Save",
|
||||
"glossaries.activeTarget": "Active translation:",
|
||||
"glossaries.searchPlaceholder": "Search a glossary…",
|
||||
"glossaries.countMany": "{count} glossaries — click a card to edit",
|
||||
"glossaries.countNone": "No glossary yet — create one to get started",
|
||||
"glossaries.termsOne": "1 term",
|
||||
"glossaries.termsMany": "{count} terms",
|
||||
"glossaries.badge.otherLang": "Other language",
|
||||
"glossaries.card.edit": "Edit",
|
||||
"glossaries.card.use": "Use",
|
||||
"glossaries.createFirst": "Create my first glossary",
|
||||
"glossaries.noResults": "No results for “{query}”",
|
||||
"glossaries.card.terms": "terms"
|
||||
}
|
||||
|
||||
@@ -16,15 +16,16 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
import providerTheme from "./providerTheme.json";
|
||||
import register from "./register.json";
|
||||
import resetPassword from "./resetPassword.json";
|
||||
import reviews from "./reviews.json";
|
||||
import services from "./services.json";
|
||||
import settings from "./settings.json";
|
||||
import teams from "./teams.json";
|
||||
import translate from "./translate.json";
|
||||
import translateComplete from "./translateComplete.json";
|
||||
|
||||
@@ -44,15 +45,16 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
...providerTheme,
|
||||
...register,
|
||||
...resetPassword,
|
||||
...reviews,
|
||||
...services,
|
||||
...settings,
|
||||
...teams,
|
||||
...translate,
|
||||
...translateComplete,
|
||||
};
|
||||
|
||||
@@ -148,5 +148,6 @@
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX or PDF files supported",
|
||||
"landing.translate.aiAnalysis": "Active AI Analysis",
|
||||
"landing.translate.processing": "Processing",
|
||||
"landing.translate.preservingLayout": "Your layout is being preserved"
|
||||
"landing.translate.preservingLayout": "Your layout is being preserved",
|
||||
"landing.formats.pill": "COMPATIBILITY"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"langSelector.noResults": "No results",
|
||||
"langSelector.source": "Source",
|
||||
"langSelector.target": "Target",
|
||||
"langSelector.swap": "Swap"
|
||||
"langSelector.swap": "Swap",
|
||||
"langSelector.changeLanguage": "Change language"
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Discover Memento",
|
||||
"memento.slogan": "Memento is more than just a notes application. It's an intelligent ecosystem that connects, analyzes, and develops your ideas in real-time using 6 AI agents and cutting-edge semantic search.",
|
||||
"memento.ctaFree": "Start for free",
|
||||
"memento.ctaMore": "Learn more",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -147,5 +147,10 @@
|
||||
"pricing.toast.paymentError": "Error creating payment.",
|
||||
"pricing.dashboard": "Dashboard",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.error.server": "Server error {status}",
|
||||
"pricing.enterprise.subject": "Enterprise plan enquiry",
|
||||
"pricing.toast.close": "Close",
|
||||
"pricing.header.titleBase": "A plan for",
|
||||
"pricing.header.titleAccent": "every need"
|
||||
}
|
||||
|
||||
32
frontend/src/lib/i18n/messages/en/reviews.json
Normal file
32
frontend/src/lib/i18n/messages/en/reviews.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"reviews.title": "Review",
|
||||
"reviews.subtitle": "{file} — {total} segments ({pending} to review, {approved} approved, {edited} edited)",
|
||||
"reviews.approveAll": "Approve all",
|
||||
"reviews.importXliff": "Import XLIFF",
|
||||
"reviews.rebuild": "Rebuild & download",
|
||||
"reviews.col.source": "Source",
|
||||
"reviews.col.target": "Translation",
|
||||
"reviews.col.status": "Status",
|
||||
"reviews.status.approved": "Approved",
|
||||
"reviews.status.edited": "Edited",
|
||||
"reviews.status.pending": "To review",
|
||||
"reviews.save": "Save",
|
||||
"reviews.cancel": "Cancel",
|
||||
"reviews.emptyCell": "— empty —",
|
||||
"reviews.action.edit": "Edit the translation",
|
||||
"reviews.action.approve": "Approve as is",
|
||||
"reviews.action.reset": "Set back to review",
|
||||
"reviews.approvedTitle": "Segments approved",
|
||||
"reviews.approvedDesc": "{count} segment(s) approved.",
|
||||
"reviews.rebuiltTitle": "Document rebuilt",
|
||||
"reviews.rebuiltDesc": "{count} reviewed segment(s) applied.",
|
||||
"reviews.rebuildFailed": "Rebuild failed",
|
||||
"reviews.xliffExportFailed": "XLIFF export failed",
|
||||
"reviews.xliffImported": "XLIFF imported",
|
||||
"reviews.xliffImportedDesc": "{count} segment(s) updated.",
|
||||
"reviews.xliffImportFailed": "XLIFF import failed",
|
||||
"reviews.error.title": "Error",
|
||||
"reviews.error.load": "Loading failed",
|
||||
"reviews.error.update": "Could not update",
|
||||
"reviews.error.download": "Download failed (HTTP {status})"
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
"services.adminOnly.title": "Provider configuration is admin-only",
|
||||
"services.adminOnly.desc": "API keys, model selection, and provider activation are managed exclusively by the administrator in the admin panel. You never need to enter an API key.",
|
||||
"services.fallback.google.label": "Google Translate",
|
||||
"services.fallback.google.desc": "Fast translation, 130+ languages"
|
||||
"services.fallback.google.desc": "Fast translation, 130+ languages",
|
||||
"services.pill": "Engines"
|
||||
}
|
||||
|
||||
@@ -16,5 +16,8 @@
|
||||
"settings.cache.clear": "Clear cache",
|
||||
"settings.formats.excel.name": "Excel",
|
||||
"settings.formats.word.name": "Word",
|
||||
"settings.formats.powerpoint.name": "PowerPoint"
|
||||
"settings.formats.powerpoint.name": "PowerPoint",
|
||||
"settings.pill": "Configuration",
|
||||
"settings.cache.confirm": "Confirm and reload",
|
||||
"settings.cache.cancelClear": "Cancel"
|
||||
}
|
||||
|
||||
26
frontend/src/lib/i18n/messages/en/teams.json
Normal file
26
frontend/src/lib/i18n/messages/en/teams.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"teams.title": "Workspaces",
|
||||
"teams.subtitle": "Share your glossaries with your team (Business plan — 5 seats).",
|
||||
"teams.needsBusiness": "Workspaces require the Business plan. Upgrade to collaborate with your team.",
|
||||
"teams.newWorkspace": "New workspace",
|
||||
"teams.newPlaceholder": "e.g. Translation team",
|
||||
"teams.create": "Create",
|
||||
"teams.created": "Workspace created",
|
||||
"teams.createdDesc": "You are the owner.",
|
||||
"teams.createFailed": "Could not create",
|
||||
"teams.memberAdded": "Member added",
|
||||
"teams.addFailed": "Could not add",
|
||||
"teams.removeFailed": "Could not remove",
|
||||
"teams.removeMember": "Remove from workspace",
|
||||
"teams.owner": "Owner",
|
||||
"teams.seats": "{count} / {limit} seats",
|
||||
"teams.seatsUnlimited": "{count} seats (unlimited)",
|
||||
"teams.role.member": "Member",
|
||||
"teams.role.admin": "Admin",
|
||||
"teams.addMember": "Add a member (email)",
|
||||
"teams.emailPlaceholder": "colleague@company.com",
|
||||
"teams.invite": "Invite",
|
||||
"teams.empty": "No workspace yet — create the first one above.",
|
||||
"teams.error.title": "Error",
|
||||
"teams.error.load": "Could not load"
|
||||
}
|
||||
@@ -89,5 +89,25 @@
|
||||
"translate.preserveLayoutDesc": "Preserve the layout",
|
||||
"translate.textOnly": "Text only",
|
||||
"translate.textOnlyDesc": "Fast translation of text only",
|
||||
"translate.unavailableStandard": "Unavailable in Standard mode (AI only)"
|
||||
"translate.unavailableStandard": "Unavailable in Standard mode (AI only)",
|
||||
"translate.header.aiActiveTitle": "AI analysis",
|
||||
"translate.header.aiActiveAccent": "active",
|
||||
"translate.header.completedTitleBase": "Translation",
|
||||
"translate.header.completedTitleAccent": "completed",
|
||||
"translate.header.translateDocBase": "Translate",
|
||||
"translate.header.translateDocAccent": "a document",
|
||||
"translate.header.workspace": "Translation workspace",
|
||||
"translate.monitor.source": "Source",
|
||||
"translate.monitor.target": "Target",
|
||||
"translate.monitor.engine": "Engine",
|
||||
"translate.stat.progress": "progress",
|
||||
"translate.stat.remaining": "time left",
|
||||
"translate.stat.elapsed": "elapsed",
|
||||
"translate.leaveScreen": "Back to start",
|
||||
"translate.leaveScreenHint": "The translation keeps running in the background.",
|
||||
"translate.reviewCta": "Review segment by segment",
|
||||
"translate.downloadFailed": "The download failed. Please try again.",
|
||||
"translate.recent.title": "Recent translations",
|
||||
"translate.recent.review": "Review",
|
||||
"translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -44,7 +43,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Descubre Memento",
|
||||
"memento.slogan": "Memento no es solo una aplicación de notas. Es un ecosistema inteligente que conecta, analiza y desarrolla tus ideas en tiempo real usando 6 agentes de IA y búsqueda semántica avanzada.",
|
||||
"memento.ctaFree": "Empezar gratis",
|
||||
"memento.ctaMore": "Saber más",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -41,7 +40,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Memento را کشف کنید",
|
||||
"memento.slogan": "Memento فقط یک برنامه یادداشت نیست. یک اکوسیستم هوشمند است که با استفاده از ۶ عامل هوش مصنوعی و جستجوی معنایی پیشرفته، ایدههای شما را در زمان واقعی متصل، تحلیل و توسعه میدهد.",
|
||||
"memento.ctaFree": "رایگان شروع کنید",
|
||||
"memento.ctaMore": "بیشتر بدانید",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -112,5 +112,6 @@
|
||||
"dashboard.checkoutSyncError": "Erreur lors de la synchronisation du paiement.",
|
||||
"dashboard.networkRefresh": "Erreur réseau. Veuillez rafraîchir la page.",
|
||||
"dashboard.continueToTranslate": "Continuer vers la traduction",
|
||||
"dashboard.nav.teams": "Équipe"
|
||||
"dashboard.nav.teams": "Équipe",
|
||||
"dashboard.loading": "Chargement…"
|
||||
}
|
||||
|
||||
@@ -27,5 +27,7 @@
|
||||
"fileUploader.foundTexts": "{count} textes trouvés à traduire",
|
||||
"fileUploader.translatingItem": "Traduction {current}/{total} : « {preview} »",
|
||||
"fileUploader.reconstructing": "Reconstruction du document…",
|
||||
"fileUploader.translatingLocally": "Traduction locale avec WebLLM…"
|
||||
"fileUploader.translatingLocally": "Traduction locale avec WebLLM…",
|
||||
"fileUploader.error.invalidFormat": "Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf",
|
||||
"fileUploader.error.tooLarge": "Fichier trop volumineux (max 50 Mo)"
|
||||
}
|
||||
|
||||
@@ -189,5 +189,36 @@
|
||||
"glossaries.detail.importedTitle": "Importé",
|
||||
"glossaries.detail.importedDesc": "{count} termes importés.",
|
||||
"glossaries.detail.importErrorTitle": "Erreur de lecture",
|
||||
"glossaries.detail.importErrorDesc": "Impossible de lire le fichier."
|
||||
"glossaries.detail.importErrorDesc": "Impossible de lire le fichier.",
|
||||
"glossaries.titleBase": "Glossaires &",
|
||||
"glossaries.titleAccent": "Contexte",
|
||||
"glossaries.new": "Nouveau glossaire",
|
||||
"glossaries.info.title": "Pour utiliser un glossaire dans une traduction :",
|
||||
"glossaries.info.body": "cliquez sur « Utiliser » sur la carte souhaitée. Vous serez redirigé sur la page Traduire avec ce glossaire déjà sélectionné.",
|
||||
"glossaries.info.cta": "Aller traduire",
|
||||
"glossaries.context.title": "Consignes de contexte",
|
||||
"glossaries.context.desc": "Ces instructions sont envoyées automatiquement à l'IA avant chaque traduction en mode Pro LLM.",
|
||||
"glossaries.context.unsaved": "Non sauvegardé",
|
||||
"glossaries.context.active": "Actif",
|
||||
"glossaries.context.inactive": "Inactif",
|
||||
"glossaries.context.suggestions": "Suggestions rapides — cliquez pour ajouter",
|
||||
"glossaries.context.placeholder": "Ex : Vous traduisez des documents techniques HVAC. Utilisez une terminologie d'ingénierie précise et un ton professionnel…",
|
||||
"glossaries.context.chars": "{count} caractères",
|
||||
"glossaries.context.noPrompt": "Aucune consigne enregistrée",
|
||||
"glossaries.context.clear": "Tout effacer",
|
||||
"glossaries.context.saving": "Enregistrement…",
|
||||
"glossaries.context.saved": "Enregistré",
|
||||
"glossaries.context.save": "Enregistrer",
|
||||
"glossaries.activeTarget": "Traduction active :",
|
||||
"glossaries.searchPlaceholder": "Rechercher un glossaire…",
|
||||
"glossaries.countMany": "{count} glossaires — cliquez sur une carte pour la modifier",
|
||||
"glossaries.countNone": "Aucun glossaire — créez-en un pour commencer",
|
||||
"glossaries.termsOne": "1 terme",
|
||||
"glossaries.termsMany": "{count} termes",
|
||||
"glossaries.badge.otherLang": "Autre langue",
|
||||
"glossaries.card.edit": "Éditer",
|
||||
"glossaries.card.use": "Utiliser",
|
||||
"glossaries.createFirst": "Créer mon premier glossaire",
|
||||
"glossaries.noResults": "Aucun résultat pour « {query} »",
|
||||
"glossaries.card.terms": "termes"
|
||||
}
|
||||
|
||||
@@ -16,15 +16,16 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
import providerTheme from "./providerTheme.json";
|
||||
import register from "./register.json";
|
||||
import resetPassword from "./resetPassword.json";
|
||||
import reviews from "./reviews.json";
|
||||
import services from "./services.json";
|
||||
import settings from "./settings.json";
|
||||
import teams from "./teams.json";
|
||||
import translate from "./translate.json";
|
||||
import translateComplete from "./translateComplete.json";
|
||||
|
||||
@@ -44,15 +45,16 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
...providerTheme,
|
||||
...register,
|
||||
...resetPassword,
|
||||
...reviews,
|
||||
...services,
|
||||
...settings,
|
||||
...teams,
|
||||
...translate,
|
||||
...translateComplete,
|
||||
};
|
||||
|
||||
@@ -148,5 +148,6 @@
|
||||
"landing.translate.supportedFormats": "Fichiers DOCX, XLSX, PPTX ou PDF supportés",
|
||||
"landing.translate.aiAnalysis": "Analyse IA Active",
|
||||
"landing.translate.processing": "Traitement en cours",
|
||||
"landing.translate.preservingLayout": "Votre mise en page est en cours de préservation"
|
||||
"landing.translate.preservingLayout": "Votre mise en page est en cours de préservation",
|
||||
"landing.formats.pill": "COMPATIBILITÉ"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"langSelector.noResults": "Aucun résultat",
|
||||
"langSelector.source": "Source",
|
||||
"langSelector.target": "Cible",
|
||||
"langSelector.swap": "Inverser"
|
||||
"langSelector.swap": "Inverser",
|
||||
"langSelector.changeLanguage": "Changer la langue"
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Découvrez Memento",
|
||||
"memento.slogan": "Memento n'est pas qu'une simple application de notes. C'est un écosystème intelligent qui connecte, analyse et développe vos idées en temps réel grâce à 6 types d'agents IA et une recherche sémantique de pointe.",
|
||||
"memento.ctaFree": "Commencer gratuitement",
|
||||
"memento.ctaMore": "Voir plus",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -147,5 +147,10 @@
|
||||
"pricing.toast.paymentError": "Erreur lors de la création du paiement.",
|
||||
"pricing.dashboard": "Tableau de bord",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.error.server": "Erreur serveur {status}",
|
||||
"pricing.enterprise.subject": "Offre Enterprise",
|
||||
"pricing.toast.close": "Fermer",
|
||||
"pricing.header.titleBase": "Un forfait pour",
|
||||
"pricing.header.titleAccent": "chaque besoin"
|
||||
}
|
||||
|
||||
32
frontend/src/lib/i18n/messages/fr/reviews.json
Normal file
32
frontend/src/lib/i18n/messages/fr/reviews.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"reviews.title": "Relecture",
|
||||
"reviews.subtitle": "{file} — {total} segments ({pending} à relire, {approved} approuvés, {edited} modifiés)",
|
||||
"reviews.approveAll": "Tout approuver",
|
||||
"reviews.importXliff": "Importer XLIFF",
|
||||
"reviews.rebuild": "Reconstruire et télécharger",
|
||||
"reviews.col.source": "Source",
|
||||
"reviews.col.target": "Traduction",
|
||||
"reviews.col.status": "Statut",
|
||||
"reviews.status.approved": "Approuvé",
|
||||
"reviews.status.edited": "Modifié",
|
||||
"reviews.status.pending": "À relire",
|
||||
"reviews.save": "Enregistrer",
|
||||
"reviews.cancel": "Annuler",
|
||||
"reviews.emptyCell": "— vide —",
|
||||
"reviews.action.edit": "Modifier la traduction",
|
||||
"reviews.action.approve": "Approuver tel quel",
|
||||
"reviews.action.reset": "Remettre à relire",
|
||||
"reviews.approvedTitle": "Segments approuvés",
|
||||
"reviews.approvedDesc": "{count} segment(s) approuvé(s).",
|
||||
"reviews.rebuiltTitle": "Document reconstruit",
|
||||
"reviews.rebuiltDesc": "{count} segment(s) relu(s) appliqué(s).",
|
||||
"reviews.rebuildFailed": "Reconstruction impossible",
|
||||
"reviews.xliffExportFailed": "Export XLIFF échoué",
|
||||
"reviews.xliffImported": "XLIFF importé",
|
||||
"reviews.xliffImportedDesc": "{count} segment(s) mis à jour.",
|
||||
"reviews.xliffImportFailed": "Import XLIFF échoué",
|
||||
"reviews.error.title": "Erreur",
|
||||
"reviews.error.load": "Erreur de chargement",
|
||||
"reviews.error.update": "Mise à jour impossible",
|
||||
"reviews.error.download": "Téléchargement échoué (HTTP {status})"
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
"services.adminOnly.title": "La configuration des fournisseurs est réservée à l'administrateur",
|
||||
"services.adminOnly.desc": "Les clés API, la sélection de modèle et l'activation des fournisseurs sont gérées exclusivement par l'administrateur. Vous n'avez jamais besoin de saisir de clé API.",
|
||||
"services.fallback.google.label": "Google Traduction",
|
||||
"services.fallback.google.desc": "Traduction rapide, 130+ langues"
|
||||
"services.fallback.google.desc": "Traduction rapide, 130+ langues",
|
||||
"services.pill": "Moteurs"
|
||||
}
|
||||
|
||||
@@ -16,5 +16,8 @@
|
||||
"settings.cache.clear": "Effacer le cache",
|
||||
"settings.formats.excel.name": "Excel",
|
||||
"settings.formats.word.name": "Word",
|
||||
"settings.formats.powerpoint.name": "PowerPoint"
|
||||
"settings.formats.powerpoint.name": "PowerPoint",
|
||||
"settings.pill": "Configuration",
|
||||
"settings.cache.confirm": "Confirmer et recharger",
|
||||
"settings.cache.cancelClear": "Annuler"
|
||||
}
|
||||
|
||||
26
frontend/src/lib/i18n/messages/fr/teams.json
Normal file
26
frontend/src/lib/i18n/messages/fr/teams.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"teams.title": "Espaces de travail",
|
||||
"teams.subtitle": "Partagez vos glossaires avec votre équipe (plan Business — 5 sièges).",
|
||||
"teams.needsBusiness": "Les espaces de travail nécessitent le plan Business. Passez au plan supérieur pour collaborer avec votre équipe.",
|
||||
"teams.newWorkspace": "Nouvel espace de travail",
|
||||
"teams.newPlaceholder": "Ex. Équipe traduction",
|
||||
"teams.create": "Créer",
|
||||
"teams.created": "Espace créé",
|
||||
"teams.createdDesc": "Vous êtes propriétaire.",
|
||||
"teams.createFailed": "Création impossible",
|
||||
"teams.memberAdded": "Membre ajouté",
|
||||
"teams.addFailed": "Ajout impossible",
|
||||
"teams.removeFailed": "Retrait impossible",
|
||||
"teams.removeMember": "Retirer du workspace",
|
||||
"teams.owner": "Propriétaire",
|
||||
"teams.seats": "{count} / {limit} sièges",
|
||||
"teams.seatsUnlimited": "{count} sièges (illimité)",
|
||||
"teams.role.member": "Membre",
|
||||
"teams.role.admin": "Admin",
|
||||
"teams.addMember": "Ajouter un membre (e-mail)",
|
||||
"teams.emailPlaceholder": "collegue@entreprise.com",
|
||||
"teams.invite": "Inviter",
|
||||
"teams.empty": "Aucun espace pour l'instant — créez le premier ci-dessus.",
|
||||
"teams.error.title": "Erreur",
|
||||
"teams.error.load": "Chargement impossible"
|
||||
}
|
||||
@@ -89,5 +89,25 @@
|
||||
"translate.preserveLayoutDesc": "Conserver la mise en page",
|
||||
"translate.textOnly": "Texte brut",
|
||||
"translate.textOnlyDesc": "Traduction rapide du texte uniquement",
|
||||
"translate.unavailableStandard": "Indisponible en mode Standard (IA uniquement)"
|
||||
"translate.unavailableStandard": "Indisponible en mode Standard (IA uniquement)",
|
||||
"translate.header.aiActiveTitle": "Analyse IA",
|
||||
"translate.header.aiActiveAccent": "active",
|
||||
"translate.header.completedTitleBase": "Traduction",
|
||||
"translate.header.completedTitleAccent": "terminée",
|
||||
"translate.header.translateDocBase": "Traduire",
|
||||
"translate.header.translateDocAccent": "un document",
|
||||
"translate.header.workspace": "Espace de traduction",
|
||||
"translate.monitor.source": "Source",
|
||||
"translate.monitor.target": "Cible",
|
||||
"translate.monitor.engine": "Moteur",
|
||||
"translate.stat.progress": "progression",
|
||||
"translate.stat.remaining": "temps restant",
|
||||
"translate.stat.elapsed": "temps écoulé",
|
||||
"translate.leaveScreen": "Revenir à l'accueil",
|
||||
"translate.leaveScreenHint": "La traduction continue en arrière-plan.",
|
||||
"translate.reviewCta": "Relire segment par segment",
|
||||
"translate.downloadFailed": "Le téléchargement a échoué. Réessayez.",
|
||||
"translate.recent.title": "Traductions récentes",
|
||||
"translate.recent.review": "Relire",
|
||||
"translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -44,7 +43,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Scopri Memento",
|
||||
"memento.slogan": "Memento non è solo un'app di note. È un ecosistema intelligente che connette, analizza e sviluppa le tue idee in tempo reale usando 6 agenti IA e ricerca semantica avanzata.",
|
||||
"memento.ctaFree": "Inizia gratis",
|
||||
"memento.ctaMore": "Scopri di più",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -44,7 +43,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Mementoを発見",
|
||||
"memento.slogan": "Mementoは単なるメモアプリではありません。6つのAIエージェントと高度なセマンティック検索を使用して、アイデアをリアルタイムで接続、分析、発展させるインテリジェントなエコシステムです。",
|
||||
"memento.ctaFree": "無料で始める",
|
||||
"memento.ctaMore": "詳しく見る",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -44,7 +43,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Memento 발견하기",
|
||||
"memento.slogan": "Memento는 단순한 메모 앱이 아닙니다. 6개의 AI 에이전트와 최첨단 시맨틱 검색을 사용하여 아이디어를 실시간으로 연결, 분석, 발전시키는 지능형 생태계입니다.",
|
||||
"memento.ctaFree": "무료로 시작",
|
||||
"memento.ctaMore": "자세히 보기",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -44,7 +43,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Ontdek Memento",
|
||||
"memento.slogan": "Memento is meer dan alleen een notitie-app. Het is een intelligent ecosysteem dat uw ideeën in realtime verbindt, analyseert en verder ontwikkelt met 6 AI-agents en geavanceerde semantische zoekfuncties.",
|
||||
"memento.ctaFree": "Gratis beginnen",
|
||||
"memento.ctaMore": "Meer informatie",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -44,7 +43,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Descubra o Memento",
|
||||
"memento.slogan": "O Memento é mais do que uma aplicação de notas. É um ecossistema inteligente que conecta, analisa e desenvolve suas ideias em tempo real usando 6 agentes de IA e busca semântica avançada.",
|
||||
"memento.ctaFree": "Começar grátis",
|
||||
"memento.ctaMore": "Saiba mais",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -44,7 +43,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "Откройте Memento",
|
||||
"memento.slogan": "Memento — это больше, чем приложение для заметок. Это интеллектуальная экосистема, которая связывает, анализирует и развивает ваши идеи в реальном времени с помощью 6 ИИ-агентов и продвинутого семантического поиска.",
|
||||
"memento.ctaFree": "Начать бесплатно",
|
||||
"memento.ctaMore": "Узнать больше",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import landing from "./landing.json";
|
||||
import langSelector from "./langSelector.json";
|
||||
import layout from "./layout.json";
|
||||
import login from "./login.json";
|
||||
import memento from "./memento.json";
|
||||
import pricing from "./pricing.json";
|
||||
import profile from "./profile.json";
|
||||
import providerSelector from "./providerSelector.json";
|
||||
@@ -41,7 +40,6 @@ const messages: Record<string, string> = {
|
||||
...langSelector,
|
||||
...layout,
|
||||
...login,
|
||||
...memento,
|
||||
...pricing,
|
||||
...profile,
|
||||
...providerSelector,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"memento.title": "探索 Memento",
|
||||
"memento.slogan": "Memento 不仅仅是一个笔记应用。它是一个智能生态系统,利用 6 个 AI 代理和先进的语义搜索,实时连接、分析和发展您的想法。",
|
||||
"memento.ctaFree": "免费开始",
|
||||
"memento.ctaMore": "了解更多",
|
||||
"memento.url": "https://memento-note.com/"
|
||||
}
|
||||
Reference in New Issue
Block a user