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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user