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

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

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

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -12,94 +12,473 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { TermEditor } from './TermEditor';
import { parseFileToTerms } from './csvUtils';
import { useGlossaryTemplates } from './useGlossaries';
import type { GlossaryTermInput } from './types';
import type { GlossaryTemplate } from './useGlossaries';
import { useI18n } from '@/lib/i18n';
import {
Upload,
FileText,
BookOpen,
PenLine,
CheckCircle2,
AlertCircle,
Loader2,
X,
Scale,
Cpu,
TrendingUp,
HeartPulse,
Megaphone,
Users,
FlaskConical,
ShoppingCart,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface CreateGlossaryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
onImportTemplate: (templateId: string, name?: string) => Promise<void>;
isCreating: boolean;
isImportingTemplate: boolean;
}
const TEMPLATE_ICONS: Record<string, React.ReactNode> = {
legal: <Scale className="size-5" />,
technology: <Cpu className="size-5" />,
finance: <TrendingUp className="size-5" />,
medical: <HeartPulse className="size-5" />,
marketing: <Megaphone className="size-5" />,
hr: <Users className="size-5" />,
scientific: <FlaskConical className="size-5" />,
ecommerce: <ShoppingCart className="size-5" />,
};
const TEMPLATE_COLORS: Record<string, string> = {
legal: 'bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100',
technology: 'bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100',
finance: 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100',
medical: 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100',
marketing: 'bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100',
hr: 'bg-teal-50 text-teal-700 border-teal-200 hover:bg-teal-100',
scientific: 'bg-indigo-50 text-indigo-700 border-indigo-200 hover:bg-indigo-100',
ecommerce: 'bg-pink-50 text-pink-700 border-pink-200 hover:bg-pink-100',
};
type FileStatus = 'idle' | 'parsing' | 'success' | 'error';
const MAX_FILE_SIZE_MB = 5;
function TemplateCard({
template,
onSelect,
isLoading,
termsLabel,
}: {
template: GlossaryTemplate;
onSelect: (t: GlossaryTemplate) => void;
isLoading: boolean;
termsLabel: string;
}) {
const icon = TEMPLATE_ICONS[template.id] ?? <BookOpen className="size-5" />;
const colorClass = TEMPLATE_COLORS[template.id] ?? 'bg-muted text-foreground border-border hover:bg-muted/80';
return (
<button
type="button"
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',
colorClass
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{icon}
<span className="text-sm font-medium leading-tight">
{template.name.split(' - ')[0]}
</span>
</div>
<Badge variant="secondary" className="shrink-0 text-xs font-normal">
{template.terms_count} {termsLabel}
</Badge>
</div>
<p className="text-xs opacity-75 line-clamp-2">{template.description}</p>
</button>
);
}
function FileUploadZone({
onTermsParsed,
disabled,
}: {
onTermsParsed: (terms: GlossaryTermInput[], filename: string) => void;
disabled: boolean;
}) {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState<FileStatus>('idle');
const [errorMsg, setErrorMsg] = useState('');
const [parsedFile, setParsedFile] = useState<{ name: string; count: number } | null>(null);
const processFile = useCallback(async (file: File) => {
const ext = file.name.split('.').pop()?.toLowerCase();
const allowed = ['csv', 'xlsx', 'xls', 'ods', 'txt', 'tsv'];
if (!ext || !allowed.includes(ext)) {
setStatus('error');
setErrorMsg(t('glossaries.dialog.errorFormat'));
return;
}
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
setStatus('error');
setErrorMsg(t('glossaries.dialog.errorSize', { max: String(MAX_FILE_SIZE_MB) }));
return;
}
setStatus('parsing');
setErrorMsg('');
try {
const terms = await parseFileToTerms(file);
if (terms.length === 0) {
setStatus('error');
setErrorMsg(t('glossaries.dialog.errorEmpty'));
return;
}
setStatus('success');
setParsedFile({ name: file.name, count: terms.length });
onTermsParsed(terms, file.name);
} catch {
setStatus('error');
setErrorMsg(t('glossaries.dialog.errorRead'));
}
}, [onTermsParsed, t]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) processFile(file);
}, [processFile]);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) processFile(file);
e.target.value = '';
}, [processFile]);
const reset = () => {
setStatus('idle');
setParsedFile(null);
setErrorMsg('');
};
return (
<div className="space-y-3">
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => !disabled && fileInputRef.current?.click()}
className={cn(
'relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors cursor-pointer',
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/30',
disabled && 'opacity-50 cursor-not-allowed',
status === 'success' && 'border-green-400 bg-green-50',
status === 'error' && 'border-destructive/50 bg-destructive/5'
)}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
className="hidden"
onChange={handleFileChange}
disabled={disabled}
/>
{status === 'parsing' && (
<>
<Loader2 className="size-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">{t('glossaries.dialog.parsing')}</p>
</>
)}
{status === 'success' && parsedFile && (
<>
<CheckCircle2 className="size-8 text-green-600" />
<div>
<p className="text-sm font-medium text-green-700">
{t('glossaries.dialog.termsImported', { count: String(parsedFile.count) })}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{parsedFile.name}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); reset(); }}
className="gap-1.5 text-xs"
>
<X className="size-3.5" /> {t('glossaries.dialog.changeFile')}
</Button>
</>
)}
{status === 'error' && (
<>
<AlertCircle className="size-8 text-destructive" />
<div>
<p className="text-sm font-medium text-destructive">{errorMsg}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); reset(); }}
className="gap-1.5 text-xs"
>
<X className="size-3.5" /> {t('glossaries.dialog.retry')}
</Button>
</>
)}
{status === 'idle' && (
<>
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Upload className="size-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">{t('glossaries.dialog.dropTitle')}</p>
<p className="text-xs text-muted-foreground mt-1">{t('glossaries.dialog.dropOr')}</p>
</div>
<p className="text-xs text-muted-foreground">{t('glossaries.dialog.dropFormats')}</p>
</>
)}
</div>
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1">
<p className="font-medium">{t('glossaries.dialog.formatTitle')}</p>
<p>{t('glossaries.dialog.formatDesc')}</p>
<div className="font-mono bg-background rounded border px-2 py-1 mt-1">
<div className="text-muted-foreground">source,target</div>
<div>server,server</div>
<div>database,database</div>
</div>
<p className="mt-1">{t('glossaries.dialog.formatNote')}</p>
</div>
</div>
);
}
export function CreateGlossaryDialog({
open,
onOpenChange,
onCreate,
onImportTemplate,
isCreating,
isImportingTemplate,
}: CreateGlossaryDialogProps) {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<'templates' | 'file' | 'manual'>('templates');
const [name, setName] = useState('');
const [nameAutoFilled, setNameAutoFilled] = useState(false);
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
const [fileTerms, setFileTerms] = useState<GlossaryTermInput[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<GlossaryTemplate | null>(null);
const handleCreate = useCallback(async () => {
if (!name.trim()) return;
const validTerms = terms.filter(t => t.source.trim() && t.target.trim());
await onCreate({
name: name.trim(),
terms: validTerms,
});
const { templates, isLoading: isLoadingTemplates } = useGlossaryTemplates();
const isProcessing = isCreating || isImportingTemplate;
const reset = useCallback(() => {
setName('');
setNameAutoFilled(false);
setTerms([{ source: '', target: '' }]);
}, [name, terms, onCreate]);
setFileTerms([]);
setSelectedTemplate(null);
setActiveTab('templates');
}, []);
const handleOpenChange = useCallback((newOpen: boolean) => {
if (!newOpen) {
setName('');
setTerms([{ source: '', target: '' }]);
}
if (!newOpen) reset();
onOpenChange(newOpen);
}, [onOpenChange]);
}, [onOpenChange, reset]);
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
const handleTemplateSelect = useCallback((template: GlossaryTemplate) => {
setSelectedTemplate(template);
if (!name || nameAutoFilled) {
setName(template.name.split(' - ')[0]);
setNameAutoFilled(true);
}
}, [name, nameAutoFilled]);
const handleFileTermsParsed = useCallback((parsed: GlossaryTermInput[], filename: string) => {
setFileTerms(parsed);
if (!name || nameAutoFilled) {
const baseName = filename.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
setName(baseName);
setNameAutoFilled(true);
}
}, [name, nameAutoFilled]);
const handleSubmit = useCallback(async () => {
if (!name.trim()) return;
if (activeTab === 'templates' && selectedTemplate) {
await onImportTemplate(selectedTemplate.id, name.trim());
reset();
return;
}
const termsToSave = activeTab === 'file'
? fileTerms
: terms.filter(t => t.source.trim() && t.target.trim());
await onCreate({ name: name.trim(), terms: termsToSave });
reset();
}, [activeTab, selectedTemplate, name, fileTerms, terms, onCreate, onImportTemplate, reset]);
const canSubmit = (() => {
if (!name.trim() || isProcessing) return false;
if (activeTab === 'templates') return !!selectedTemplate;
if (activeTab === 'file') return fileTerms.length > 0;
return terms.some(t => t.source.trim() && t.target.trim());
})();
const submitLabel = (() => {
if (isProcessing) {
return activeTab === 'templates'
? t('glossaries.dialog.importing')
: t('glossaries.dialog.creating');
}
if (activeTab === 'templates') {
return selectedTemplate
? t('glossaries.dialog.importBtn', { count: String(selectedTemplate.terms_count) })
: t('glossaries.dialog.selectPrompt');
}
if (activeTab === 'file') {
return fileTerms.length > 0
? t('glossaries.dialog.importBtn', { count: String(fileTerms.length) })
: t('glossaries.dialog.dropTitle');
}
const count = terms.filter(t => t.source.trim() && t.target.trim()).length;
return count > 0
? t('glossaries.dialog.createBtn', { count: String(count) })
: t('glossaries.dialog.createEmpty');
})();
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Glossary</DialogTitle>
<DialogDescription>
Create a glossary with custom terminology for your translations.
</DialogDescription>
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>{t('glossaries.dialog.title')}</DialogTitle>
<DialogDescription>{t('glossaries.dialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-2">
<Label htmlFor="glossary-name">Glossary Name</Label>
<Input
id="glossary-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Technical Terms FR-EN"
disabled={isCreating}
/>
</div>
<div className="shrink-0 space-y-2 pt-2">
<Label htmlFor="glossary-name">{t('glossaries.dialog.nameLabel')}</Label>
<Input
id="glossary-name"
value={name}
onChange={(e) => { setName(e.target.value); setNameAutoFilled(false); }}
placeholder={t('glossaries.dialog.namePlaceholder')}
disabled={isProcessing}
/>
</div>
<div className="space-y-2">
<Label>Terms ({validTermsCount} valid)</Label>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
className="flex flex-col flex-1 min-h-0 mt-4"
>
<TabsList className="shrink-0 w-full grid grid-cols-3">
<TabsTrigger value="templates" className="gap-1.5 text-xs">
<BookOpen className="size-3.5" />
{t('glossaries.dialog.tabTemplates')}
</TabsTrigger>
<TabsTrigger value="file" className="gap-1.5 text-xs">
<FileText className="size-3.5" />
{t('glossaries.dialog.tabFile')}
</TabsTrigger>
<TabsTrigger value="manual" className="gap-1.5 text-xs">
<PenLine className="size-3.5" />
{t('glossaries.dialog.tabManual')}
</TabsTrigger>
</TabsList>
<TabsContent value="templates" className="flex-1 overflow-y-auto mt-4 space-y-3">
{isLoadingTemplates ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<p className="text-xs text-muted-foreground">
{t('glossaries.dialog.templatesDesc')}
</p>
<div className="grid gap-2 sm:grid-cols-2">
{templates.map((template) => (
<div
key={template.id}
className={cn(
'relative',
selectedTemplate?.id === template.id && 'ring-2 ring-primary ring-offset-1 rounded-lg'
)}
>
{selectedTemplate?.id === template.id && (
<CheckCircle2 className="absolute -top-1.5 -right-1.5 size-4 text-primary bg-background rounded-full z-10" />
)}
<TemplateCard
template={template}
onSelect={handleTemplateSelect}
isLoading={isProcessing}
termsLabel={t('glossaries.dialog.terms')}
/>
</div>
))}
</div>
{templates.length === 0 && !isLoadingTemplates && (
<p className="text-sm text-muted-foreground text-center py-8">
{t('glossaries.dialog.templatesEmpty')}
</p>
)}
</>
)}
</TabsContent>
<TabsContent value="file" className="flex-1 overflow-y-auto mt-4">
<FileUploadZone
onTermsParsed={handleFileTermsParsed}
disabled={isProcessing}
/>
</TabsContent>
<TabsContent value="manual" className="flex-1 overflow-y-auto mt-4">
<TermEditor
terms={terms}
onChange={setTerms}
disabled={isCreating}
disabled={isProcessing}
/>
</div>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<DialogFooter className="shrink-0 pt-4 border-t mt-2">
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isCreating}
disabled={isProcessing}
>
Cancel
{t('glossaries.dialog.cancel')}
</Button>
<Button
onClick={handleCreate}
disabled={isCreating || !name.trim()}
>
{isCreating ? 'Creating...' : 'Create Glossary'}
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isProcessing && <Loader2 className="size-3.5 animate-spin mr-1.5" />}
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -6,6 +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';
interface GlossaryCardProps {
glossary: GlossaryListItem;
@@ -20,6 +21,7 @@ export const GlossaryCard = memo(function GlossaryCard({
onDelete,
isDeleting = false,
}: GlossaryCardProps) {
const { t, locale } = useI18n();
const handleEdit = useCallback(() => {
onEdit(glossary.id);
}, [glossary.id, onEdit]);
@@ -28,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('en-US', {
const formattedDate = new Date(glossary.created_at).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
@@ -46,10 +48,10 @@ export const GlossaryCard = memo(function GlossaryCard({
<h3 className="font-medium text-foreground truncate">{glossary.name}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{glossary.terms_count} {glossary.terms_count === 1 ? 'term' : 'terms'}
{glossary.terms_count} {glossary.terms_count === 1 ? t('glossaries.card.term') : t('glossaries.card.terms')}
</Badge>
<span className="text-xs text-muted-foreground">
Created {formattedDate}
{t('glossaries.card.created')} {formattedDate}
</span>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { ArrowRight, Plus, Trash2 } from 'lucide-react';
import type { GlossaryTermInput, GlossaryTermInputWithId } from './types';
import { MAX_TERMS_PER_GLOSSARY, generateTermId } from './types';
import { useI18n } from '@/lib/i18n';
interface TermEditorProps {
terms: GlossaryTermInput[];
@@ -25,6 +26,7 @@ export const TermEditor = memo(function TermEditor({
onChange,
disabled = false,
}: TermEditorProps) {
const { t } = useI18n();
// Generate stable keys for current terms
const termKeys = useMemo(() => {
return terms.map((term, index) => getTermKey(term, index));
@@ -51,11 +53,11 @@ export const TermEditor = memo(function TermEditor({
<div className="space-y-3">
<div className="mb-2 grid grid-cols-[1fr_32px_1fr_36px] items-center gap-2 px-1">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Source Term
{t('glossaries.termEditor.sourceTerm')}
</span>
<span />
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Target Translation
{t('glossaries.termEditor.targetTranslation')}
</span>
<span />
</div>
@@ -107,12 +109,12 @@ export const TermEditor = memo(function TermEditor({
className="mt-3 gap-1.5 border-dashed"
>
<Plus className="size-3.5" />
Add Term
{t('glossaries.termEditor.addTerm')}
</Button>
{maxTermsReached && (
<p className="text-xs text-amber-600">
Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary reached.
{t('glossaries.termEditor.maxReached', { max: String(MAX_TERMS_PER_GLOSSARY) })}
</p>
)}
</div>

View File

@@ -50,6 +50,47 @@ export function parseCsvToTerms(csvText: string): GlossaryTermInput[] {
return terms;
}
export async function parseXlsxToTerms(file: File): Promise<GlossaryTermInput[]> {
const { read, utils } = await import('xlsx');
const buffer = await file.arrayBuffer();
const workbook = read(buffer, { type: 'array' });
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
if (!firstSheet) return [];
const rows: string[][] = utils.sheet_to_json(firstSheet, { header: 1, defval: '' });
if (rows.length === 0) return [];
// Detect header row
const firstRow = rows[0].map(c => String(c).toLowerCase().trim());
const hasHeader =
firstRow.some(c => c.includes('source') || c === 'src') &&
firstRow.some(c => c.includes('target') || c === 'tgt' || c.includes('traduction') || c.includes('cible'));
const dataRows = hasHeader ? rows.slice(1) : rows;
const terms: GlossaryTermInput[] = [];
for (const row of dataRows) {
const source = String(row[0] ?? '').trim();
const target = String(row[1] ?? '').trim();
if (source && target) {
terms.push({ source, target });
}
}
return terms;
}
export async function parseFileToTerms(file: File): Promise<GlossaryTermInput[]> {
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext === 'xlsx' || ext === 'xls' || ext === 'ods') {
return parseXlsxToTerms(file);
}
// CSV / TSV / TXT
const text = await file.text();
return parseCsvToTerms(text);
}
function parseCsvLine(line: string): string[] {
const result: string[] = [];
let current = '';

View File

@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useUser } from '@/app/dashboard/useUser';
import { useI18n } from '@/lib/i18n';
import { useGlossaries, useGlossary } from './useGlossaries';
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
import { ProUpgradePrompt } from './ProUpgradePrompt';
@@ -16,6 +17,7 @@ import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
import { useToast } from '@/components/ui/toast';
export default function GlossariesPage() {
const { t } = useI18n();
const { data: user, isLoading: isLoadingUser } = useUser();
const {
glossaries,
@@ -24,9 +26,11 @@ export default function GlossariesPage() {
isCreating,
isUpdating,
isDeleting,
isImportingTemplate,
createGlossary,
updateGlossary,
deleteGlossary,
importTemplate,
} = useGlossaries();
const { toast } = useToast();
@@ -62,14 +66,34 @@ export default function GlossariesPage() {
await createGlossary(data);
setCreateDialogOpen(false);
toast({
title: 'Glossary created',
description: `"${data.name}" has been created successfully.`,
title: t('glossaries.toast.created'),
description: t('glossaries.toast.createdDesc', { name: data.name }),
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to create glossary. Please try again.',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorCreate'),
});
throw error;
}
};
const handleImportTemplate = async (templateId: string, name?: string) => {
try {
await importTemplate(templateId, name);
setCreateDialogOpen(false);
toast({
title: t('glossaries.toast.imported'),
description: name
? t('glossaries.toast.importedDesc', { name })
: t('glossaries.toast.importedDesc', { name: templateId }),
});
} catch (error) {
toast({
variant: 'destructive',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorImport'),
});
throw error;
}
@@ -81,14 +105,14 @@ export default function GlossariesPage() {
setEditDialogOpen(false);
setSelectedGlossary(null);
toast({
title: 'Glossary updated',
description: `"${data.name}" has been updated successfully.`,
title: t('glossaries.toast.updated'),
description: t('glossaries.toast.updatedDesc', { name: data.name }),
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to update glossary. Please try again.',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorUpdate'),
});
throw error;
}
@@ -101,14 +125,14 @@ export default function GlossariesPage() {
setDeleteDialogOpen(false);
setGlossaryToDelete(null);
toast({
title: 'Glossary deleted',
description: 'The glossary has been deleted successfully.',
title: t('glossaries.toast.deleted'),
description: t('glossaries.toast.deletedDesc'),
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to delete glossary. Please try again.',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorDelete'),
});
}
};
@@ -131,10 +155,8 @@ export default function GlossariesPage() {
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Glossaries</h1>
<p className="text-muted-foreground">
Manage custom terminology for your LLM translations.
</p>
<h1 className="text-2xl font-semibold tracking-tight">{t('glossaries.title')}</h1>
<p className="text-muted-foreground">{t('glossaries.description')}</p>
</div>
<Card>
@@ -144,8 +166,8 @@ export default function GlossariesPage() {
<BookText className="size-4 text-accent" />
</div>
<div>
<CardTitle className="text-base">Your Glossaries</CardTitle>
<CardDescription>Create and manage glossaries for consistent translations</CardDescription>
<CardTitle className="text-base">{t('glossaries.yourGlossaries')}</CardTitle>
<CardDescription>{t('glossaries.yourGlossariesDesc')}</CardDescription>
</div>
</div>
</CardHeader>
@@ -154,11 +176,9 @@ export default function GlossariesPage() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">
{total} glossarie{total !== 1 ? 's' : ''}
</p>
<p className="text-xs text-muted-foreground">
Define term pairs to customize your LLM translations
{t(total !== 1 ? 'glossaries.count_other' : 'glossaries.count_one', { count: String(total) })}
</p>
<p className="text-xs text-muted-foreground">{t('glossaries.defineTerms')}</p>
</div>
<Button
onClick={() => setCreateDialogOpen(true)}
@@ -166,17 +186,15 @@ export default function GlossariesPage() {
className="gap-1.5"
>
<Plus className="size-3.5" />
Create New Glossary
{t('glossaries.createNew')}
</Button>
</div>
{glossaries.length === 0 ? (
<div className="text-center py-8">
<BookText className="size-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No glossaries yet</p>
<p className="text-sm text-muted-foreground/80">
Create your first glossary to customize translations
</p>
<p className="text-muted-foreground">{t('glossaries.empty')}</p>
<p className="text-sm text-muted-foreground/80">{t('glossaries.emptyDesc')}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -198,15 +216,11 @@ export default function GlossariesPage() {
<Card className="border-border/50">
<CardHeader>
<CardTitle className="text-sm">About Glossaries</CardTitle>
<CardTitle className="text-sm">{t('glossaries.aboutTitle')}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>
Glossaries let you define custom terminology for your translations. When using LLM translation modes, your terms will be applied to ensure consistent translations.
</p>
<p>
<strong>Format:</strong> Each term has a source (original) and target (translation) pair.
</p>
<p>{t('glossaries.aboutDesc')}</p>
<p><strong>{t('glossaries.aboutFormat')}</strong></p>
</CardContent>
</Card>
@@ -214,7 +228,9 @@ export default function GlossariesPage() {
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onCreate={handleCreateGlossary}
onImportTemplate={handleImportTemplate}
isCreating={isCreating}
isImportingTemplate={isImportingTemplate}
/>
{editDialogOpen && (fullGlossary || !isLoadingGlossaryDetail) && (

View File

@@ -22,6 +22,21 @@ export type GlossaryErrorCode =
| 'INVALID_GLOSSARY_ID'
| 'UNAUTHORIZED';
export interface GlossaryTemplate {
id: string;
name: string;
description: string;
source_lang: string;
target_lang: string;
terms_count: number;
file: string;
}
export interface GlossaryTemplatesResponse {
data: GlossaryTemplate[];
meta: { total: number };
}
export interface GlossaryError {
status: number;
code: GlossaryErrorCode;
@@ -103,6 +118,24 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
return deleteMutation.mutateAsync(id);
};
const importTemplateMutation = useMutation({
mutationFn: async ({ templateId, name }: { templateId: string; name?: string }): Promise<Glossary> => {
const params = new URLSearchParams({ template_id: templateId });
if (name) params.set('name', name);
const response = await apiClient.post<GlossaryDetailResponse>(
`/api/v1/glossaries/import?${params.toString()}`
);
return response.data.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
},
});
const importTemplate = async (templateId: string, name?: string) => {
return importTemplateMutation.mutateAsync({ templateId, name });
};
const parseError = (error: Error | null): GlossaryError | null => {
if (!error) return null;
@@ -145,9 +178,30 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
isImportingTemplate: importTemplateMutation.isPending,
importTemplateError: importTemplateMutation.error,
parseCreateError: () => parseError(createMutation.error),
parseUpdateError: () => parseError(updateMutation.error),
parseDeleteError: () => parseError(deleteMutation.error),
importTemplate,
};
}
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;
},
staleTime: 5 * 60 * 1000, // templates rarely change
retry: 1,
});
return {
templates: data?.data ?? [],
isLoading,
error,
};
}