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