fix(ui): wave 2 from critique re-run — AA contrast, unified PageHeader, checkout confirm
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s
Nav: teams/settings/services back out of the nav until product-ready (user decision); teams page was already half-finished (UUID member display is a backend limitation). Payment: explicit confirmation dialog (plan, amount, billing period, Stripe note) before any redirect; ?plan= URL now pre-opens the dialog instead of triggering a silent checkout. Contrast AA sweep (71 replacements): functional micro-labels raised to >=60-65% opacity and >=10px across sidebar, header, pricing, landing; decorative all-caps 'interface' label removed. Design unification: new PageHeader component (accent pill + serif base/accent title) applied to settings, services, reviews, teams; pricing header returned to the editorial voice (serif + accent pill); dead GlossaryCard deleted. i18n residuals: suggestion chips, 'Standard' provider label, notification close, model-combobox strings extracted (+broken bg-surface/border-border- subtle tokens fixed); ~45 new keys EN+FR; 13 dead keys removed; zero missing keys verified. Reviews: icon-only row actions now carry visible text; 'Approve all' is two-step armed-confirm. Accelerators: Ctrl/Cmd+Enter submits; arrow-key navigation in the language combobox; recent-jobs history cap 8->20 with per-job download; source=target config rejected; empty 'Master Quality' badge removed. Cookie consent: reopenable via footer link, emoji replaced with drawn icon. Verified: build exit 0, vitest 9/9, eslint 64 errors = previous level, 0 missing i18n keys.
This commit is contained in:
@@ -70,16 +70,34 @@ function Combobox({
|
||||
|
||||
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
|
||||
|
||||
const handleListboxKeys = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
e.stopPropagation();
|
||||
closeAndRefocus();
|
||||
return;
|
||||
}
|
||||
if (!open || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
|
||||
e.preventDefault();
|
||||
const options = Array.from(
|
||||
ref.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? []
|
||||
);
|
||||
if (options.length === 0) return;
|
||||
const idx = options.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next = e.key === 'ArrowDown'
|
||||
? options[Math.min(options.length - 1, idx + 1)]
|
||||
: options[Math.max(0, idx - 1)];
|
||||
// from the search input, ArrowDown goes to the first option
|
||||
(idx === -1 && document.activeElement === inputRef.current
|
||||
? options[e.key === 'ArrowDown' ? 0 : options.length - 1]
|
||||
: next
|
||||
).focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative text-left"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
e.stopPropagation();
|
||||
closeAndRefocus();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleListboxKeys}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
|
||||
@@ -155,10 +155,10 @@ export function ProviderSelector({
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={cn(
|
||||
"text-[9.5px] font-black uppercase tracking-tight truncate flex-1",
|
||||
isSelected ? 'text-brand-accent' : 'text-brand-dark/40 dark:text-white/40'
|
||||
"text-[10px] font-black uppercase tracking-tight truncate flex-1",
|
||||
isSelected ? 'text-brand-accent' : 'text-brand-dark/60 dark:text-white/60'
|
||||
)}>
|
||||
Standard
|
||||
{t('providerSelector.standard')}
|
||||
</span>
|
||||
{isSelected && <div className="w-1.5 h-1.5 rounded-full bg-brand-accent animate-pulse shrink-0 ml-1" />}
|
||||
</div>
|
||||
|
||||
@@ -142,13 +142,13 @@ export default function TranslatePage() {
|
||||
};
|
||||
|
||||
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
|
||||
const handleDownload = async () => {
|
||||
if (!submit.jobId) return;
|
||||
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
|
||||
if (!jobId) return;
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
|
||||
if (!response.ok) {
|
||||
showError({ title: t('dashboard.translate.error.title'), description: t('translate.downloadFailed') });
|
||||
return;
|
||||
@@ -180,6 +180,19 @@ export default function TranslatePage() {
|
||||
const isCompleted = submit.status === 'completed';
|
||||
const isFailed = submit.status === 'failed';
|
||||
|
||||
// Ctrl/Cmd+Enter launches the translation from anywhere on the page
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && upload.file && config.isConfigValid && !submit.isSubmitting && !isProcessing) {
|
||||
e.preventDefault();
|
||||
handleTranslate();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [upload.file, config.isConfigValid, submit.isSubmitting, isProcessing]);
|
||||
|
||||
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
|
||||
const showConfiguring = isConfiguring;
|
||||
const showProcessing = isProcessing;
|
||||
@@ -311,18 +324,27 @@ export default function TranslatePage() {
|
||||
{t('translate.recent.title')}
|
||||
</h4>
|
||||
<ul className="divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{recentJobs.slice(0, 4).map((job) => (
|
||||
{recentJobs.slice(0, 6).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>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownload(job.jobId)}
|
||||
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-black/10 dark:border-white/10 hover:bg-brand-muted/50 transition-colors"
|
||||
>
|
||||
<Download size={11} className="inline" />
|
||||
</button>
|
||||
<Link
|
||||
href={`/dashboard/reviews/${job.jobId}`}
|
||||
className="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>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -446,21 +468,18 @@ export default function TranslatePage() {
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-[13px] font-bold uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
||||
{t('translate.header.completedTitle')}
|
||||
{t('translate.header.completedTitleBase')} {t('translate.header.completedTitleAccent')}
|
||||
</p>
|
||||
<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-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/20 shadow-sm shrink-0">
|
||||
{t('translate.complete.masterQuality')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-16 bg-brand-muted/20 dark:bg-white/5 rounded-[40px] border border-black/5 dark:border-white/5">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
onClick={() => handleDownload()}
|
||||
className="premium-button px-24 py-6 text-xl !rounded-full flex items-center gap-6 mb-8 group cursor-pointer hover:scale-[1.02] active:scale-95"
|
||||
>
|
||||
<Download size={28} className="group-hover:translate-y-1 transition-transform" />
|
||||
|
||||
@@ -233,8 +233,10 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
||||
const isConfigValid = useMemo(() => {
|
||||
if (!hasFile || !targetLang) return false;
|
||||
if (!provider) return false;
|
||||
// Translating a language into itself is always a mistake
|
||||
if (sourceLang !== 'auto' && sourceLang === targetLang) return false;
|
||||
return true;
|
||||
}, [hasFile, targetLang, provider]);
|
||||
}, [hasFile, targetLang, provider, sourceLang]);
|
||||
|
||||
const getConfig = useCallback((): TranslationConfig => ({
|
||||
sourceLang,
|
||||
|
||||
@@ -15,7 +15,7 @@ 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 MAX_RECENT_JOBS = 20;
|
||||
const ACTIVE_JOB_TTL_MS = 24 * 60 * 60 * 1000; // discard stale entries after 24h
|
||||
|
||||
interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
|
||||
|
||||
Reference in New Issue
Block a user