feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup
- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,106 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Presentation,
|
||||
Upload,
|
||||
X,
|
||||
ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2,
|
||||
FileSpreadsheet, FileText, Presentation, Upload, X,
|
||||
Zap, Brain, CheckCircle2, ArrowLeftRight,
|
||||
Search, Languages, Wrench, Activity, Gauge, Timer,
|
||||
Download, Plus, TrendingUp, AlertTriangle, FileType,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileDropZone } from './FileDropZone';
|
||||
import { useFileUpload } from './useFileUpload';
|
||||
import { useTranslationConfig } from './useTranslationConfig';
|
||||
import { useTranslationSubmit } from './useTranslationSubmit';
|
||||
import { LanguageSelector } from './LanguageSelector';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
import { ProviderSelector } from './ProviderSelector';
|
||||
import { TranslationProgress } from './TranslationProgress';
|
||||
import { TranslationComplete } from './TranslationComplete';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
const FILE_ICONS: Record<string, React.ElementType> = {
|
||||
xlsx: FileSpreadsheet,
|
||||
docx: FileText,
|
||||
pptx: Presentation,
|
||||
xlsx: FileSpreadsheet, docx: FileText, pptx: Presentation, pdf: FileType,
|
||||
};
|
||||
const FILE_COLORS: Record<string, string> = {
|
||||
xlsx: 'text-green-500',
|
||||
docx: 'text-blue-500',
|
||||
pptx: 'text-orange-500',
|
||||
xlsx: 'text-green-500', docx: 'text-blue-500', pptx: 'text-orange-500', pdf: 'text-red-500',
|
||||
};
|
||||
|
||||
function fmt(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/* ── Compact file strip ──────────────────────────────────────────── */
|
||||
function FileStrip({
|
||||
file,
|
||||
onRemove,
|
||||
onReplace,
|
||||
}: {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
onReplace: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<FileIcon className={`size-5 shrink-0 ${color}`} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{fmt(file.size)} · .{ext.toUpperCase()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReplace}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
{t('dashboard.translate.dropzone.replaceFile')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
onClick={onRemove}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
/* ── Quality label based on provider ────────────────────────────── */
|
||||
function getQualityLabel(t: (key: string) => string, provider: string | null | undefined): string {
|
||||
if (!provider) return t('dashboard.translate.highQuality');
|
||||
if (['openai', 'openrouter', 'openrouter_premium'].includes(provider)) return t('dashboard.translate.highQuality');
|
||||
if (provider === 'deepl') return t('dashboard.translate.highQuality');
|
||||
return t('dashboard.translate.quality');
|
||||
}
|
||||
|
||||
/* ── Trust row ───────────────────────────────────────────────────── */
|
||||
function TrustRow() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
{t('dashboard.translate.trust.zeroRetention')}
|
||||
</span>
|
||||
<span className="h-3 w-px bg-border" aria-hidden />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
{t('dashboard.translate.trust.deletedAfter')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
/* ── Pipeline step keys ─────────────────────────────────────────── */
|
||||
const PIPELINE_STEP_KEYS = [
|
||||
'dashboard.translate.pipeline.upload',
|
||||
'dashboard.translate.pipeline.analyze',
|
||||
'dashboard.translate.pipeline.translate',
|
||||
'dashboard.translate.pipeline.rebuild',
|
||||
'dashboard.translate.pipeline.finalize',
|
||||
] as const;
|
||||
|
||||
const PIPELINE_ICONS = [Upload, Search, Languages, Wrench, CheckCircle2] as const;
|
||||
const PIPELINE_STARTS = [0, 10, 25, 75, 92];
|
||||
|
||||
function getActiveStepIdx(progress: number) {
|
||||
for (let i = PIPELINE_STARTS.length - 1; i >= 0; i--) {
|
||||
if (progress >= PIPELINE_STARTS[i]) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds: number) {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/* ── Page ────────────────────────────────────────────────────────── */
|
||||
@@ -112,201 +76,528 @@ export default function TranslatePage() {
|
||||
const { t } = useI18n();
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pdfMode, setPdfMode] = useState<'layout' | 'text_only'>('layout');
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (submit.error && submit.error !== lastErrorRef.current) {
|
||||
lastErrorRef.current = submit.error;
|
||||
showError({
|
||||
title: t('dashboard.translate.errorNotificationTitle'),
|
||||
description: submit.error,
|
||||
});
|
||||
showError({ title: t('dashboard.translate.errorNotificationTitle'), description: submit.error });
|
||||
}
|
||||
}, [submit.error, showError, t]);
|
||||
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if ((submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed') {
|
||||
setElapsed(0);
|
||||
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
|
||||
} else {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}, [submit.status, submit.isSubmitting]);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!upload.file || !config.isConfigValid) return;
|
||||
await submit.submitTranslation(upload.file, config.getConfig());
|
||||
const cfg = config.getConfig();
|
||||
if (isPdf) cfg.pdfMode = pdfMode;
|
||||
await submit.submitTranslation(upload.file, cfg);
|
||||
};
|
||||
|
||||
const handleNewTranslation = () => {
|
||||
submit.reset();
|
||||
upload.removeFile();
|
||||
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
|
||||
const handleDownload = async () => {
|
||||
if (!submit.jobId) return;
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
|
||||
if (!response.ok) return;
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let downloadFilename = 'translated_document';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
||||
if (match?.[1]) downloadFilename = match[1];
|
||||
} else if (submit.fileName) {
|
||||
const ext = submit.fileName.split('.').pop() || '';
|
||||
const base = submit.fileName.replace(/\.[^.]+$/, '');
|
||||
downloadFilename = `${base}_translated.${ext}`;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = downloadFilename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
/* ── Derived states ──────────────────────────────────────────── */
|
||||
const isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
|
||||
const isProcessing =
|
||||
(submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||
const isCompleted = submit.status === 'completed';
|
||||
const isFailed = submit.status === 'failed';
|
||||
|
||||
/* ── STATE: No file ─────────────────────────────────────────────── */
|
||||
if (!upload.file && !isProcessing && !isCompleted && !isFailed) {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('dashboard.translate.pageTitle')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.pageSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<FileDropZone upload={upload} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
<TrustRow />
|
||||
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
|
||||
const showConfiguring = isConfiguring;
|
||||
const showProcessing = isProcessing;
|
||||
const showComplete = isCompleted && !!submit.jobId;
|
||||
const showFailed = isFailed;
|
||||
|
||||
const currentProvider = config.availableProviders.find(p => p.id === config.provider);
|
||||
const srcLangName = config.languages.find(l => l.code === config.sourceLang)?.name || config.sourceLang;
|
||||
const tgtLangName = config.languages.find(l => l.code === config.targetLang)?.name || config.targetLang;
|
||||
const activeStepIdx = getActiveStepIdx(submit.progress);
|
||||
const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════ */
|
||||
/* UNIFIED LAYOUT — always the same grid */
|
||||
/* ═══════════════════════════════════════════════════════════════ */
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('dashboard.translate.pageTitle')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.pageSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Configure — single column, full width ───────────────── */
|
||||
if (isConfiguring) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
{/* Scrollable content */}
|
||||
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8">
|
||||
{/* Page title */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-foreground">
|
||||
{t('dashboard.translate.pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
{/* Grid: LEFT (2/3) + RIGHT (1/3) — always present */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* File strip */}
|
||||
<FileStrip
|
||||
file={upload.file!}
|
||||
onRemove={upload.removeFile}
|
||||
onReplace={() => replaceInputRef.current?.click()}
|
||||
/>
|
||||
<input
|
||||
ref={replaceInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
/>
|
||||
{upload.error && <p className="text-sm text-destructive">{upload.error}</p>}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* LEFT CARD (2/3) — content swaps based on state */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
|
||||
{/* Language selector — full width */}
|
||||
<LanguageSelector
|
||||
sourceLang={config.sourceLang}
|
||||
targetLang={config.targetLang}
|
||||
languages={config.languages}
|
||||
isLoading={config.isLoadingLanguages}
|
||||
error={config.languagesError}
|
||||
onSourceChange={config.setSourceLang}
|
||||
onTargetChange={config.setTargetLang}
|
||||
/>
|
||||
{/* ── UPLOAD STATE: Dropzone ─────────────────────────── */}
|
||||
{showUpload && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
|
||||
</div>
|
||||
<div className="flex-1 px-6 pb-6">
|
||||
<FileDropZone upload={upload} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Provider selector — full width */}
|
||||
<ProviderSelector
|
||||
provider={config.provider}
|
||||
onProviderChange={config.setProvider}
|
||||
availableProviders={config.availableProviders}
|
||||
isLoadingProviders={config.isLoadingProviders}
|
||||
isPro={config.isPro}
|
||||
/>
|
||||
{/* ── CONFIGURING STATE: File strip ──────────────────── */}
|
||||
{showConfiguring && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── PROCESSING STATE: Rich progress ────────────────── */}
|
||||
{showProcessing && (
|
||||
<div className="flex flex-col">
|
||||
{/* Header band */}
|
||||
<div className="border-b border-border bg-gradient-to-r from-primary/5 via-primary/8 to-primary/3 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">{t('dashboard.translate.translating')}</h2>
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[300px]">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{submit.estimatedRemaining != null && submit.estimatedRemaining > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
|
||||
<Clock className="size-3.5" />
|
||||
~{submit.estimatedRemaining}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
{/* Pipeline stepper */}
|
||||
<PipelineStepper activeIdx={activeStepIdx} t={t} />
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-foreground animate-pulse truncate">
|
||||
{submit.currentStep || (submit.isSubmitting ? t('dashboard.translate.steps.uploading') : t('dashboard.translate.steps.starting'))}
|
||||
</p>
|
||||
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
|
||||
{Math.round(submit.progress)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary via-primary/80 to-primary transition-all duration-700 ease-out"
|
||||
style={{ width: `${Math.max(0, submit.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live stats */}
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
<StatBox icon={<FileText className="size-4" />} value={`${Math.round(submit.progress)}%`} label={t('dashboard.translate.segments')} />
|
||||
<StatBox icon={<Activity className="size-4" />} value={t('dashboard.translate.translating')} label={t('dashboard.translate.characters')} />
|
||||
<StatBox icon={<Gauge className="size-4" />} value="—" label={t('dashboard.translate.segPerMin')} />
|
||||
<StatBox icon={<Timer className="size-4" />} value={formatElapsed(elapsed)} label={t('dashboard.translate.elapsed')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── COMPLETE STATE: Success ────────────────────────── */}
|
||||
{showComplete && (
|
||||
<div className="flex flex-col">
|
||||
{/* Success header */}
|
||||
<div className="border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-6 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
|
||||
<CheckCircle2 className="size-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">{t('dashboard.translate.completed')}</h2>
|
||||
{submit.fileName && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{submit.fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<TrendingUp className="size-3" />{qualityLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="lg" className="h-11 gap-2 flex-1 font-semibold" onClick={handleDownload}>
|
||||
<Download className="size-4" />{t('dashboard.translate.complete.download')}
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="h-11 gap-2 flex-1" onClick={handleNewTranslation}>
|
||||
<Plus className="size-4" />{t('dashboard.translate.complete.newTranslation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── FAILED STATE ───────────────────────────────────── */}
|
||||
{showFailed && (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
|
||||
<p className="text-sm text-destructive/80">{submit.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky bottom bar: button + trust */}
|
||||
<div className="shrink-0 border-t border-border/50 bg-background px-6 py-4 lg:px-8">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-13 w-full gap-2 text-base font-semibold"
|
||||
disabled={!config.isConfigValid || submit.isSubmitting}
|
||||
onClick={handleTranslate}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{t('dashboard.translate.actions.uploading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('dashboard.translate.actions.translate')}
|
||||
<ArrowRight className="size-5 rtl:rotate-180" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="mt-3">
|
||||
<TrustRow />
|
||||
</div>
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* RIGHT CARD (1/3) — Config / Monitor / Summary */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
||||
{(showUpload || showConfiguring || showFailed) && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.configuration')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-5 px-6">
|
||||
<LanguageSelector
|
||||
sourceLang={config.sourceLang} targetLang={config.targetLang}
|
||||
languages={config.languages} isLoading={config.isLoadingLanguages}
|
||||
error={config.languagesError} onSourceChange={config.setSourceLang}
|
||||
onTargetChange={config.setTargetLang}
|
||||
/>
|
||||
|
||||
<ProviderSelector
|
||||
provider={config.provider} onProviderChange={config.setProvider}
|
||||
availableProviders={config.availableProviders} isLoadingProviders={config.isLoadingProviders}
|
||||
isPro={config.isPro}
|
||||
/>
|
||||
|
||||
{/* PDF mode selector — only shown for PDF files */}
|
||||
{isPdf && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.pdfMode.title')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('layout')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'layout'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<FileText className="size-4 text-primary" />
|
||||
{t('dashboard.translate.pdfMode.preserveLayout')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.preserveLayoutDesc')}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('text_only')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'text_only'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Languages className="size-4 text-primary" />
|
||||
{t('dashboard.translate.pdfMode.textOnly')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.textOnlyDesc')}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with button */}
|
||||
<div className="px-6 py-4 border-t border-border/50">
|
||||
<Button
|
||||
size="lg" className="h-12 w-full gap-2 text-base font-semibold"
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
onClick={handleTranslate}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
<><Loader2 className="size-5 animate-spin" />{t('dashboard.translate.actions.uploading')}</>
|
||||
) : (
|
||||
<>{t('dashboard.translate.actions.translate')}<ArrowRight className="size-5 rtl:rotate-180" /></>
|
||||
)}
|
||||
</Button>
|
||||
<div className="mt-3 flex items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5"><ShieldCheck className="size-3.5" />{t('dashboard.translate.trust.zeroRetention')}</span>
|
||||
<span className="h-3 w-px bg-border" aria-hidden />
|
||||
<span className="flex items-center gap-1.5"><Clock className="size-3.5" />{t('dashboard.translate.trust.deletedAfter')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── MONITOR (processing) ────────────────────────────── */}
|
||||
{showProcessing && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border-b border-border px-6 py-4">
|
||||
<div className="size-2 animate-pulse rounded-full bg-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('dashboard.translate.liveMonitor')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-6">
|
||||
{/* File summary */}
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-primary/5 border border-primary/10 p-3">
|
||||
{(() => {
|
||||
const name = submit.fileName || upload.file?.name || '';
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
||||
return <FileIcon className={`size-6 shrink-0 ${color}`} />;
|
||||
})()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{submit.fileName || upload.file?.name}</p>
|
||||
{upload.file && <p className="text-[11px] text-muted-foreground">{fmt(upload.file.size)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config summary */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quality indicator */}
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-700"
|
||||
style={{ width: `${Math.min(95, 40 + submit.progress * 0.55)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancel */}
|
||||
<div className="px-6 py-4 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-destructive/20 text-destructive hover:bg-destructive hover:text-white hover:border-destructive"
|
||||
onClick={handleNewTranslation}
|
||||
>
|
||||
<RotateCcw className="size-4" />{t('dashboard.translate.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── SUMMARY (complete) ──────────────────────────────── */}
|
||||
{showComplete && (
|
||||
<>
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />{t('dashboard.translate.summary')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 space-y-2.5">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality bar */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-3">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: '95%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Processing ──────────────────────────────────────────── */
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.actions.filePrefix')}{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="w-full max-w-md">
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={
|
||||
submit.currentStep ||
|
||||
(submit.isSubmitting
|
||||
? t('dashboard.translate.steps.uploading')
|
||||
: t('dashboard.translate.steps.starting'))
|
||||
}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={null}
|
||||
isPolling={submit.isPolling}
|
||||
isUploading={submit.isSubmitting}
|
||||
isCompleted={false}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
|
||||
<RotateCcw className="size-4" />
|
||||
{t('dashboard.translate.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Complete ────────────────────────────────────────────── */
|
||||
if (isCompleted && submit.jobId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center overflow-y-auto p-6">
|
||||
<TranslationComplete
|
||||
jobId={submit.jobId}
|
||||
fileName={submit.fileName}
|
||||
onNewTranslation={handleNewTranslation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Failed ──────────────────────────────────────────────── */
|
||||
if (isFailed) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
|
||||
<div className="w-full max-w-md">
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={submit.currentStep}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={submit.error}
|
||||
isPolling={false}
|
||||
isCompleted={false}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
|
||||
<RotateCcw className="size-4" />
|
||||
{t('dashboard.translate.actions.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══ Sub-components ═══════════════════════════════════════════════ */
|
||||
|
||||
/** Compact file strip */
|
||||
function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: () => void; onReplace: () => void; t: (key: string) => string }) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<FileIcon className={`size-5 shrink-0 ${color}`} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{fmt(file.size)} · .{ext.toUpperCase()}</span>
|
||||
</div>
|
||||
<button type="button" onClick={onReplace} className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition hover:bg-secondary hover:text-foreground">
|
||||
<Upload className="size-3.5" />{t('dashboard.translate.replace')}
|
||||
</button>
|
||||
<button type="button" aria-label="Remove" onClick={onRemove} className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-secondary hover:text-foreground">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pipeline stepper */
|
||||
function PipelineStepper({ activeIdx, t }: { activeIdx: number; t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
{PIPELINE_STEP_KEYS.map((stepKey, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
const isDone = i < activeIdx;
|
||||
const Icon = PIPELINE_ICONS[i];
|
||||
return (
|
||||
<div key={stepKey} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEP_KEYS.length - 1 ? 1 : 'none' }}>
|
||||
<div className="flex items-center w-full">
|
||||
<div className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
|
||||
isDone
|
||||
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
|
||||
: isActive
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{isDone ? <CheckCircle2 className="size-4" /> : <Icon className={cn('size-4', isActive && 'animate-pulse')} />}
|
||||
</div>
|
||||
{i < PIPELINE_STEP_KEYS.length - 1 && (
|
||||
<div className={cn('mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500', i < activeIdx ? 'bg-primary' : 'bg-border')} />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn('text-[11px] font-medium transition-colors', isDone || isActive ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{t(stepKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small stat box */
|
||||
function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
|
||||
<div className="text-primary">{icon}</div>
|
||||
<p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user