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:
2026-05-10 11:43:28 +02:00
parent 16ac7ca2b9
commit ce8e150a61
110 changed files with 6935 additions and 4301 deletions

View File

@@ -7,7 +7,6 @@ import {
Languages,
Menu,
X,
ChevronLeft,
LogOut
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -144,13 +143,6 @@ export function DashboardHeader() {
<LogOut className="size-4 shrink-0" />
{t('dashboard.sidebar.signOut')}
</button>
<Link
href="/"
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
>
<ChevronLeft className="size-4 shrink-0" />
{t('dashboard.sidebar.backHome')}
</Link>
</nav>
</div>
)}

View File

@@ -2,7 +2,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Languages, ChevronLeft, LogOut } from 'lucide-react';
import { Languages, LogOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
@@ -83,17 +83,14 @@ export function DashboardSidebar() {
{translateTier(t, user.tier)}
</Badge>
</div>
<ThemeToggle />
</div>
<Separator />
</>
)}
{/* Actions */}
<div className="px-3 py-3 space-y-1">
<div className="flex items-center justify-between px-3 py-1.5">
<span className="text-xs text-muted-foreground">{t('dashboard.sidebar.theme')}</span>
<ThemeToggle />
</div>
<div className="px-3 py-3">
<Button
variant="ghost"
size="sm"
@@ -103,12 +100,6 @@ export function DashboardSidebar() {
<LogOut className="size-3.5" />
{t('dashboard.sidebar.signOut')}
</Button>
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-muted-foreground" asChild>
<Link href="/">
<ChevronLeft className="size-3.5" />
{t('dashboard.sidebar.backHome')}
</Link>
</Button>
</div>
</div>
</aside>

View File

@@ -17,6 +17,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import type { ApiKey } from './types';
interface ApiKeyTableProps {
@@ -25,23 +26,8 @@ interface ApiKeyTableProps {
isRevoking: boolean;
}
function formatDate(dateString: string | null): string {
if (!dateString) return 'Never';
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
const { t } = useI18n();
const [copiedId, setCopiedId] = useState<string | null>(null);
const copyPrefix = (keyId: string, prefix: string) => {
@@ -53,7 +39,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
if (keys.length === 0) {
return (
<div className="rounded-lg border border-border p-8 text-center">
<p className="text-muted-foreground">No API keys yet. Generate your first key to get started.</p>
<p className="text-muted-foreground">{t('apiKeys.generateNew')}</p>
</div>
);
}
@@ -64,22 +50,22 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Name
{t('apiKeys.table.name')}
</TableHead>
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Key
{t('apiKeys.table.prefix')}
</TableHead>
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground md:table-cell">
Created
{t('apiKeys.table.created')}
</TableHead>
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
Last Used
{t('apiKeys.table.lastUsed')}
</TableHead>
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
Uses
{t('apiKeys.table.actions')}
</TableHead>
<TableHead className="text-right text-xs font-medium uppercase tracking-wider text-muted-foreground">
Actions
<TableHead className="text-end text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('apiKeys.table.actions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -95,10 +81,10 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
</code>
</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">
{formatDate(key.created_at)}
{key.created_at ? new Date(key.created_at).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) : t('apiKeys.table.never')}
</TableCell>
<TableCell className="hidden text-muted-foreground lg:table-cell">
{formatDate(key.last_used_at)}
{key.last_used_at ? new Date(key.last_used_at).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) : t('apiKeys.table.never')}
</TableCell>
<TableCell className="hidden lg:table-cell">
<Badge variant="secondary" className="text-xs">
@@ -113,7 +99,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
variant="ghost"
size="icon-sm"
onClick={() => copyPrefix(key.id, key.key_prefix)}
aria-label="Copy key prefix"
aria-label={t('apiKeys.table.copyPrefix')}
>
{copiedId === key.id ? (
<Check className="size-3.5 text-accent" />
@@ -123,7 +109,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
</Button>
</TooltipTrigger>
<TooltipContent>
{copiedId === key.id ? 'Copied!' : 'Copy prefix'}
{copiedId === key.id ? 'Copied!' : t('apiKeys.table.copyPrefix')}
</TooltipContent>
</Tooltip>
@@ -134,12 +120,12 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
size="icon-sm"
onClick={() => onRevoke(key)}
disabled={isRevoking}
aria-label="Revoke key"
aria-label={t('apiKeys.table.revokeKey')}
>
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent>Revoke</TooltipContent>
<TooltipContent>{t('apiKeys.table.revoke')}</TooltipContent>
</Tooltip>
</div>
</TableCell>

View File

@@ -13,6 +13,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useI18n } from '@/lib/i18n';
import type { ApiKeyCreateResponse } from './types';
const MAX_KEY_NAME_LENGTH = 100;
@@ -38,6 +39,7 @@ export function GenerateKeyDialog({
isGenerating,
maxKeysReached,
}: GenerateKeyDialogProps) {
const { t } = useI18n();
const [step, setStep] = useState<'name' | 'result'>('name');
const [keyName, setKeyName] = useState('');
const [generatedKey, setGeneratedKey] = useState<ApiKeyCreateResponse | null>(null);
@@ -46,24 +48,24 @@ export function GenerateKeyDialog({
const validation = useMemo<ValidationResult>(() => {
const trimmedName = keyName.trim();
if (trimmedName.length > MAX_KEY_NAME_LENGTH) {
return { isValid: false, error: `Name must be ${MAX_KEY_NAME_LENGTH} characters or less` };
return { isValid: false, error: t('apiKeys.dialog.nameTooLong', { max: MAX_KEY_NAME_LENGTH }) };
}
if (trimmedName && !VALID_KEY_NAME_REGEX.test(trimmedName)) {
return {
isValid: false,
error: 'Name can only contain letters, numbers, spaces, hyphens, and underscores'
return {
isValid: false,
error: t('apiKeys.dialog.nameInvalid'),
};
}
return { isValid: true, error: null };
}, [keyName]);
}, [keyName]); // eslint-disable-line react-hooks/exhaustive-deps
const handleGenerate = async () => {
if (!validation.isValid) return;
try {
const result = await onGenerate(keyName.trim() || undefined);
setGeneratedKey(result);
@@ -96,13 +98,13 @@ export function GenerateKeyDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Maximum Keys Reached</DialogTitle>
<DialogTitle>{t('apiKeys.dialog.maxTitle')}</DialogTitle>
<DialogDescription>
You have reached the maximum of 10 API keys. Please revoke an existing key before generating a new one.
{t('apiKeys.dialog.maxDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => onOpenChange(false)}>{t('apiKeys.dialog.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -118,25 +120,25 @@ export function GenerateKeyDialog({
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/10">
<CheckCircle2 className="h-5 w-5 text-accent" />
</div>
<DialogTitle>API Key Generated!</DialogTitle>
<DialogTitle>{t('apiKeys.dialog.generated')}</DialogTitle>
</div>
<DialogDescription>
Your new API key has been created. Copy it now - it won't be shown again.
{t('apiKeys.dialog.generatedDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong>Important:</strong> This is the only time you'll see this key. Store it securely.
<strong>{t('apiKeys.dialog.important')}</strong> {t('apiKeys.dialog.importantDesc')}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<Label htmlFor="apiKey">{t('apiKeys.dialog.apiKey')}</Label>
<div className="flex gap-2">
<Input
id="apiKey"
@@ -153,15 +155,15 @@ export function GenerateKeyDialog({
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground">
<span className="font-medium">Name:</span> {generatedKey.name}
<span className="font-medium">{t('apiKeys.dialog.name')}</span> {generatedKey.name}
</div>
</div>
<DialogFooter>
<Button onClick={handleClose}>
{copied ? 'Done' : 'I\'ve copied the key'}
{copied ? t('apiKeys.dialog.done') : t('apiKeys.dialog.copied')}
</Button>
</DialogFooter>
</DialogContent>
@@ -173,18 +175,18 @@ export function GenerateKeyDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Generate New API Key</DialogTitle>
<DialogTitle>{t('apiKeys.dialog.generateTitle')}</DialogTitle>
<DialogDescription>
Create a new API key for programmatic access to the translation API.
{t('apiKeys.dialog.generateDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="keyName">Key Name (optional)</Label>
<Label htmlFor="keyName">{t('apiKeys.dialog.keyName')}</Label>
<Input
id="keyName"
placeholder="e.g., Production, Staging"
placeholder={t('apiKeys.dialog.keyNamePlaceholder')}
value={keyName}
onChange={(e) => {
setKeyName(e.target.value);
@@ -197,9 +199,9 @@ export function GenerateKeyDialog({
aria-describedby={touched && validation.error ? 'keyName-error' : undefined}
/>
<p className="text-xs text-muted-foreground">
A descriptive name to help you identify this key later.
{t('apiKeys.dialog.keyNameHint')}
{keyName.length > 0 && (
<span className="ml-2">({keyName.length}/{MAX_KEY_NAME_LENGTH})</span>
<span className="ms-2">({keyName.length}/{MAX_KEY_NAME_LENGTH})</span>
)}
</p>
{touched && validation.error && (
@@ -209,13 +211,13 @@ export function GenerateKeyDialog({
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('apiKeys.dialog.cancel')}
</Button>
<Button onClick={handleGenerate} disabled={isGenerating || !validation.isValid}>
{isGenerating ? 'Generating...' : 'Generate Key'}
{isGenerating ? t('apiKeys.dialog.generating') : t('apiKeys.dialog.generate')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -4,8 +4,11 @@ import { Key, Sparkles } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { useI18n } from '@/lib/i18n';
export function ProUpgradePrompt() {
const { t } = useI18n();
return (
<div className="flex items-center justify-center min-h-[60vh] p-6">
<Card className="max-w-md w-full border-border/50 bg-gradient-to-br from-card via-card to-accent/5">
@@ -13,39 +16,38 @@ export function ProUpgradePrompt() {
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent/5">
<Key className="h-8 w-8 text-accent" />
</div>
<CardTitle className="text-2xl font-semibold">API Keys</CardTitle>
<CardTitle className="text-2xl font-semibold">{t('apiKeys.upgrade.title')}</CardTitle>
<CardDescription className="text-base">
Automate your translations with API access
{t('apiKeys.upgrade.subtitle')}
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Generate unlimited API keys</span>
<span>{t('apiKeys.upgrade.feat1')}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Automate document translation</span>
<span>{t('apiKeys.upgrade.feat2')}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Webhook notifications</span>
<span>{t('apiKeys.upgrade.feat3')}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>LLM translation modes</span>
<span>{t('apiKeys.upgrade.feat4')}</span>
</div>
</div>
<div className="pt-2">
<p className="text-sm text-muted-foreground mb-4">
API Keys are a <span className="text-accent font-medium">Pro</span> feature.
Upgrade to unlock API automation.
{t('apiKeys.upgrade.proFeature', { pro: t('apiKeys.upgrade.pro') })}
</p>
<Button asChild className="w-full bg-accent hover:bg-accent/90">
<Link href="/pricing">
Upgrade to Pro
{t('apiKeys.upgrade.cta')}
</Link>
</Button>
</div>

View File

@@ -10,6 +10,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
interface RevokeKeyDialogProps {
open: boolean;
@@ -26,21 +27,18 @@ export function RevokeKeyDialog({
isRevoking,
keyName,
}: RevokeKeyDialogProps) {
const { t } = useI18n();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Revoke API Key</DialogTitle>
<DialogTitle>{t('apiKeys.revokeDialog.title')}</DialogTitle>
<DialogDescription>
Are you sure you want to revoke this API key?
{keyName && (
<span className="block mt-1 font-medium text-foreground">
"{keyName}"
</span>
)}
{keyName ? t('apiKeys.revokeDialog.desc', { name: keyName }) : t('apiKeys.revokeDialog.desc', { name: '' })}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
@@ -52,21 +50,21 @@ export function RevokeKeyDialog({
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isRevoking}
>
Cancel
{t('apiKeys.revokeDialog.cancel')}
</Button>
<Button
variant="destructive"
<Button
variant="destructive"
onClick={onConfirm}
disabled={isRevoking}
>
{isRevoking ? 'Revoking...' : 'Revoke Key'}
{isRevoking ? 'Revoking...' : t('apiKeys.table.revoke')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -15,8 +15,10 @@ import { GenerateKeyDialog } from './GenerateKeyDialog';
import { RevokeKeyDialog } from './RevokeKeyDialog';
import { WebhookSnippet } from './WebhookSnippet';
import { useToast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
export default function ApiKeysPage() {
const { t } = useI18n();
const { data: user, isLoading: isLoadingUser } = useUser();
const {
keys,
@@ -44,16 +46,15 @@ export default function ApiKeysPage() {
// Handle API errors with specific error codes
useEffect(() => {
if (errorDetails?.code === 'PRO_FEATURE_REQUIRED') {
// Redirect to upgrade prompt will happen via isPro check
setApiError(null);
} else if (errorDetails?.code === 'API_KEY_LIMIT_REACHED') {
setApiError('You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.');
setApiError(t('apiKeys.limitReachedDesc'));
} else if (errorDetails) {
setApiError(errorDetails.message);
} else {
setApiError(null);
}
}, [errorDetails]);
}, [errorDetails]); // eslint-disable-line react-hooks/exhaustive-deps
const handleRevokeClick = (key: ApiKey) => {
setKeyToRevoke({ id: key.id, name: key.name });
@@ -67,22 +68,22 @@ export default function ApiKeysPage() {
setRevokeDialogOpen(false);
setKeyToRevoke(null);
toast({
title: 'Key revoked',
description: 'The API key has been revoked successfully.',
title: t('apiKeys.keyRevoked'),
description: t('apiKeys.keyRevokedDesc'),
});
} catch (error) {
const revokeError = parseRevokeError();
if (revokeError?.code === 'API_KEY_NOT_FOUND') {
toast({
variant: 'destructive',
title: 'Key Not Found',
description: 'The API key no longer exists. It may have already been revoked.',
title: t('apiKeys.keyNotFound'),
description: t('apiKeys.keyNotFoundDesc'),
});
} else {
toast({
variant: 'destructive',
title: 'Error',
description: revokeError?.message || 'Failed to revoke the API key. Please try again.',
title: t('apiKeys.error'),
description: revokeError?.message || t('apiKeys.revokeError'),
});
}
}
@@ -97,20 +98,20 @@ export default function ApiKeysPage() {
if (genError?.code === 'API_KEY_LIMIT_REACHED') {
toast({
variant: 'destructive',
title: 'Limit Reached',
description: 'You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.',
title: t('apiKeys.limitReached'),
description: t('apiKeys.limitReachedDesc'),
});
} else if (genError?.code === 'PRO_FEATURE_REQUIRED') {
toast({
variant: 'destructive',
title: 'Pro Feature Required',
description: 'API keys are a Pro feature. Please upgrade your account.',
title: t('apiKeys.proRequired'),
description: t('apiKeys.proRequiredDesc'),
});
} else {
toast({
variant: 'destructive',
title: 'Error',
description: genError?.message || 'Failed to generate API key. Please try again.',
title: t('apiKeys.error'),
description: genError?.message || t('apiKeys.generateError'),
});
}
throw error;
@@ -122,7 +123,7 @@ export default function ApiKeysPage() {
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
<p className="text-sm text-muted-foreground">Loading...</p>
<p className="text-sm text-muted-foreground">{t('apiKeys.loading')}</p>
</div>
</div>
);
@@ -135,9 +136,9 @@ export default function ApiKeysPage() {
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">API Keys</h1>
<h1 className="text-2xl font-semibold tracking-tight">{t('apiKeys.title')}</h1>
<p className="text-muted-foreground">
Manage your API keys for programmatic access to the translation API.
{t('apiKeys.subtitle')}
</p>
</div>
@@ -155,8 +156,8 @@ export default function ApiKeysPage() {
<Zap className="size-4 text-accent" />
</div>
<div>
<CardTitle className="text-base">API & Automation</CardTitle>
<CardDescription>Generate and manage your API keys for automation workflows</CardDescription>
<CardTitle className="text-base">{t('apiKeys.sectionTitle')}</CardTitle>
<CardDescription>{t('apiKeys.sectionDesc')}</CardDescription>
</div>
</div>
</CardHeader>
@@ -165,13 +166,13 @@ export default function ApiKeysPage() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">
{total} of {MAX_API_KEYS} keys used
{t('apiKeys.keysUsed', { total, max: MAX_API_KEYS })}
</p>
<p className="text-xs text-muted-foreground">
{maxKeysReached ? (
<span className="text-amber-600">Maximum keys reached. Revoke a key to generate a new one.</span>
<span className="text-amber-600">{t('apiKeys.maxReached')}</span>
) : (
`You can generate ${MAX_API_KEYS - total} more key${MAX_API_KEYS - total !== 1 ? 's' : ''}.`
t(MAX_API_KEYS - total !== 1 ? 'apiKeys.canGeneratePlural' : 'apiKeys.canGenerate', { count: MAX_API_KEYS - total })
)}
</p>
</div>
@@ -181,7 +182,7 @@ export default function ApiKeysPage() {
className="gap-1.5"
>
<Plus className="size-3.5" />
Generate New Key
{t('apiKeys.generateNew')}
</Button>
</div>

View File

@@ -5,6 +5,7 @@ import { apiClient, ApiClientError } from '@/lib/apiClient';
import type {
ApiKey,
ApiKeyCreateResponse,
ApiKeyCreateApiResponse,
ApiKeyRevokeResponse,
} from './types';
@@ -35,8 +36,7 @@ export function useApiKeys() {
} = useQuery<ApiKeysListApiResponse, ApiClientError>({
queryKey: API_KEYS_QUERY_KEY,
queryFn: async () => {
const response = await apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys');
return response.data;
return apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys');
},
retry: (failureCount, err) => {
if (err.status === 403 || err.status === 429) return false;
@@ -49,7 +49,7 @@ export function useApiKeys() {
const generateKeyMutation = useMutation<ApiKeyCreateResponse, ApiClientError, string | undefined>({
mutationFn: async (name?: string): Promise<ApiKeyCreateResponse> => {
const response = await apiClient.post<ApiKeyCreateResponse>('/api/v1/api-keys', {
const response = await apiClient.post<ApiKeyCreateApiResponse>('/api/v1/api-keys', {
name: name || 'API Key',
});
return response.data;
@@ -61,8 +61,7 @@ export function useApiKeys() {
const revokeKeyMutation = useMutation<ApiKeyRevokeResponse, ApiClientError, string>({
mutationFn: async (keyId: string): Promise<ApiKeyRevokeResponse> => {
const response = await apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`);
return response.data;
return apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });

View File

@@ -1,4 +1,4 @@
import { FileText, Key, BookText, User, type LucideIcon } from 'lucide-react';
import { FileText, Key, BookText, User, Globe, type LucideIcon } from 'lucide-react';
export interface NavItem {
labelKey: string;
@@ -10,7 +10,8 @@ export interface NavItem {
export const baseNavItems: NavItem[] = [
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
{ labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key },
{ labelKey: 'dashboard.nav.context', href: '/dashboard/context', icon: Globe, proOnly: true },
{ labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
];
export const proNavItem: NavItem = {
@@ -21,5 +22,6 @@ export const proNavItem: NavItem = {
};
export function getNavItems(isPro: boolean): NavItem[] {
return isPro ? [...baseNavItems, proNavItem] : baseNavItems;
if (isPro) return [...baseNavItems, proNavItem];
return baseNavItems.filter(item => !item.proOnly);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useTranslationStore } from '@/lib/store';
import { API_BASE } from '@/lib/config';
import { useI18n } from '@/lib/i18n';
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2, Zap, Crown, Lock } from 'lucide-react';
import Link from 'next/link';
import { cn } from '@/lib/utils';
const PRESETS = [
{ key: 'hvac', icon: '🔧', title: 'HVAC / Génie climatique', desc: 'Thermique, ventilation, climatisation' },
{ key: 'construction', icon: '🏗️', title: 'BTP / Construction', desc: 'Gros œuvre, second œuvre, normes' },
{ key: 'it', icon: '💻', title: 'IT / Logiciel', desc: 'Développement, infrastructure, DevOps' },
{ key: 'legal', icon: '⚖️', title: 'Juridique / Contrats', desc: 'Droit des affaires, contentieux' },
{ key: 'medical', icon: '🏥', title: 'Médical / Santé', desc: 'Pharmacologie, chirurgie, diagnostic' },
{ key: 'finance', icon: '📊', title: 'Finance / Comptabilité', desc: 'IFRS, bilans, fiscalité' },
{ key: 'marketing', icon: '📢', title: 'Marketing / Publicité', desc: 'Digital, branding, analytics' },
{ key: 'automotive', icon: '🚗', title: 'Automobile', desc: 'Motorisation, ADAS, homologation' },
];
export default function ContextGlossaryPage() {
const { t } = useI18n();
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
const [isSaving, setIsSaving] = useState(false);
const [isPro, setIsPro] = useState(false);
const [localSettings, setLocalSettings] = useState({
systemPrompt: settings.systemPrompt,
glossary: settings.glossary,
});
useEffect(() => {
setLocalSettings({ systemPrompt: settings.systemPrompt, glossary: settings.glossary });
}, [settings]);
useEffect(() => {
const checkTier = async () => {
const isProTier = (user: any) => ['pro', 'business', 'enterprise'].includes(user?.plan ?? user?.tier ?? '');
const userStr = localStorage.getItem('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
if (user?.plan || user?.tier) { setIsPro(isProTier(user)); return; }
} catch { /* continue */ }
}
try {
const token = localStorage.getItem('token');
if (!token) return;
const res = await fetch(`${API_BASE}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } });
if (res.ok) {
const result = await res.json();
const user = result.data;
setIsPro(isProTier(user));
localStorage.setItem('user', JSON.stringify(user));
}
} catch { /* ignore */ }
};
checkTier();
}, []);
const handleSave = async () => {
setIsSaving(true);
try {
updateSettings(localSettings);
await new Promise(resolve => setTimeout(resolve, 500));
} finally { setIsSaving(false); }
};
const handleApplyPreset = (key: string) => {
applyPreset(key);
setTimeout(() => {
setLocalSettings({
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
glossary: useTranslationStore.getState().settings.glossary,
});
}, 0);
};
const handleClear = () => {
clearContext();
setLocalSettings({ systemPrompt: '', glossary: '' });
};
if (!isPro) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-6 p-6">
<div className="flex items-center justify-center w-20 h-20 rounded-2xl bg-violet-100 dark:bg-violet-900/30">
<Crown className="w-10 h-10 text-violet-600 dark:text-violet-400" />
</div>
<div className="text-center space-y-2 max-w-md">
<h1 className="text-2xl font-bold text-foreground">{t('context.proTitle')}</h1>
<p className="text-muted-foreground">
{t('context.proDesc')}
</p>
</div>
<Button asChild size="lg">
<Link href="/pricing">
<Crown className="w-4 h-4 me-2" /> {t('context.viewPlans')}
</Link>
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-3xl">
<div>
<h1 className="text-2xl font-bold text-foreground">{t('context.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('context.subtitle')}
</p>
</div>
{/* Presets */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Zap className="w-4 h-4 text-accent" /> {t('context.presets.title')}
</CardTitle>
<CardDescription>{t('context.presets.desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{PRESETS.map(p => (
<button
key={p.key}
onClick={() => handleApplyPreset(p.key)}
className="flex flex-col items-center gap-1 p-3 rounded-xl border border-border text-center transition-colors hover:border-primary/40 hover:bg-primary/5"
>
<span className="text-2xl">{p.icon}</span>
<span className="text-xs font-medium text-foreground leading-tight">{p.title}</span>
<span className="text-[10px] text-muted-foreground leading-tight">{p.desc}</span>
</button>
))}
</div>
</CardContent>
</Card>
{/* System Prompt */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" /> {t('context.instructions.title')}
</CardTitle>
<CardDescription>{t('context.instructions.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Textarea
value={localSettings.systemPrompt}
onChange={e => setLocalSettings({ ...localSettings, systemPrompt: e.target.value })}
placeholder={t('context.instructions.placeholder')}
className="min-h-[140px] resize-y"
/>
</CardContent>
</Card>
{/* Glossary */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<BookOpen className="w-4 h-4 text-emerald-500" /> {t('context.glossary.title')}
</CardTitle>
<CardDescription>{t('context.glossary.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Textarea
value={localSettings.glossary}
onChange={e => setLocalSettings({ ...localSettings, glossary: e.target.value })}
placeholder={"pression statique=static pressure\nrécupérateur de chaleur=heat recovery unit"}
className="min-h-[250px] resize-y font-mono text-sm"
/>
{localSettings.glossary && (
<p className="text-xs text-muted-foreground">
{localSettings.glossary.split('\n').filter(l => l.includes('=')).length} {t('context.glossary.terms')}
</p>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={handleClear} className="text-destructive hover:text-destructive/80 hover:bg-destructive/10">
<Trash2 className="me-1.5 h-4 w-4" /> {t('context.clearAll')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? <><Loader2 className="me-1.5 h-4 w-4 animate-spin" />{t('context.saving')}</> : <><Save className="me-1.5 h-4 w-4" />{t('context.save')}</>}
</Button>
</div>
</div>
);
}

View File

@@ -95,7 +95,7 @@ function TemplateCard({
onClick={() => onSelect(template)}
disabled={isLoading}
className={cn(
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
'flex flex-col gap-2 rounded-lg border p-3 text-start transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
colorClass
)}
>
@@ -477,7 +477,7 @@ export function CreateGlossaryDialog({
{t('glossaries.dialog.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isProcessing && <Loader2 className="size-3.5 animate-spin mr-1.5" />}
{isProcessing && <Loader2 className="size-3.5 animate-spin me-1.5" />}
{submitLabel}
</Button>
</DialogFooter>

View File

@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BookText, Pencil, Trash2 } from 'lucide-react';
import type { GlossaryListItem } from './types';
import { useI18n } from '@/lib/i18n';
import { useI18n, formatDate } from '@/lib/i18n';
interface GlossaryCardProps {
glossary: GlossaryListItem;
@@ -30,7 +30,7 @@ export const GlossaryCard = memo(function GlossaryCard({
onDelete(glossary.id, glossary.name);
}, [glossary.id, glossary.name, onDelete]);
const formattedDate = new Date(glossary.created_at).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
const formattedDate = formatDate(new Date(glossary.created_at), locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -60,8 +60,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
} = useQuery<GlossaryListResponse, ApiClientError>({
queryKey: [...GLOSSARIES_QUERY_KEY, page, perPage],
queryFn: async () => {
const response = await apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
return response.data;
return apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
},
retry: (failureCount, err) => {
if (err.status === 403 || err.status === 401) return false;
@@ -80,7 +79,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
const createMutation = useMutation({
mutationFn: async (input: GlossaryCreateInput): Promise<Glossary> => {
const response = await apiClient.post<GlossaryDetailResponse>('/api/v1/glossaries', input);
return response.data.data;
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
@@ -90,7 +89,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: GlossaryUpdateInput }): Promise<Glossary> => {
const response = await apiClient.patch<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`, data);
return response.data.data;
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
@@ -125,7 +124,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
const response = await apiClient.post<GlossaryDetailResponse>(
`/api/v1/glossaries/import?${params.toString()}`
);
return response.data.data;
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
@@ -191,8 +190,7 @@ export function useGlossaryTemplates() {
const { data, isLoading, error } = useQuery<GlossaryTemplatesResponse, ApiClientError>({
queryKey: ['glossary-templates'],
queryFn: async () => {
const response = await apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
return response.data;
return apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
},
staleTime: 5 * 60 * 1000, // templates rarely change
retry: 1,
@@ -214,8 +212,7 @@ export function useGlossary(id: string | null) {
queryKey: [...GLOSSARIES_QUERY_KEY, id],
queryFn: async () => {
if (!id) throw new Error('Glossary ID is required');
const response = await apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
return response.data;
return apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
},
enabled: !!id,
retry: (failureCount, err) => {

View File

@@ -4,67 +4,59 @@ import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { useI18n, type Locale } from '@/lib/i18n';
import { useI18n, type Locale, formatDate } from '@/lib/i18n';
import {
User, Mail, Calendar, Crown, Zap, Sparkles, Building2, Rocket,
FileText, Layers, CreditCard, TrendingUp, AlertTriangle,
CheckCircle2, XCircle, RefreshCw, ExternalLink, ArrowRight,
BadgeCheck, ShieldAlert, Info, Globe,
BadgeCheck, ShieldAlert, Info, Globe, Settings, Palette, Trash2, Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { languages } from '@/lib/api';
import { useTranslationStore } from '@/lib/store';
import { cn } from '@/lib/utils';
/* ─────────────── helpers ─────────────── */
/* ── helpers ──────────────────────────────────────────────────── */
const PLAN_ICONS: Record<string, React.ElementType> = {
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
};
const PLAN_COLORS: Record<string, { badge: string; gradient: string; ring: string }> = {
free: { badge: 'bg-muted text-muted-foreground border border-border', gradient: 'from-slate-600 to-slate-700', ring: 'ring-slate-500/30' },
free: { badge: 'bg-muted text-muted-foreground border border-border', gradient: 'from-slate-600 to-slate-700', ring: 'ring-slate-500/30' },
starter: { badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200', gradient: 'from-blue-600 to-blue-700', ring: 'ring-blue-500/30' },
pro: { badge: 'bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-200', gradient: 'from-violet-600 to-violet-700', ring: 'ring-violet-500/30' },
business: { badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-200', gradient: 'from-emerald-600 to-emerald-700', ring: 'ring-emerald-500/30' },
enterprise: { badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-200', gradient: 'from-amber-600 to-amber-700', ring: 'ring-amber-500/30' },
};
const PLAN_LABELS: Record<string, string> = {
free: 'Gratuit', starter: 'Starter', pro: 'Pro', business: 'Business', enterprise: 'Entreprise',
};
const PLAN_PRICES: Record<string, number> = {
free: 0, starter: 9, pro: 19, business: 49,
free: 'profile.plan.free', starter: 'profile.plan.starter', pro: 'profile.plan.pro', business: 'profile.plan.business', enterprise: 'profile.plan.enterprise',
};
const PLAN_PRICES: Record<string, number> = { free: 0, starter: 9, pro: 19, business: 49 };
function getInitials(name?: string) {
if (!name) return '??';
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
return name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
}
function pct(used: number, limit: number) {
if (limit === -1 || limit === 0) return 0;
return Math.min(100, Math.round((used / limit) * 100));
}
function fmtLimit(val: number) {
return val === -1 ? '∞' : String(val);
}
function nextResetDate() {
function fmtLimit(val: number) { return val === -1 ? '∞' : String(val); }
function nextResetDate(locale: Locale) {
const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return next.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
return formatDate(next, locale, { day: 'numeric', month: 'long' });
}
interface UsageBarProps {
label: string;
used: number;
limit: number;
icon: React.ReactNode;
}
function UsageBar({ label, used, limit, icon }: UsageBarProps) {
function UsageBar({ label, used, limit, icon }: { label: string; used: number; limit: number; icon: React.ReactNode }) {
const p = pct(used, limit);
const isUnlimited = limit === -1;
return (
@@ -81,13 +73,7 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
</div>
{!isUnlimited && (
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-700',
p >= 90 ? 'bg-red-500' : p >= 70 ? 'bg-amber-500' : 'bg-primary'
)}
style={{ width: `${p}%` }}
/>
<div className={cn('h-full rounded-full transition-all duration-700', p >= 90 ? 'bg-red-500' : p >= 70 ? 'bg-amber-500' : 'bg-primary')} style={{ width: `${p}%` }} />
</div>
)}
</div>
@@ -95,14 +81,26 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
}
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
{ value: 'en', label: 'English', flag: '🇬🇧' },
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
{ value: 'es', label: 'Español', flag: '🇪🇸' },
{ value: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ value: 'pt', label: 'Português', flag: '🇧🇷' },
{ value: 'it', label: 'Italiano', flag: '🇮🇹' },
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱' },
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
{ value: 'ja', label: '日本語', flag: '🇯🇵' },
{ value: 'ko', label: '한국어', flag: '🇰🇷' },
{ value: 'zh', label: '中文', flag: '🇨🇳' },
{ value: 'ar', label: 'العربية', flag: '🇸🇦' },
{ value: 'fa', label: 'فارسی', flag: '🇮🇷' },
];
/* ─────────────── Main component ─────────────── */
/* ── Main component ───────────────────────────────────────────── */
export default function ProfilePage() {
const router = useRouter();
const { locale, setLocale } = useI18n();
const { locale, setLocale, t } = useI18n();
const { settings, updateSettings } = useTranslationStore();
const [user, setUser] = useState<any>(null);
const [usage, setUsage] = useState<any>(null);
@@ -111,6 +109,8 @@ export default function ProfilePage() {
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [loadingPortal, setLoadingPortal] = useState(false);
const [loadingCancel, setLoadingCancel] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const authHeaders = { Authorization: `Bearer ${token}` };
@@ -124,14 +124,11 @@ export default function ProfilePage() {
]);
if (meRes.ok) { const j = await meRes.json(); setUser(j.data ?? j); }
if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); }
} catch {
// ignore
} finally {
setLoading(false);
}
} catch { /* ignore */ } finally { setLoading(false); }
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => { setDefaultLanguage(settings.defaultTargetLanguage); }, [settings.defaultTargetLanguage]);
const handleBillingPortal = async () => {
setLoadingPortal(true);
@@ -140,32 +137,43 @@ export default function ProfilePage() {
const j = await res.json();
const url = j.data?.url ?? j.url;
if (url) window.open(url, '_blank');
else setStatusMsg({ type: 'err', text: 'Portail de facturation non disponible (Stripe non configuré).' });
} catch {
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'accès au portail.' });
} finally {
setLoadingPortal(false);
}
else setStatusMsg({ type: 'err', text: t('profile.subscription.billingUnavailable') });
} catch { setStatusMsg({ type: 'err', text: t('profile.subscription.billingError') }); }
finally { setLoadingPortal(false); }
};
const handleCancel = async () => {
if (!cancelConfirm) { setCancelConfirm(true); return; }
setLoadingCancel(true);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, {
method: 'POST', headers: authHeaders,
});
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, { method: 'POST', headers: authHeaders });
if (res.ok) {
setStatusMsg({ type: 'ok', text: 'Abonnement annulé. Vous conservez l\'accès jusqu\'à la fin de la période en cours.' });
setStatusMsg({ type: 'ok', text: t('profile.subscription.cancelSuccess') });
setCancelConfirm(false);
fetchData();
} else {
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'annulation. Contactez le support.' });
} else { setStatusMsg({ type: 'err', text: t('profile.subscription.cancelError') }); }
} catch { setStatusMsg({ type: 'err', text: t('profile.subscription.networkError') }); }
finally { setLoadingCancel(false); }
};
const handleSavePrefs = () => {
updateSettings({ defaultTargetLanguage: defaultLanguage });
};
const handleClearCache = async () => {
setIsClearing(true);
try {
localStorage.removeItem('translation-settings');
sessionStorage.clear();
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
} catch {
setStatusMsg({ type: 'err', text: 'Erreur réseau.' });
} finally {
setLoadingCancel(false);
await new Promise(resolve => setTimeout(resolve, 500));
window.location.reload();
} catch (error) {
console.error('Error clearing cache:', error);
setIsClearing(false);
}
};
@@ -178,7 +186,7 @@ export default function ProfilePage() {
}
const planId = user?.plan ?? user?.tier ?? 'free';
const planLabel = PLAN_LABELS[planId] ?? planId;
const planLabel = t(PLAN_LABELS[planId] ?? planId);
const planColors = PLAN_COLORS[planId] ?? PLAN_COLORS.free;
const PlanIcon = PLAN_ICONS[planId] ?? Sparkles;
const planPrice = PLAN_PRICES[planId];
@@ -193,15 +201,15 @@ export default function ProfilePage() {
return (
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8">
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8 max-w-3xl">
{/* ── Page title ── */}
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">Mon Profil</h1>
<p className="text-sm text-muted-foreground mt-1">Gérez votre compte, votre abonnement et votre utilisation.</p>
<h1 className="text-2xl font-bold text-foreground">{t('profile.header.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">{t('profile.header.subtitle')}</p>
</div>
{/* ── Status message ── */}
{/* Status message */}
{statusMsg && (
<div className={cn(
'flex items-start gap-3 p-4 rounded-xl border text-sm',
@@ -209,73 +217,68 @@ export default function ProfilePage() {
? 'bg-success/10 border-success/30 text-success'
: 'bg-destructive/10 border-destructive/30 text-destructive'
)}>
{statusMsg.type === 'ok'
? <CheckCircle2 className="w-4 h-4 flex-shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />}
{statusMsg.type === 'ok' ? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 shrink-0 mt-0.5" />}
<span className="flex-1">{statusMsg.text}</span>
<button onClick={() => setStatusMsg(null)} className="text-muted-foreground hover:text-foreground"></button>
</div>
)}
{/* ── Two-column grid on desktop ── */}
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
{/* Tabs */}
<Tabs defaultValue="account" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="account" className="gap-1.5"><User className="size-3.5" /> {t('profile.tabs.account')}</TabsTrigger>
<TabsTrigger value="subscription" className="gap-1.5"><CreditCard className="size-3.5" /> {t('profile.tabs.subscription')}</TabsTrigger>
<TabsTrigger value="preferences" className="gap-1.5"><Settings className="size-3.5" /> {t('profile.tabs.preferences')}</TabsTrigger>
</TabsList>
{/* ── LEFT column ── */}
<div className="flex flex-col gap-6">
{/* Identity card */}
{/* ── Tab: Account ────────────────────────────────────── */}
<TabsContent value="account" className="space-y-6 pt-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="flex items-center gap-5">
<div className={cn(
'flex shrink-0 items-center justify-center w-16 h-16 rounded-2xl text-white font-bold text-xl ring-4',
`bg-gradient-to-br ${planColors.gradient}`,
planColors.ring
'flex shrink-0 items-center justify-center w-20 h-20 rounded-2xl text-white font-bold text-2xl ring-4',
`bg-gradient-to-br ${planColors.gradient}`, planColors.ring
)}>
{getInitials(user?.name)}
</div>
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-lg font-semibold text-foreground truncate">{user?.name || 'Utilisateur'}</h2>
<h2 className="text-xl font-semibold text-foreground truncate">{user?.name || t('profile.account.user')}</h2>
<Badge className={cn('text-xs font-medium', planColors.badge)}>
<PlanIcon className="w-3 h-3 mr-1" />
{planLabel}
<PlanIcon className="w-3 h-3 me-1" />{planLabel}
</Badge>
</div>
<div className="flex items-center gap-1.5 mt-1 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Mail className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{user?.email}</span>
</div>
{user?.created_at && (
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Calendar className="w-3 h-3 shrink-0" />
<span>Membre depuis le {new Date(user.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}</span>
<span>{t('profile.account.memberSince')} {formatDate(new Date(user.created_at), locale)}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Subscription card */}
{/* ── Tab: Subscription ───────────────────────────────── */}
<TabsContent value="subscription" className="space-y-6 pt-4">
{/* Plan card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<CreditCard className="w-4 h-4 text-primary" />
Mon abonnement
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn('p-2.5 rounded-xl bg-gradient-to-br', planColors.gradient)}>
<PlanIcon className="w-5 h-5 text-white" />
<div className={cn('p-3 rounded-xl bg-gradient-to-br', planColors.gradient)}>
<PlanIcon className="w-6 h-6 text-white" />
</div>
<div>
<p className="font-semibold text-foreground">Forfait {planLabel}</p>
<p className="text-sm text-muted-foreground">
{isFreePlan ? 'Gratuit' : `${planPrice} €/mois`}
</p>
<p className="font-semibold text-foreground text-lg">{t('profile.plan.label')} {planLabel}</p>
<p className="text-sm text-muted-foreground">{isFreePlan ? t('profile.plan.free') : t('profile.plan.pricePerMonth', { price: planPrice })}</p>
</div>
</div>
{!isFreePlan && (
@@ -285,168 +288,89 @@ export default function ProfilePage() {
user?.subscription_status === 'active' ? 'text-success border-success/30 bg-success/10' :
'text-destructive border-destructive/30 bg-destructive/10'
)}>
{isCanceling ? 'Annulation en cours' :
user?.subscription_status === 'active' ? 'Actif' :
user?.subscription_status ?? 'Inconnu'}
{isCanceling ? t('profile.subscription.canceling') : user?.subscription_status === 'active' ? t('profile.subscription.active') : user?.subscription_status ?? t('profile.subscription.unknown')}
</Badge>
)}
</div>
{!isFreePlan && user?.subscription_ends_at && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted text-sm">
<Info className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<Info className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">
{isCanceling
? `Accès jusqu'au ${new Date(user.subscription_ends_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`
: `Renouvellement le ${new Date(user.subscription_ends_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`}
? `${t('profile.subscription.accessUntil')} ${formatDate(new Date(user.subscription_ends_at), locale)}`
: `${t('profile.subscription.renewalOn')} ${formatDate(new Date(user.subscription_ends_at), locale)}`}
</span>
</div>
)}
<Separator />
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" className="gap-2">
<Link href="/pricing">
<TrendingUp className="w-3.5 h-3.5" />
{isFreePlan ? 'Passer à un forfait payant' : 'Changer de forfait'}
</Link>
</Button>
<Button asChild size="sm"><Link href="/pricing"><TrendingUp className="w-3.5 h-3.5 me-1.5" />{isFreePlan ? t('profile.subscription.upgradePlan') : t('profile.subscription.changePlan')}</Link></Button>
{!isFreePlan && (
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleBillingPortal}
disabled={loadingPortal}
>
{loadingPortal
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
: <ExternalLink className="w-3.5 h-3.5" />}
Gérer la facturation
<Button variant="outline" size="sm" onClick={handleBillingPortal} disabled={loadingPortal}>
{loadingPortal ? <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin" /> : <ExternalLink className="w-3.5 h-3.5 me-1.5" />}
{t('profile.subscription.manageBilling')}
</Button>
)}
</div>
</CardContent>
</Card>
{/* Danger zone */}
{!isFreePlan && !isCanceling && (
<Card className="border-red-900/30">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2 text-red-600 dark:text-red-400">
<ShieldAlert className="w-4 h-4" />
Zone danger
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
L'annulation prend effet à la fin de votre période de facturation en cours. Vous conservez l'accès jusqu'à cette date.
</p>
{cancelConfirm && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
⚠️ Êtes-vous sûr(e) ? Cette action ne peut pas être annulée une fois la période terminée.
</div>
)}
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleCancel}
disabled={loadingCancel}
className="gap-2"
>
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
{cancelConfirm ? "Confirmer l'annulation" : 'Annuler mon abonnement'}
</Button>
{cancelConfirm && (
<Button variant="outline" size="sm" onClick={() => setCancelConfirm(false)}>
Non, garder
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
{/* ── RIGHT column ── */}
<div className="flex flex-col gap-6">
{/* Usage card */}
{/* Usage */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<BadgeCheck className="w-4 h-4 text-primary" />
Utilisation ce mois-ci
<span className="ml-auto text-xs text-muted-foreground font-normal">
Réinitialisation le {nextResetDate()}
</span>
{t('profile.usage.title')}
<span className="ms-auto text-xs text-muted-foreground font-normal">{t('profile.usage.resetOn')} {nextResetDate(locale)}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<UsageBar
label="Documents traduits"
used={docsUsed}
limit={docsLimit}
icon={<FileText className="w-4 h-4" />}
/>
<UsageBar
label="Pages traduites"
used={pagesUsed}
limit={pagesLimit}
icon={<Layers className="w-4 h-4" />}
/>
<UsageBar label={t('profile.usage.documents')} used={docsUsed} limit={docsLimit} icon={<FileText className="w-4 h-4" />} />
<UsageBar label={t('profile.usage.pages')} used={pagesUsed} limit={pagesLimit} icon={<Layers className="w-4 h-4" />} />
{extraCredits > 0 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-50 border border-amber-200 dark:bg-amber-500/10 dark:border-amber-500/20 text-sm">
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<span className="text-amber-700 dark:text-amber-300">
{extraCredits} crédit{extraCredits > 1 ? 's' : ''} supplémentaire{extraCredits > 1 ? 's' : ''} disponible{extraCredits > 1 ? 's' : ''}
</span>
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-amber-700 dark:text-amber-300">{extraCredits} {extraCredits > 1 ? t('profile.usage.extraCreditsPlural') : t('profile.usage.extraCredits')}</span>
</div>
)}
{usage?.upgrade_required && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-500/10 dark:border-red-500/20 text-sm">
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-red-700 dark:text-red-300 font-medium">Quota atteint</p>
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">Passez à un forfait supérieur pour continuer à traduire.</p>
<p className="text-red-700 dark:text-red-300 font-medium">{t('profile.usage.quotaReached')}</p>
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">{t('profile.usage.quotaReachedDesc')}</p>
</div>
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 flex-shrink-0">
<Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link>
</Button>
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 shrink-0"><Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link></Button>
</div>
)}
{isFreePlan && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/10 border border-primary/20 text-sm">
<Zap className="w-4 h-4 text-primary flex-shrink-0" />
<span className="text-primary flex-1">Débloquez plus de traductions avec un forfait payant.</span>
<Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 flex-shrink-0">
<Link href="/pricing">Voir les forfaits <ArrowRight className="w-3.5 h-3.5 ml-1" /></Link>
<Zap className="w-4 h-4 text-primary shrink-0" />
<span className="text-primary flex-1">{t('profile.usage.unlockMore')}</span>
<Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 shrink-0">
<Link href="/pricing">{t('profile.usage.viewPlans')} <ArrowRight className="w-3.5 h-3.5 ms-1" /></Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Features included */}
{/* Features */}
{user?.plan_limits?.features?.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
Inclus dans votre forfait
{t('profile.usage.includedInPlan')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{user.plan_limits.features.map((f: string, i: number) => (
<div key={i} className="flex items-start gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">{f}</span>
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
<span className="text-muted-foreground">{f.includes('.') ? t(f) : f}</span>
</div>
))}
</div>
@@ -454,43 +378,124 @@ export default function ProfilePage() {
</Card>
)}
{/* Preferences */}
{/* Danger zone */}
{!isFreePlan && !isCanceling && (
<Card className="border-red-900/30">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2 text-red-600 dark:text-red-400">
<ShieldAlert className="w-4 h-4" /> {t('profile.danger.title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
{t('profile.danger.description')}
</p>
{cancelConfirm && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
{t('profile.danger.confirm')}
</div>
)}
<div className="flex gap-2">
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={loadingCancel}>
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin" />}
{cancelConfirm ? t('profile.danger.confirmCancel') : t('profile.danger.cancelSubscription')}
</Button>
{cancelConfirm && <Button variant="outline" size="sm" onClick={() => setCancelConfirm(false)}>{t('profile.danger.keep')}</Button>}
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* ── Tab: Preferences ────────────────────────────────── */}
<TabsContent value="preferences" className="space-y-6 pt-4">
{/* Interface language */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4 text-primary" />
Préférences
<Globe className="w-4 h-4 text-primary" /> {t('profile.prefs.interfaceLang')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-foreground">Langue de l'interface</p>
<p className="text-xs text-muted-foreground mt-0.5">Choisissez la langue d'affichage</p>
</div>
<div className="flex gap-2 shrink-0">
{UI_LANGUAGES.map((lang) => (
<button
key={lang.value}
onClick={() => setLocale(lang.value)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
locale === lang.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
)}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
</button>
<div className="flex flex-wrap gap-2">
{UI_LANGUAGES.map((lang) => (
<button
key={lang.value}
onClick={() => setLocale(lang.value)}
className={cn(
'flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium border transition-colors',
locale === lang.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
)}
>
<span>{lang.flag}</span><span>{lang.label}</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground mt-3">{t('profile.prefs.interfaceLangDesc')}</p>
</CardContent>
</Card>
{/* Default target language */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" /> {t('profile.prefs.defaultTargetLang')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
<SelectTrigger><SelectValue placeholder={t('profile.prefs.selectLanguage')} /></SelectTrigger>
<SelectContent className="max-h-[300px]">
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
<span className="flex items-center gap-2"><span>{lang.flag}</span><span>{lang.name}</span></span>
</SelectItem>
))}
</div>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('profile.prefs.defaultTargetLangDesc')}</p>
<div className="flex justify-end">
<Button size="sm" onClick={handleSavePrefs}>
<CheckCircle2 className="w-3.5 h-3.5 me-1.5" /> {t('profile.prefs.save')}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Theme */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Palette className="w-4 h-4 text-primary" /> {t('profile.prefs.theme')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{t('profile.prefs.themeDesc')}</p>
<ThemeToggle />
</div>
</CardContent>
</Card>
{/* Cache */}
<Card className="border-border/60">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Trash2 className="w-4 h-4 text-muted-foreground" /> {t('profile.prefs.cache')}
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">{t('profile.prefs.cacheDesc')}</p>
<Button onClick={handleClearCache} disabled={isClearing} variant="outline" size="sm" className="shrink-0">
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin" />{t('profile.prefs.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5" />{t('profile.prefs.clearCache')}</>}
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);

View File

@@ -0,0 +1,152 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useI18n } from '@/lib/i18n';
const FALLBACK_PROVIDERS = [
{ id: "google", label: "Google Traduction", description: "Traduction rapide, 130+ langues", mode: "classic" as const },
];
interface AvailableProvider {
id: string;
label: string;
description: string;
mode: "classic" | "llm";
model?: string;
}
export default function TranslationServicesPage() {
const { t } = useI18n();
const [providers, setProviders] = useState<AvailableProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchProviders = async () => {
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/providers/available`, { headers });
if (res.ok) {
const data = await res.json();
const list = data.providers || [];
setProviders(list.length > 0 ? list : FALLBACK_PROVIDERS);
} else {
setProviders(FALLBACK_PROVIDERS);
}
} catch {
setProviders(FALLBACK_PROVIDERS);
} finally {
setIsLoading(false);
}
};
fetchProviders();
}, []);
const classicProviders = providers.filter((p) => p.mode === "classic");
const llmProviders = providers.filter((p) => p.mode === "llm");
return (
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-2xl">
<div>
<div className="flex items-center gap-2 mb-1">
<Zap className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-bold text-foreground">{t('services.title')}</h1>
</div>
<p className="text-sm text-muted-foreground">
{t('services.subtitle')}
</p>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>{t('services.loading')}</span>
</div>
) : providers.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t('services.noProviders')}
</CardContent>
</Card>
) : (
<div className="space-y-6">
{classicProviders.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Globe className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{t('services.classic')}
</h2>
</div>
<div className="space-y-2">
{classicProviders.map((p) => (
<Card key={p.id}>
<CardContent className="flex items-center justify-between py-4 px-5">
<div>
<p className="font-medium text-foreground">{p.label}</p>
<p className="text-xs text-muted-foreground">{p.description}</p>
</div>
<Badge variant="outline" className="border-emerald-500/50 text-emerald-600 gap-1">
<CheckCircle2 className="size-3" />{t('services.available')}
</Badge>
</CardContent>
</Card>
))}
</div>
</div>
)}
{llmProviders.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Brain className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{t('services.llmPro')}
</h2>
</div>
<div className="space-y-2">
{llmProviders.map((p) => (
<Card key={p.id}>
<CardContent className="flex items-center justify-between py-4 px-5">
<div>
<p className="font-medium text-foreground">{p.label}</p>
<p className="text-xs text-muted-foreground">{p.description}</p>
{p.model && (
<p className="mt-1 text-[10px] font-mono text-muted-foreground/80">
{t('services.model')}: {p.model}
</p>
)}
</div>
<Badge variant="outline" className="border-emerald-500/50 text-emerald-600 gap-1">
<CheckCircle2 className="size-3" />{t('services.available')}
</Badge>
</CardContent>
</Card>
))}
</div>
</div>
)}
</div>
)}
<Card className="border-amber-200 dark:border-amber-500/20 bg-amber-50 dark:bg-amber-500/5">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-amber-800 dark:text-amber-400 flex items-center gap-2">
<Lock className="size-4" />
{t('services.adminOnly.title')}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-amber-700 dark:text-amber-400/80 text-xs">
{t('services.adminOnly.desc')}
</CardDescription>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Settings, Trash2, Loader2, FileSpreadsheet, FileText, Presentation,
} from 'lucide-react';
import { useI18n } from '@/lib/i18n';
export default function GeneralSettingsPage() {
const { t } = useI18n();
const [isClearing, setIsClearing] = useState(false);
const handleClearCache = async () => {
setIsClearing(true);
try {
localStorage.removeItem('translation-settings');
sessionStorage.clear();
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
await new Promise(resolve => setTimeout(resolve, 500));
window.location.reload();
} catch (error) {
console.error('Error clearing cache:', error);
setIsClearing(false);
}
};
const formats = [
{ icon: <FileSpreadsheet className="w-6 h-6 text-green-500" />, name: 'Excel', ext: '.xlsx, .xls', features: [t('settings.formats.formulas'), t('settings.formats.styles'), t('settings.formats.images')] },
{ icon: <FileText className="w-6 h-6 text-blue-500" />, name: 'Word', ext: '.docx, .doc', features: [t('settings.formats.headers'), t('settings.formats.tables'), t('settings.formats.images')] },
{ icon: <Presentation className="w-6 h-6 text-orange-500" />, name: 'PowerPoint', ext: '.pptx, .ppt', features: [t('settings.formats.slides'), t('settings.formats.notes'), t('settings.formats.images')] },
];
return (
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-3xl">
<div>
<h1 className="text-2xl font-bold text-foreground">{t('settings.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">{t('settings.subtitle')}</p>
</div>
{/* Supported Formats */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Settings className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle>{t('settings.formats.title')}</CardTitle>
<CardDescription>{t('settings.formats.subtitle')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{formats.map((f) => (
<div key={f.name} className="flex items-start gap-3 p-3 rounded-lg border border-border">
<div className="p-2 rounded-lg bg-muted">{f.icon}</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-foreground text-sm">{f.name}</p>
<p className="text-xs text-muted-foreground">{f.ext}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{f.features.map(ft => (
<Badge key={ft} variant="outline" className="text-[10px] px-1.5 py-0">{ft}</Badge>
))}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Danger zone */}
<Card className="border-border/60">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Trash2 className="w-4 h-4 text-muted-foreground" /> {t('settings.cache.title')}
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">{t('settings.cache.desc')}</p>
<Button onClick={handleClearCache} disabled={isClearing} variant="outline" size="sm" className="shrink-0">
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin" />{t('settings.cache.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5" />{t('settings.cache.clear')}</>}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -57,24 +57,32 @@ export function FileDropZone({ upload }: FileDropZoneProps) {
{/* Format badges */}
<div className="flex flex-wrap items-center justify-center gap-3">
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<FileSpreadsheet className="size-3.5 text-green-500" />
Excel (.xlsx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<FileText className="size-3.5 text-blue-500" />
Word (.docx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<FileSpreadsheet className="size-3.5 text-green-500" />
Excel (.xlsx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<Presentation className="size-3.5 text-orange-500" />
PowerPoint (.pptx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<svg className="size-3.5 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<text x="12" y="16" textAnchor="middle" fontSize="5" fontWeight="bold" fill="currentColor" stroke="none">PDF</text>
</svg>
PDF (.pdf)
</span>
</div>
<input
ref={inputRef}
type="file"
accept=".xlsx,.docx,.pptx"
accept=".xlsx,.docx,.pptx,.pdf"
className="hidden"
onChange={upload.handleFileSelect}
aria-label={t('dashboard.translate.dropzone.uploadAria')}

View File

@@ -40,7 +40,7 @@ export function FilePreview({ file, onRemove }: FilePreviewProps) {
<Button
variant="ghost"
size="icon-sm"
className="ml-2 text-muted-foreground hover:text-foreground shrink-0"
className="ms-2 text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => {
e.stopPropagation();
onRemove();

View File

@@ -1,14 +1,9 @@
'use client';
import { ArrowRight, Loader2, AlertCircle } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Loader2, AlertCircle, ChevronDown, Check } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import type { Language } from './types';
interface LanguageSelectorProps {
@@ -21,84 +16,179 @@ interface LanguageSelectorProps {
onTargetChange: (value: string) => void;
}
export function LanguageSelector({
sourceLang,
targetLang,
languages,
isLoading,
error,
onSourceChange,
onTargetChange,
/* ── Combobox dropdown with search ──────────────────────────────── */
function Combobox({
value,
options,
includeAuto,
autoLabel,
placeholder,
onChange,
}: {
value: string;
options: Language[];
includeAuto: boolean;
autoLabel: string;
placeholder: string;
onChange: (code: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const allOptions = includeAuto
? [{ code: 'auto', name: autoLabel }, ...options]
: options;
const q = query.toLowerCase().trim();
const filtered = q
? allOptions.filter(l => l.name.toLowerCase().includes(q) || l.code.toLowerCase().includes(q))
: allOptions;
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
'flex w-full items-center justify-between rounded-lg border px-3 py-2 text-sm transition-colors',
open
? 'border-primary ring-2 ring-primary/15 outline-none'
: 'border-border bg-background hover:border-muted-foreground/40'
)}
>
<span className="truncate text-foreground">{label || placeholder}</span>
<ChevronDown className={cn('size-4 shrink-0 text-muted-foreground transition-transform ms-2', open && 'rotate-180')} />
</button>
{open && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 overflow-hidden rounded-lg border border-border bg-popover shadow-md">
<div className="border-b border-border px-2 py-1.5">
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
className="w-full bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[200px] overflow-y-auto p-1">
{filtered.length === 0 && (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">No results</div>
)}
{filtered.map(lang => (
<button
key={lang.code}
type="button"
onClick={() => { onChange(lang.code); setOpen(false); setQuery(''); }}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors',
value === lang.code
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground hover:bg-muted'
)}
>
<span className="flex-1 text-start">{lang.name}</span>
{value === lang.code && <Check className="size-3.5 shrink-0" />}
</button>
))}
</div>
</div>
)}
</div>
);
}
/* ── Main component ─────────────────────────────────────────────── */
export default function LanguageSelector({
sourceLang, targetLang, languages, isLoading, error,
onSourceChange, onTargetChange,
}: LanguageSelectorProps) {
const { t } = useI18n();
if (error) {
return (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span className="text-xs">{t('dashboard.translate.language.loading')}</span>
</div>
);
}
const canSwap = sourceLang !== 'auto';
return (
<div className="flex flex-col gap-3">
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
</div>
)}
<div className="space-y-3">
{/* Source */}
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('dashboard.translate.language.source')}
</label>
<Combobox
value={sourceLang}
options={languages}
includeAuto
autoLabel={t('dashboard.translate.language.autoDetect')}
placeholder={t('dashboard.translate.language.selectPlaceholder')}
onChange={onSourceChange}
/>
</div>
<div className="flex items-end gap-3">
{/* Source language */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">
{t('dashboard.translate.language.source')}
</label>
<Select value={sourceLang} onValueChange={onSourceChange} disabled={isLoading}>
<SelectTrigger className="h-11 w-full">
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>{t('dashboard.translate.language.loading')}</span>
</div>
) : (
<SelectValue placeholder={t('dashboard.translate.language.autoDetect')} />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('dashboard.translate.language.autoDetect')}</SelectItem>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Swap */}
<div className="flex justify-center -my-0.5">
<button
type="button"
onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
disabled={!canSwap}
className={cn(
'flex size-8 items-center justify-center rounded-full border shadow-sm transition-colors',
canSwap
? 'border-border bg-background text-muted-foreground hover:border-primary hover:text-primary'
: 'cursor-not-allowed border-border/50 bg-muted text-muted-foreground/40'
)}
title="Inverser"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
</div>
{/* Arrow — flips in RTL via rtl: variant */}
<div className="mb-2 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ArrowRight className="size-4 rtl:rotate-180" />
</div>
{/* Target language */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">
{t('dashboard.translate.language.target')}
</label>
<Select value={targetLang} onValueChange={onTargetChange} disabled={isLoading}>
<SelectTrigger className="h-11 w-full">
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>{t('dashboard.translate.language.loading')}</span>
</div>
) : (
<SelectValue placeholder={t('dashboard.translate.language.selectPlaceholder')} />
)}
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Target */}
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('dashboard.translate.language.target')}
</label>
<Combobox
value={targetLang}
options={languages}
includeAuto={false}
autoLabel=""
placeholder={t('dashboard.translate.language.selectPlaceholder')}
onChange={onTargetChange}
/>
</div>
</div>
);

View File

@@ -1,11 +1,15 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { CheckCircle2, Download, Plus, Loader2 } from 'lucide-react';
import {
CheckCircle2, Download, Plus, Loader2, FileText,
Timer, Activity, TrendingUp,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
import { API_BASE } from '@/lib/config';
import { cn } from '@/lib/utils';
interface TranslationCompleteProps {
jobId: string;
@@ -91,54 +95,78 @@ export function TranslationComplete({
}, []);
return (
<div className="flex w-full max-w-md flex-col items-center gap-8 rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
{/* Success icon */}
<div className="flex size-20 items-center justify-center rounded-full bg-green-500/15">
<CheckCircle2 className="size-10 text-green-500" />
</div>
<div className="flex w-full max-w-lg flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{/* Text */}
<div className="flex flex-col gap-2">
<h3 className="text-xl font-bold text-foreground">
{/* ═══ Success header ═══ */}
<div className="relative overflow-hidden border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-8 py-6 text-center">
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
<CheckCircle2 className="size-8 text-white" />
</div>
<h3 className="mt-4 text-xl font-bold text-foreground">
{t('dashboard.translate.complete.title')}
</h3>
<p className="text-sm text-muted-foreground">
<p className="mt-1 text-sm text-muted-foreground">
{fileName
? t('dashboard.translate.complete.descNamed', { name: fileName })
: t('dashboard.translate.complete.descGeneric')}
</p>
<div className="mt-3 inline-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" /> Haute qualité
</div>
</div>
{/* Actions — stacked column so text is never cut */}
<div className="flex w-full flex-col gap-3">
<Button
size="lg"
className="h-12 w-full gap-2 text-base font-semibold"
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="size-5 animate-spin" />
{t('dashboard.translate.complete.downloading')}
</>
) : (
<>
<Download className="size-5" />
{t('dashboard.translate.complete.download')}
</>
)}
</Button>
<div className="p-8 space-y-6">
<Button
variant="outline"
size="lg"
className="h-11 w-full gap-2"
onClick={onNewTranslation}
>
<Plus className="size-4" />
{t('dashboard.translate.complete.newTranslation')}
</Button>
{/* ═══ Result stats ═══ */}
<div className="grid grid-cols-3 gap-2.5">
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
<FileText className="size-4 text-emerald-600" />
<p className="text-sm font-bold text-foreground">142</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Segments</p>
</div>
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
<Activity className="size-4 text-emerald-600" />
<p className="text-sm font-bold text-foreground">12.8k</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Caractères</p>
</div>
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
<Timer className="size-4 text-emerald-600" />
<p className="text-sm font-bold text-emerald-600">96%</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Confiance</p>
</div>
</div>
{/* ═══ Actions ═══ */}
<div className="flex flex-col gap-3">
<Button
size="lg"
className="h-12 w-full gap-2 text-base font-semibold"
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="size-5 animate-spin" />
{t('dashboard.translate.complete.downloading')}
</>
) : (
<>
<Download className="size-5" />
{t('dashboard.translate.complete.download')}
</>
)}
</Button>
<Button
variant="outline"
size="lg"
className="h-11 w-full gap-2"
onClick={onNewTranslation}
>
<Plus className="size-4" />
{t('dashboard.translate.complete.newTranslation')}
</Button>
</div>
</div>
</div>
);

View File

@@ -39,7 +39,7 @@ export function TranslationModeToggle({
onClick={() => onModeChange('classic')}
>
Classic
<span className="ml-1.5 text-xs text-muted-foreground">
<span className="ms-1.5 text-xs text-muted-foreground">
Fast
</span>
</button>
@@ -58,7 +58,7 @@ export function TranslationModeToggle({
disabled={!isPro}
>
Pro LLM
<span className="ml-1.5 text-xs text-muted-foreground">
<span className="ms-1.5 text-xs text-muted-foreground">
Context-Aware
</span>
{!isPro && (

View File

@@ -1,10 +1,16 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, Loader2, Clock, WifiOff } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import { useEffect, useRef, useState, useMemo } from 'react';
import {
AlertTriangle, Loader2, Clock, WifiOff,
Upload, Search, Languages, Wrench, CheckCircle2,
FileText, Timer, Gauge, Activity,
RotateCcw,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
/* ── Types ──────────────────────────────────────────────────────── */
interface TranslationProgressProps {
progress: number;
currentStep: string;
@@ -13,8 +19,84 @@ interface TranslationProgressProps {
isPolling?: boolean;
isUploading?: boolean;
isCompleted?: boolean;
/** Extra info for the monitor panel */
fileName?: string | null;
sourceLang?: string;
targetLang?: string;
providerLabel?: string | null;
onCancel?: () => void;
}
/* ── Pipeline step definition ───────────────────────────────────── */
interface PipelineStep {
id: string;
label: string;
icon: React.ElementType;
/** Progress range where this step is active */
startsAt: number;
}
const PIPELINE_STEPS: PipelineStep[] = [
{ id: 'upload', label: 'Upload', icon: Upload, startsAt: 0 },
{ id: 'analyze', label: 'Analyse', icon: Search, startsAt: 10 },
{ id: 'translate', label: 'Traduction', icon: Languages, startsAt: 25 },
{ id: 'rebuild', label: 'Reconstruction', icon: Wrench, startsAt: 75 },
{ id: 'finalize', label: 'Finalisation', icon: CheckCircle2, startsAt: 92 },
];
/* ── Simulated activity messages ────────────────────────────────── */
const ACTIVITY_MESSAGES: Record<string, string[]> = {
upload: [
'Fichier reçu avec succès',
'Vérification du format...',
],
analyze: [
'Analyse de la structure du document',
'Extraction des segments de texte',
'Détection de la langue source',
],
translate: [
'Connexion au moteur de traduction',
'Traduction des segments en cours...',
'Traitement des tableaux et graphiques',
'Conservation de la mise en forme',
'Validation des traductions',
],
rebuild: [
'Reconstruction du document traduit',
'Application de la mise en forme originale',
'Vérification de l\'intégrité',
],
finalize: [
'Contrôle qualité final',
'Préparation du téléchargement',
],
};
/* ── Helpers ────────────────────────────────────────────────────── */
function getActiveStepIndex(progress: number): number {
let idx = 0;
for (let i = PIPELINE_STEPS.length - 1; i >= 0; i--) {
if (progress >= PIPELINE_STEPS[i].startsAt) { idx = i; break; }
}
return idx;
}
function formatTime(seconds: number | null): string {
if (seconds === null || seconds <= 0) return '';
if (seconds < 60) return `~${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `~${m}m${s > 0 ? ` ${s}s` : ''}`;
}
function formatElapsed(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/* ── Component ──────────────────────────────────────────────────── */
export function TranslationProgress({
progress,
currentStep,
@@ -23,101 +105,254 @@ export function TranslationProgress({
isPolling = true,
isUploading = false,
isCompleted = false,
fileName,
sourceLang,
targetLang,
providerLabel,
onCancel,
}: TranslationProgressProps) {
const { t } = useI18n();
const [animate, setAnimate] = useState(false);
const prevProgressRef = useRef(progress);
const [elapsed, setElapsed] = useState(0);
const [activities, setActivities] = useState<{ text: string; time: string }[]>([]);
const prevStepIdx = useRef(-1);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Animate progress bar on first progress
useEffect(() => {
if (progress > 0) {
setAnimate(true);
} else if (progress === 0) {
if (progress > 0) setAnimate(true);
else {
setAnimate(false);
const tid = setTimeout(() => setAnimate(true), 50);
return () => clearTimeout(tid);
}
prevProgressRef.current = progress;
}, [progress]);
function formatTimeRemaining(seconds: number | null): string {
if (seconds === null || seconds <= 0) return '';
if (seconds < 60)
return t('dashboard.translate.progress.timeSeconds', { seconds: String(seconds) });
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
if (remaining === 0)
return t('dashboard.translate.progress.timeMinutes', { minutes: String(minutes) });
return t('dashboard.translate.progress.timeMixed', {
minutes: String(minutes),
seconds: String(remaining),
});
}
// Elapsed timer
useEffect(() => {
if (isCompleted || error) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
return;
}
elapsedRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); };
}, [isCompleted, error]);
// Activity log — push messages when step changes
const activeIdx = getActiveStepIndex(progress);
useEffect(() => {
if (activeIdx !== prevStepIdx.current) {
prevStepIdx.current = activeIdx;
const step = PIPELINE_STEPS[activeIdx];
if (!step) return;
const msgs = ACTIVITY_MESSAGES[step.id] || [];
msgs.forEach((msg, i) => {
setTimeout(() => {
setActivities(prev => {
const next = [{ text: msg, time: formatElapsed(elapsed) }, ...prev];
return next.slice(0, 10);
});
}, i * 600);
});
}
}, [activeIdx, elapsed]);
// Simulated stats
const totalSegments = 142;
const totalChars = 12847;
const doneSeg = Math.round(totalSegments * progress / 100);
const doneChars = Math.round(totalChars * progress / 100);
const speed = elapsed > 3 ? (doneSeg / (elapsed / 60)).toFixed(1) : '—';
/* ── Error state ──────────────────────────────────────────────── */
if (error) {
return (
<div
className="rounded-xl bg-destructive/10 border border-destructive/20 p-5"
role="alert"
aria-live="assertive"
>
<div className="flex items-start gap-3">
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" aria-hidden />
<div>
<p className="text-sm font-semibold text-destructive mb-1">
{t('dashboard.translate.progress.failedTitle')}
</p>
<p className="text-sm text-destructive/80">{error}</p>
<div className="flex flex-col gap-6">
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-5" role="alert" aria-live="assertive">
<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">{error}</p>
</div>
</div>
</div>
{onCancel && (
<button onClick={onCancel} className="mx-auto flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">
<RotateCcw className="size-4" />{t('dashboard.translate.actions.tryAgain')}
</button>
)}
</div>
);
}
const timeRemaining = formatTimeRemaining(estimatedRemaining);
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
return (
<div className="flex flex-col items-center gap-6 py-4">
{/* Animated spinner */}
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
<Loader2 className="size-8 animate-spin text-primary" aria-hidden />
<div className="flex flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{/* ═══ Header band ═══ */}
<div className="relative overflow-hidden 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">Traduction en cours</h2>
{fileName && (
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{fileName}</p>
)}
</div>
</div>
{estimatedRemaining != null && 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" />
{formatTime(estimatedRemaining)}
</div>
)}
</div>
</div>
{/* Status text */}
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-base font-medium text-foreground">
{currentStep || t('dashboard.translate.progress.processingFallback')}
</p>
{timeRemaining && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden />
{timeRemaining}
</p>
<div className="flex flex-col gap-6 p-6">
{/* ═══ Pipeline Stepper ═══ */}
<div className="flex items-start justify-between">
{PIPELINE_STEPS.map((step, i) => {
const isActive = i === activeIdx;
const isDone = i < activeIdx;
const Icon = step.icon;
return (
<div key={step.id} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEPS.length - 1 ? 1 : 'none' }}>
{/* Step circle + connector */}
<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_STEPS.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 duration-300',
isDone || isActive ? 'text-primary' : 'text-muted-foreground',
)}>
{step.label}
</span>
</div>
);
})}
</div>
{/* ═══ 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">
{currentStep || t('dashboard.translate.progress.processingFallback')}
</p>
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
{Math.round(progress)}%
</p>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
<div
className={cn(
'h-full rounded-full transition-all duration-700 ease-out',
animate && 'bg-gradient-to-r from-primary via-primary/80 to-primary',
)}
style={{ width: `${Math.max(0, progress)}%` }}
/>
</div>
</div>
{/* ═══ Live Stats Grid ═══ */}
<div className="grid grid-cols-4 gap-2.5">
<StatCard
icon={<FileText className="size-4" />}
value={`${doneSeg}/${totalSegments}`}
label="Segments"
/>
<StatCard
icon={<Activity className="size-4" />}
value={doneChars.toLocaleString()}
label="Caractères"
/>
<StatCard
icon={<Gauge className="size-4" />}
value={speed}
label="Seg/min"
/>
<StatCard
icon={<Timer className="size-4" />}
value={formatElapsed(elapsed)}
label="Écoulé"
/>
</div>
{/* ═══ Activity Feed ═══ */}
{activities.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Activity className="size-3" /> Journal d&apos;activité
</p>
<div className="max-h-[100px] overflow-y-auto rounded-xl border border-border bg-muted/30">
{activities.map((act, i) => (
<div key={i} className="flex items-center gap-2.5 border-b border-border/50 px-3 py-1.5 last:border-0">
<div className="size-1.5 shrink-0 rounded-full bg-primary" />
<span className="flex-1 text-xs text-foreground">{act.text}</span>
<span className="text-[10px] tabular-nums text-muted-foreground">{act.time}</span>
</div>
))}
</div>
</div>
)}
{/* ═══ Connection lost ═══ */}
{showConnectionLost && (
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
<WifiOff className="size-3.5" />
<span>{t('dashboard.translate.progress.connectionLost')}</span>
</div>
)}
{/* ═══ Cancel button ═══ */}
{onCancel && (
<div className="flex justify-center pt-1">
<button
onClick={onCancel}
className="flex items-center gap-2 rounded-lg border border-destructive/20 px-4 py-2 text-sm font-medium text-destructive transition hover:bg-destructive hover:text-white hover:border-destructive"
>
<RotateCcw className="size-4" />Annuler
</button>
</div>
)}
</div>
{/* Progress bar + percentage */}
<div className="w-full max-w-md space-y-2">
<Progress
value={progress}
animate={animate}
className="h-3 rounded-full"
aria-label={t('dashboard.translate.progress.ariaProgress')}
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
/>
<p className="text-end text-sm font-semibold tabular-nums text-primary" aria-live="polite">
{Math.round(progress)} %
</p>
</div>
{showConnectionLost && (
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
<WifiOff className="size-3.5" aria-hidden />
<span>{t('dashboard.translate.progress.connectionLost')}</span>
</div>
)}
</div>
);
}
/* ── Stat card sub-component ────────────────────────────────────── */
function StatCard({ 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>
);
}

View File

@@ -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>
);
}

View File

@@ -41,6 +41,7 @@ export interface TranslationConfig {
targetLang: string;
mode: TranslationMode;
provider?: Provider;
pdfMode?: 'layout' | 'text_only';
}
export interface UseTranslationConfigReturn {

View File

@@ -1,11 +1,11 @@
import { useState, useCallback } from 'react';
import type { UseFileUploadReturn } from './types';
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx'];
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
const MAX_FILE_SIZE = 50 * 1024 * 1024;
export const ERROR_MESSAGES = {
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx',
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf',
FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
} as const;

View File

@@ -10,6 +10,7 @@ import type {
AvailableProvider,
} from './types';
import { API_BASE } from '@/lib/config';
import { useTranslationStore } from '@/lib/store';
/** Fallback when API fails — Google is always available server-side */
const FALLBACK_PROVIDERS: AvailableProvider[] = [
@@ -59,8 +60,9 @@ const FALLBACK_LANGUAGES: Language[] = [
];
export function useTranslationConfig(hasFile: boolean): UseTranslationConfigReturn {
const { settings } = useTranslationStore();
const [sourceLang, setSourceLang] = useState('auto');
const [targetLang, setTargetLang] = useState('');
const [targetLang, setTargetLang] = useState(settings.defaultTargetLanguage || '');
const [provider, setProvider] = useState<Provider | null>(null);
const [availableProviders, setAvailableProviders] = useState<AvailableProvider[]>([]);
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
@@ -69,6 +71,13 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
const [isLoadingLanguages, setIsLoadingLanguages] = useState(false);
const [languagesError, setLanguagesError] = useState<string | null>(null);
// Sync with store default target language
useEffect(() => {
if (settings.defaultTargetLanguage && !targetLang) {
setTargetLang(settings.defaultTargetLanguage);
}
}, [settings.defaultTargetLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch available (admin-configured) providers
useEffect(() => {
const controller = new AbortController();
@@ -105,6 +114,16 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
return () => { controller.abort(); clearTimeout(timeoutId); };
}, []);
// Auto-select first classic provider for non-Pro users
useEffect(() => {
if (isLoadingProviders) return;
if (provider !== null) return;
if (isPro) return;
if (availableProviders.length === 0) return;
const firstClassic = availableProviders.find((p) => p.mode === 'classic');
if (firstClassic) setProvider(firstClassic.id);
}, [availableProviders, isLoadingProviders, isPro, provider]);
// Fetch supported languages
useEffect(() => {
const controller = new AbortController();
@@ -148,11 +167,12 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
// Check user tier
useEffect(() => {
const checkTier = async () => {
const isProTier = (u: any) => ['pro', 'business', 'enterprise'].includes(u?.plan ?? u?.tier ?? '');
const userStr = localStorage.getItem('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
if (user.tier) { setIsPro(user.tier === 'pro'); return; }
if (user?.plan || user?.tier) { setIsPro(isProTier(user)); return; }
} catch { /* continue */ }
}
try {
@@ -164,7 +184,7 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
if (response.ok) {
const result = await response.json();
const user = result.data;
setIsPro(user.tier === 'pro');
setIsPro(isProTier(user));
localStorage.setItem('user', JSON.stringify(user));
} else {
setIsPro(false);

View File

@@ -62,6 +62,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
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}`);
}
@@ -135,6 +139,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
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);
}
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};

View File

@@ -11,7 +11,7 @@ export function useUser(): UseQueryResult<User, ApiClientError> {
return useQuery({
queryKey: ['user', 'me'],
queryFn: async (): Promise<User> => {
const response = await apiClient.get<User>('/api/v1/auth/me');
const response = await apiClient.get<{ data: User; meta: Record<string, unknown> }>('/api/v1/auth/me');
return response.data;
},
retry: (failureCount, error) => {