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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user