Files
office_translator/frontend/src/app/dashboard/translate/useTranslationSubmit.ts
sepehr 50047ea8a2
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
fix(ui): design critique overhaul — a11y, honest metrics, i18n repair (critique 2026-08-30)
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.
2026-08-30 21:44:53 +02:00

301 lines
9.7 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import type {
UseTranslationSubmitReturn,
TranslationConfig,
TranslationStatus,
TranslationSubmitResponse,
TranslationStatusResponse
} from './types';
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');
const [progress, setProgress] = useState(0);
const [currentStep, setCurrentStep] = useState('');
const [error, setError] = useState<string | null>(null);
const [estimatedRemaining, setEstimatedRemaining] = useState<number | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [pollingFailures, setPollingFailures] = useState(0);
const [isPolling, setIsPolling] = useState(false);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const isPollingRef = useRef(false);
// Use a ref for failure count to avoid stale closure in the interval callback.
// 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) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
isPollingRef.current = false;
setIsPolling(false);
}, []);
const pollProgress = useCallback(async (id: string) => {
if (isPollingRef.current) return;
isPollingRef.current = 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/translations/${id}`, { headers });
if (!response.ok) {
if (response.status === 404) {
stopPolling();
setIsSubmitting(false);
setStatus('failed');
setError('Translation job not found');
return;
}
// 429 (rate-limited) is not a real failure — just skip this poll
if (response.status === 429) {
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: TranslationStatusResponse = await response.json();
const job = data.data;
setStatus(job.status as TranslationStatus);
setProgress(job.progress_percent || 0);
setCurrentStep(job.current_step || '');
setEstimatedRemaining(data.meta.estimated_remaining_seconds ?? null);
pollingFailuresRef.current = 0;
setPollingFailures(0);
if (job.file_name) {
setFileName(job.file_name);
}
if (job.status === 'completed' || job.status === 'failed') {
stopPolling();
setIsSubmitting(false);
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);
pollingFailuresRef.current += 1;
setPollingFailures(pollingFailuresRef.current);
if (pollingFailuresRef.current >= MAX_POLLING_FAILURES) {
stopPolling();
setIsSubmitting(false);
setStatus('failed');
setError('Lost connection to translation service. Please check your internet connection and try again.');
}
} finally {
isPollingRef.current = false;
}
}, [stopPolling]);
const startPolling = useCallback((id: string) => {
stopPolling();
pollingFailuresRef.current = 0;
setIsPolling(true);
setPollingFailures(0);
pollProgress(id);
pollingIntervalRef.current = setInterval(() => {
pollProgress(id);
}, POLLING_INTERVAL_MS);
}, [pollProgress, stopPolling]);
const submitTranslation = useCallback(async (file: File, config: TranslationConfig) => {
setIsSubmitting(true);
setError(null);
setProgress(0);
setCurrentStep('Uploading file...');
setEstimatedRemaining(null);
setStatus('processing'); // IMPORTANT: Set to 'processing' IMMEDIATELY so progress bar shows
setFileName(file.name);
setJobId(null);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('source_lang', config.sourceLang);
formData.append('target_lang', config.targetLang);
formData.append('mode', config.mode);
// Provider is configured server-side by admin — only send the provider name.
if (config.mode === 'llm' && config.provider) {
formData.append('provider', config.provider);
}
// PDF mode: layout (preserve layout) or text_only (clean text output)
if (config.pdfMode) {
formData.append('pdf_mode', config.pdfMode);
}
// Glossary for LLM translation (Pro only)
if (config.glossaryId) {
formData.append('glossary_id', config.glossaryId);
}
// Translate images toggle
if (config.translateImages !== undefined) {
formData.append('translate_images', String(config.translateImages));
}
// System prompt from Context page (Pro only)
const { settings } = await import('@/lib/store').then(m => m.useTranslationStore.getState());
if (settings.systemPrompt?.trim()) {
formData.append('custom_prompt', settings.systemPrompt.trim());
}
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}/api/v1/translate`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
let errorMessage = `Translation failed: ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// Response not JSON, use default message
}
throw new Error(errorMessage);
}
const data: TranslationSubmitResponse = await response.json();
setJobId(data.data.id);
setFileName(data.data.file_name || file.name);
setProgress(data.data.progress_percent || 5); // Start with at least 5%
setCurrentStep(data.data.current_step || 'Translating...');
startPolling(data.data.id);
} catch (err) {
setStatus('failed');
setError(err instanceof Error ? err.message : 'Translation failed');
setIsSubmitting(false);
}
// NOTE: Don't set isSubmitting(false) here - let polling handle the transition
}, [startPolling]);
const reset = useCallback(() => {
stopPolling();
persistActiveJob(null);
setJobId(null);
setStatus('idle');
setProgress(0);
setCurrentStep('');
setError(null);
setEstimatedRemaining(null);
setFileName(null);
setIsSubmitting(false);
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();
};
}, [stopPolling]);
return {
submitTranslation,
jobId,
status,
progress,
currentStep,
error,
estimatedRemaining,
fileName,
reset,
isSubmitting,
isPolling,
pollingFailures,
};
}