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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
195
frontend/src/app/dashboard/context/page.tsx
Normal file
195
frontend/src/app/dashboard/context/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
152
frontend/src/app/dashboard/services/page.tsx
Normal file
152
frontend/src/app/dashboard/services/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/src/app/dashboard/settings/page.tsx
Normal file
95
frontend/src/app/dashboard/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface TranslationConfig {
|
||||
targetLang: string;
|
||||
mode: TranslationMode;
|
||||
provider?: Provider;
|
||||
pdfMode?: 'layout' | 'text_only';
|
||||
}
|
||||
|
||||
export interface UseTranslationConfigReturn {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user