feat(ui,api): wave 3 — editorial pricing, real cancel, server history, DeepL purge
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s

Pricing: full editorial redesign — serif card headers with accent pills
replace the colored font-black blocks, tone sweep across toggle/metrics/
features/CTAs, PLAN_COLORS removed; one design system app-wide.

Translate: decorative titles one step down (CTA hierarchy restored);
glossary and image-translation blocks hidden entirely for free users
(progressive disclosure — three controls for free).

Reviews: XLIFF hint line explains the exchange format; backend errors
routed through a friendly mapper (session/not-found/rate-limit/server).

Landing: fabricated hero UI cards (fake 'Context Engine' overlay)
removed — the photo no longer promises screens that don't exist.

Nav: single DashboardNavLinks component shared by sidebar and mobile
drawer (was duplicated markup).

API: GET /api/v1/translations (user job history, paginated; completed
jobs retained 24h) and POST /api/v1/translations/{id}/cancel —
cooperative cancellation with worker checkpoints before dispatch and
before finalisation, reserved quota released immediately. Translate
monitor now offers a real 'Cancel translation' next to 'Back to start';
recent-jobs list reads server history first, localStorage fallback.

DeepL purge (backend): provider module, registry registration, config
attrs/defaults, dispatch branch, admin settings schema + test branch,
legacy availability block, validation rules, plan provider lists,
error-code mappings, MCP enums, translator prompt mention, related
tests updated/removed. Fallback resolver skips unknown providers, so
stale chains containing 'deepl' degrade gracefully.

Verified: backend 110 tests passed; frontend build exit 0, vitest 9/9,
0 missing i18n keys, eslint 63 errors (vs 64 at HEAD).
This commit is contained in:
2026-08-30 22:42:29 +02:00
parent 52748ee653
commit 67365918ae
35 changed files with 398 additions and 1572 deletions

View File

@@ -12,7 +12,7 @@ import {
} from 'lucide-react';
import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit, getRecentJobs, type RecentJob } from './useTranslationSubmit';
import { useTranslationSubmit, getRecentJobs, fetchServerHistory, type RecentJob } from './useTranslationSubmit';
import LanguageSelector from './LanguageSelector';
import { ProviderSelector } from './ProviderSelector';
import { GlossarySelector } from './GlossarySelector';
@@ -122,9 +122,14 @@ 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
// History: server list first (survives any device), localStorage as fallback
useEffect(() => {
setRecentJobs(getRecentJobs());
let cancelled = false;
fetchServerHistory().then((server) => {
if (cancelled) return;
setRecentJobs(server.length > 0 ? server : getRecentJobs());
});
return () => { cancelled = true; };
}, [submit.status]);
const handleTranslate = async () => {
@@ -141,6 +146,17 @@ export default function TranslatePage() {
await handleTranslate();
};
const handleCancel = async () => {
const ok = await submit.cancelJob();
if (ok) {
submit.reset();
setElapsed(0);
showError({ title: t('translate.cancelledTitle'), description: t('translate.cancelledDesc') });
} else {
showError({ title: t('translate.cancelFailedTitle'), description: t('translate.cancelFailedDesc') });
}
};
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
if (!jobId) return;
@@ -220,7 +236,7 @@ export default function TranslatePage() {
{showProcessing ? (
<>
<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">
<h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<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">
@@ -230,7 +246,7 @@ export default function TranslatePage() {
) : showComplete ? (
<>
<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">
<h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<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">
@@ -240,7 +256,7 @@ export default function TranslatePage() {
) : (
<>
<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">
<h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<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">
@@ -596,7 +612,8 @@ export default function TranslatePage() {
</div>
)}
{/* Glossary selector */}
{/* Glossary selector — Pro only; hidden entirely for free users */}
{config.isPro && (
<GlossarySelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
@@ -606,8 +623,10 @@ export default function TranslatePage() {
onChange={config.setGlossaryId}
disabled={submit.isSubmitting}
/>
)}
{/* Translate Images */}
{/* Translate Images — LLM mode only; hidden for free users */}
{config.isPro && (
<div className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
@@ -639,6 +658,8 @@ export default function TranslatePage() {
)}
</div>
)}
{/* PDF mode selector */}
{isPdf && (
<div className="space-y-2 text-left">
@@ -741,11 +762,17 @@ export default function TranslatePage() {
</div>
<button
onClick={handleNewTranslation}
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"
onClick={handleCancel}
className="w-full mt-8 py-3.5 border border-red-200 dark:border-red-900/40 text-red-500 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-red-50 dark:hover:bg-red-950/30 transition-all cursor-pointer"
>
<X size={13} />
{t('translate.cancelAction')}
</button>
<button
onClick={handleNewTranslation}
title={t('translate.leaveScreenHint')}
className="w-full mt-2 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-colors cursor-pointer"
>
{t('translate.leaveScreen')}
</button>
</div>

View File

@@ -101,6 +101,8 @@ export interface TranslationStatusResponse {
export interface UseTranslationSubmitReturn {
submitTranslation: (file: File, config: TranslationConfig) => Promise<void>;
/** Ask the backend to cancel the running job. */
cancelJob: () => Promise<boolean>;
jobId: string | null;
status: TranslationStatus;
progress: number;

View File

@@ -22,6 +22,30 @@ interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
export interface RecentJob { jobId: string; fileName: string; completedAt: number }
/** Server history (last jobs, newest first) — empty when offline/unauthenticated. */
export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> {
if (typeof window === 'undefined') return [];
const token = localStorage.getItem('token');
if (!token) return [];
try {
const res = await fetch(`${API_BASE}/api/v1/translations?per_page=${perPage}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
const json = await res.json();
return (json.data ?? []).map((j: {
id: string; file_name?: string | null;
completed_at?: string | null; created_at?: string | null;
}) => ({
jobId: j.id,
fileName: j.file_name ?? '',
completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
}));
} catch {
return [];
}
}
export function getRecentJobs(): RecentJob[] {
if (typeof window === 'undefined') return [];
try {
@@ -235,6 +259,28 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
// NOTE: Don't set isSubmitting(false) here - let polling handle the transition
}, [startPolling]);
/** Ask the backend to cancel the current job. Returns true on success. */
const cancelJob = useCallback(async (): Promise<boolean> => {
const id = jobId;
if (!id) return false;
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/v1/translations/${id}/cancel`, {
method: 'POST',
headers,
});
if (!res.ok) return false;
stopPolling();
persistActiveJob(null);
setIsSubmitting(false);
return true;
} catch {
return false;
}
}, [jobId, stopPolling]);
const reset = useCallback(() => {
stopPolling();
persistActiveJob(null);
@@ -285,6 +331,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
return {
submitTranslation,
cancelJob,
jobId,
status,
progress,