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