feat: production deployment - full update with providers, admin, glossaries, pricing, tests

Major changes across backend, frontend, infrastructure:
- Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud)
- Admin panel: user management, pricing, settings
- Glossary system with CSV import/export
- Subscription and tier quota management
- Security hardening (rate limiting, API key auth, path traversal fixes)
- Docker compose for dev, prod, and IONOS deployment
- Alembic migrations for new tables
- Frontend: dashboard, pricing page, landing page, i18n (en/fr)
- Test suite and verification scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

@@ -1,11 +1,20 @@
'use client';
import { useEffect, useRef } from 'react';
import { Languages, ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
ShieldCheck,
Clock,
ArrowRight,
RotateCcw,
Loader2,
FileSpreadsheet,
FileText,
Presentation,
Upload,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FileDropZone } from './FileDropZone';
import { FilePreview } from './FilePreview';
import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit } from './useTranslationSubmit';
@@ -14,187 +23,290 @@ import { ProviderSelector } from './ProviderSelector';
import { TranslationProgress } from './TranslationProgress';
import { TranslationComplete } from './TranslationComplete';
import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
/* ── helpers ─────────────────────────────────────────────────────── */
const FILE_ICONS: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
docx: FileText,
pptx: Presentation,
};
const FILE_COLORS: Record<string, string> = {
xlsx: 'text-green-500',
docx: 'text-blue-500',
pptx: 'text-orange-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>
);
}
/* ── 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>
);
}
/* ── Page ────────────────────────────────────────────────────────── */
export default function TranslatePage() {
const upload = useFileUpload();
const config = useTranslationConfig(!!upload.file);
const submit = useTranslationSubmit();
const { error: showError } = useNotification();
const { t } = useI18n();
const lastErrorRef = useRef<string | null>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (submit.error && submit.error !== lastErrorRef.current) {
lastErrorRef.current = submit.error;
showError({
title: t('dashboard.translate.errorNotificationTitle'),
description: submit.error,
});
}
}, [submit.error, showError, t]);
const handleTranslate = async () => {
if (!upload.file || !config.isConfigValid) return;
await submit.submitTranslation(upload.file, config.getConfig());
};
useEffect(() => {
if (submit.error && submit.error !== lastErrorRef.current) {
lastErrorRef.current = submit.error;
showError({
title: 'Translation Error',
description: submit.error,
});
}
}, [submit.error, showError]);
const handleNewTranslation = () => {
submit.reset();
upload.removeFile();
};
const isConfiguring = upload.file && submit.status === 'idle' && !submit.isSubmitting;
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
const isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
const isProcessing =
(submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
const isCompleted = submit.status === 'completed';
const isFailed = submit.status === 'failed';
return (
<div className="mx-auto max-w-xl px-4 py-6 lg:px-8">
<Card className="border-border/70 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Languages className="size-5" />
Office Translator
</CardTitle>
<CardDescription>
Upload an Excel, Word, or PowerPoint file to translate
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{upload.file && !isProcessing && !isCompleted && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-success/40 bg-success/5 px-6 py-4">
<FilePreview file={upload.file} onRemove={upload.removeFile} />
</div>
)}
{!upload.file && !isProcessing && !isCompleted && (
<FileDropZone upload={upload} />
)}
{upload.error && !isProcessing && !isCompleted && (
<p className="text-sm text-destructive">{upload.error}</p>
)}
{isConfiguring && (
<>
<div className="my-2 flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">Configuration</span>
<div className="h-px flex-1 bg-border" />
</div>
<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}
/>
<Button
size="lg"
className="w-full text-sm font-semibold"
disabled={!config.isConfigValid || submit.isSubmitting}
onClick={handleTranslate}
>
{submit.isSubmitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Uploading...
</>
) : (
<>
Translate Document
<ArrowRight className="ml-2 size-4" />
</>
)}
</Button>
</>
)}
{isProcessing && !isCompleted && (
<>
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>File: {submit.fileName || upload.file?.name}</span>
</div>
<TranslationProgress
progress={submit.progress}
currentStep={submit.currentStep || (submit.isSubmitting ? 'Uploading file...' : 'Starting translation...')}
estimatedRemaining={submit.estimatedRemaining}
error={null}
isPolling={submit.isPolling}
isUploading={submit.isSubmitting}
isCompleted={false}
/>
<Button
variant="outline"
size="sm"
onClick={handleNewTranslation}
className="w-full mt-2"
>
<RotateCcw className="mr-2 size-4" />
Cancel
</Button>
</>
)}
{isCompleted && submit.jobId && (
<TranslationComplete
jobId={submit.jobId}
fileName={submit.fileName}
onNewTranslation={handleNewTranslation}
/>
)}
{isFailed && (
<>
<TranslationProgress
progress={submit.progress}
currentStep={submit.currentStep}
estimatedRemaining={submit.estimatedRemaining}
error={submit.error}
isPolling={false}
isCompleted={false}
/>
<Button
variant="outline"
onClick={handleNewTranslation}
className="w-full mt-2"
>
<RotateCcw className="mr-2 size-4" />
Try Again
</Button>
</>
)}
{!upload.file && !isProcessing && !isCompleted && !isFailed && (
<p className="text-xs text-muted-foreground">
Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)
</p>
)}
</CardContent>
</Card>
<div className="flex items-center justify-center gap-4 mt-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
<span>Zero Data Retention</span>
/* ── 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="h-3 w-px bg-border" />
<div className="flex items-center gap-1.5">
<Clock className="size-3.5" />
<span>Files deleted after 60 min</span>
<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 />
</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>
{/* 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>}
{/* 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}
/>
{/* Provider selector — full width */}
<ProviderSelector
provider={config.provider}
onProviderChange={config.setProvider}
availableProviders={config.availableProviders}
isLoadingProviders={config.isLoadingProviders}
isPro={config.isPro}
/>
</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>
</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;
}