feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
108
frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx
Normal file
108
frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { TermEditor } from './TermEditor';
|
||||
import type { GlossaryTermInput } from './types';
|
||||
|
||||
interface CreateGlossaryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreate: (data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
export function CreateGlossaryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
isCreating,
|
||||
}: CreateGlossaryDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
setName('');
|
||||
setTerms([{ source: '', target: '' }]);
|
||||
}, [name, terms, onCreate]);
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setName('');
|
||||
setTerms([{ source: '', target: '' }]);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
|
||||
|
||||
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>
|
||||
</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="space-y-2">
|
||||
<Label>Terms ({validTermsCount} valid)</Label>
|
||||
<TermEditor
|
||||
terms={terms}
|
||||
onChange={setTerms}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating || !name.trim()}
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Glossary'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface DeleteGlossaryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
isDeleting: boolean;
|
||||
glossaryName?: string;
|
||||
}
|
||||
|
||||
export function DeleteGlossaryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isDeleting,
|
||||
glossaryName,
|
||||
}: DeleteGlossaryDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Glossary</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this glossary?
|
||||
{glossaryName && (
|
||||
<span className="block mt-1 font-medium text-foreground">
|
||||
"{glossaryName}"
|
||||
</span>
|
||||
)}
|
||||
</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" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">This action cannot be undone</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All term pairs will be permanently removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
216
frontend/src/app/dashboard/glossaries/EditGlossaryDialog.tsx
Normal file
216
frontend/src/app/dashboard/glossaries/EditGlossaryDialog.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Download, Upload } from 'lucide-react';
|
||||
import { TermEditor } from './TermEditor';
|
||||
import { exportGlossaryToCsv, parseCsvToTerms } from './csvUtils';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import type { Glossary, GlossaryTermInput } from './types';
|
||||
import { MAX_TERMS_PER_GLOSSARY } from './types';
|
||||
|
||||
interface EditGlossaryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
glossary: Glossary | null;
|
||||
onSave: (id: string, data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function EditGlossaryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
glossary,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: EditGlossaryDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [terms, setTerms] = useState<GlossaryTermInput[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
if (glossary && !isInitialized.current) {
|
||||
setName(glossary.name);
|
||||
setTerms(glossary.terms.map(t => ({ source: t.source, target: t.target })));
|
||||
isInitialized.current = true;
|
||||
}
|
||||
|
||||
if (!open && isInitialized.current) {
|
||||
isInitialized.current = false;
|
||||
}
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!glossary || !name.trim()) return;
|
||||
|
||||
const validTerms = terms.filter(t => t.source.trim() && t.target.trim());
|
||||
|
||||
await onSave(glossary.id, {
|
||||
name: name.trim(),
|
||||
terms: validTerms,
|
||||
});
|
||||
}, [glossary, name, terms, onSave]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (!glossary) return;
|
||||
|
||||
const glossaryWithCurrentTerms: Glossary = {
|
||||
...glossary,
|
||||
name,
|
||||
terms: terms.map((t, i) => ({
|
||||
id: `temp-${i}`,
|
||||
source: t.source,
|
||||
target: t.target,
|
||||
created_at: null,
|
||||
})),
|
||||
};
|
||||
|
||||
exportGlossaryToCsv(glossaryWithCurrentTerms);
|
||||
}, [glossary, name, terms]);
|
||||
|
||||
const handleImportClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result;
|
||||
if (typeof text === 'string') {
|
||||
const importedTerms = parseCsvToTerms(text);
|
||||
if (importedTerms.length > 0) {
|
||||
if (importedTerms.length > MAX_TERMS_PER_GLOSSARY) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Import failed',
|
||||
description: `CSV contains ${importedTerms.length} terms, but maximum is ${MAX_TERMS_PER_GLOSSARY}. Please reduce the number of terms.`,
|
||||
});
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setTerms(importedTerms);
|
||||
toast({
|
||||
title: 'Import successful',
|
||||
description: `${importedTerms.length} terms imported successfully.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Import failed',
|
||||
description: 'No valid terms found in CSV file.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Import failed',
|
||||
description: 'Failed to read CSV file.',
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
e.target.value = '';
|
||||
}, [toast]);
|
||||
|
||||
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Glossary</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the glossary name and term pairs.
|
||||
</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="Enter glossary name..."
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Terms ({validTermsCount} valid)</Label>
|
||||
<TermEditor
|
||||
terms={terms}
|
||||
onChange={setTerms}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isSaving || validTermsCount === 0}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
Import CSV
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !name.trim()}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
83
frontend/src/app/dashboard/glossaries/GlossaryCard.tsx
Normal file
83
frontend/src/app/dashboard/glossaries/GlossaryCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BookText, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { GlossaryListItem } from './types';
|
||||
|
||||
interface GlossaryCardProps {
|
||||
glossary: GlossaryListItem;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string, name: string) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const GlossaryCard = memo(function GlossaryCard({
|
||||
glossary,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}: GlossaryCardProps) {
|
||||
const handleEdit = useCallback(() => {
|
||||
onEdit(glossary.id);
|
||||
}, [glossary.id, onEdit]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(glossary.id, glossary.name);
|
||||
}, [glossary.id, glossary.name, onDelete]);
|
||||
|
||||
const formattedDate = new Date(glossary.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="group hover:border-border/80 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent/10">
|
||||
<BookText className="size-5 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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'}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created {formattedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleEdit}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Edit ${glossary.name}`}
|
||||
>
|
||||
<Pencil className="size-3.5 text-muted-foreground hover:text-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Delete ${glossary.name}`}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
56
frontend/src/app/dashboard/glossaries/ProUpgradePrompt.tsx
Normal file
56
frontend/src/app/dashboard/glossaries/ProUpgradePrompt.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { BookText, 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';
|
||||
|
||||
export function ProUpgradePrompt() {
|
||||
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">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<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">
|
||||
<BookText className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-semibold">Glossaries</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Customize your translations with custom terminology
|
||||
</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>Create multiple glossaries</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>Define source→target term pairs</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>Import/export via CSV</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>Apply to LLM translations</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Glossaries are a <span className="text-accent font-medium">Pro</span> feature.
|
||||
Upgrade to unlock custom terminology.
|
||||
</p>
|
||||
<Button asChild className="w-full bg-accent hover:bg-accent/90">
|
||||
<Link href="/pricing">
|
||||
Upgrade to Pro
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/app/dashboard/glossaries/TermEditor.tsx
Normal file
120
frontend/src/app/dashboard/glossaries/TermEditor.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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';
|
||||
|
||||
interface TermEditorProps {
|
||||
terms: GlossaryTermInput[];
|
||||
onChange: (terms: GlossaryTermInput[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Generate stable IDs for terms based on index and content hash
|
||||
function getTermKey(term: GlossaryTermInput, index: number): string {
|
||||
// Create a stable key from content to help React reconciliation
|
||||
const contentHash = `${term.source}-${term.target}`.slice(0, 50);
|
||||
return `term-${index}-${contentHash}`;
|
||||
}
|
||||
|
||||
export const TermEditor = memo(function TermEditor({
|
||||
terms,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: TermEditorProps) {
|
||||
// Generate stable keys for current terms
|
||||
const termKeys = useMemo(() => {
|
||||
return terms.map((term, index) => getTermKey(term, index));
|
||||
}, [terms]);
|
||||
|
||||
const addTerm = useCallback(() => {
|
||||
if (terms.length >= MAX_TERMS_PER_GLOSSARY) return;
|
||||
onChange([...terms, { source: '', target: '' }]);
|
||||
}, [terms, onChange]);
|
||||
|
||||
const removeTerm = useCallback((index: number) => {
|
||||
onChange(terms.filter((_, i) => i !== index));
|
||||
}, [terms, onChange]);
|
||||
|
||||
const updateTerm = useCallback((index: number, field: 'source' | 'target', value: string) => {
|
||||
const newTerms = [...terms];
|
||||
newTerms[index] = { ...newTerms[index], [field]: value };
|
||||
onChange(newTerms);
|
||||
}, [terms, onChange]);
|
||||
|
||||
const maxTermsReached = terms.length >= MAX_TERMS_PER_GLOSSARY;
|
||||
|
||||
return (
|
||||
<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
|
||||
</span>
|
||||
<span />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Target Translation
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{terms.map((term, index) => (
|
||||
<div
|
||||
key={termKeys[index]}
|
||||
className="group grid grid-cols-[1fr_32px_1fr_36px] items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
value={term.source}
|
||||
onChange={(e) => updateTerm(index, 'source', e.target.value)}
|
||||
placeholder="Source term..."
|
||||
className="font-mono text-xs"
|
||||
aria-label={`Source term ${index + 1}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
value={term.target}
|
||||
onChange={(e) => updateTerm(index, 'target', e.target.value)}
|
||||
placeholder="Translation..."
|
||||
className="font-mono text-xs"
|
||||
aria-label={`Target translation ${index + 1}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => removeTerm(index)}
|
||||
disabled={disabled}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Remove term ${index + 1}`}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addTerm}
|
||||
disabled={disabled || maxTermsReached}
|
||||
className="mt-3 gap-1.5 border-dashed"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add Term
|
||||
</Button>
|
||||
|
||||
{maxTermsReached && (
|
||||
<p className="text-xs text-amber-600">
|
||||
Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary reached.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
85
frontend/src/app/dashboard/glossaries/csvUtils.ts
Normal file
85
frontend/src/app/dashboard/glossaries/csvUtils.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Glossary, GlossaryTermInput } from './types';
|
||||
|
||||
export function exportGlossaryToCsv(glossary: Glossary): void {
|
||||
const csvContent = generateCsvContent(glossary.terms.map(t => ({ source: t.source, target: t.target })));
|
||||
downloadCsv(csvContent, `${glossary.name.replace(/[^a-z0-9]/gi, '_')}.csv`);
|
||||
}
|
||||
|
||||
export function generateCsvContent(terms: GlossaryTermInput[]): string {
|
||||
const header = 'source,target';
|
||||
const rows = terms
|
||||
.filter(t => t.source.trim() && t.target.trim())
|
||||
.map(t => `${escapeCsvField(t.source)},${escapeCsvField(t.target)}`);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
export function downloadCsv(content: string, filename: string): void {
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function parseCsvToTerms(csvText: string): GlossaryTermInput[] {
|
||||
const lines = csvText.split(/\r?\n/).filter(line => line.trim());
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const firstLine = lines[0].toLowerCase();
|
||||
const hasHeader = firstLine.includes('source') && firstLine.includes('target');
|
||||
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
|
||||
const terms: GlossaryTermInput[] = [];
|
||||
|
||||
for (const line of dataLines) {
|
||||
const parsed = parseCsvLine(line);
|
||||
if (parsed.length >= 2) {
|
||||
const source = parsed[0].trim();
|
||||
const target = parsed[1].trim();
|
||||
if (source && target) {
|
||||
terms.push({ source, target });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return terms;
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current);
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeCsvField(field: string): string {
|
||||
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
|
||||
return `"${field.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return field;
|
||||
}
|
||||
242
frontend/src/app/dashboard/glossaries/page.tsx
Normal file
242
frontend/src/app/dashboard/glossaries/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { BookText, Plus } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useUser } from '@/app/dashboard/useUser';
|
||||
import { useGlossaries, useGlossary } from './useGlossaries';
|
||||
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
|
||||
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
||||
import { GlossaryCard } from './GlossaryCard';
|
||||
import { CreateGlossaryDialog } from './CreateGlossaryDialog';
|
||||
import { EditGlossaryDialog } from './EditGlossaryDialog';
|
||||
import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
export default function GlossariesPage() {
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const {
|
||||
glossaries,
|
||||
total,
|
||||
isLoading: isLoadingGlossaries,
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
createGlossary,
|
||||
updateGlossary,
|
||||
deleteGlossary,
|
||||
} = useGlossaries();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedGlossary, setSelectedGlossary] = useState<GlossaryListItem | null>(null);
|
||||
const [glossaryToEdit, setGlossaryToEdit] = useState<Glossary | null>(null);
|
||||
const [glossaryToDelete, setGlossaryToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const { glossary: fullGlossary, isLoading: isLoadingGlossaryDetail } = useGlossary(
|
||||
selectedGlossary?.id || null
|
||||
);
|
||||
|
||||
const isPro = user?.tier === 'pro';
|
||||
const isLoading = isLoadingUser || isLoadingGlossaries;
|
||||
|
||||
const handleEditClick = (id: string) => {
|
||||
const glossary = glossaries.find((g: GlossaryListItem) => g.id === id);
|
||||
if (glossary) {
|
||||
setSelectedGlossary(glossary);
|
||||
setEditDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: string, name: string) => {
|
||||
setGlossaryToDelete({ id, name });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateGlossary = async (data: { name: string; terms: GlossaryTermInput[] }) => {
|
||||
try {
|
||||
await createGlossary(data);
|
||||
setCreateDialogOpen(false);
|
||||
toast({
|
||||
title: 'Glossary created',
|
||||
description: `"${data.name}" has been created successfully.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to create glossary. Please try again.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveGlossary = async (id: string, data: { name: string; terms: GlossaryTermInput[] }) => {
|
||||
try {
|
||||
await updateGlossary(id, data);
|
||||
setEditDialogOpen(false);
|
||||
setSelectedGlossary(null);
|
||||
toast({
|
||||
title: 'Glossary updated',
|
||||
description: `"${data.name}" has been updated successfully.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to update glossary. Please try again.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!glossaryToDelete) return;
|
||||
try {
|
||||
await deleteGlossary(glossaryToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setGlossaryToDelete(null);
|
||||
toast({
|
||||
title: 'Glossary deleted',
|
||||
description: 'The glossary has been deleted successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to delete glossary. Please try again.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPro) {
|
||||
return <ProUpgradePrompt />;
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-accent/10">
|
||||
<BookText className="size-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Your Glossaries</CardTitle>
|
||||
<CardDescription>Create and manage glossaries for consistent translations</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
disabled={isCreating}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Create New Glossary
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{glossaries.map((glossary: GlossaryListItem) => (
|
||||
<GlossaryCard
|
||||
key={glossary.id}
|
||||
glossary={glossary}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteClick}
|
||||
isDeleting={isDeleting && glossaryToDelete?.id === glossary.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">About Glossaries</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateGlossaryDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onCreate={handleCreateGlossary}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
|
||||
{editDialogOpen && (fullGlossary || !isLoadingGlossaryDetail) && (
|
||||
<EditGlossaryDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setEditDialogOpen(open);
|
||||
if (!open) setSelectedGlossary(null);
|
||||
}}
|
||||
glossary={fullGlossary}
|
||||
onSave={handleSaveGlossary}
|
||||
isSaving={isUpdating}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteGlossaryDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isDeleting={isDeleting}
|
||||
glossaryName={glossaryToDelete?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
frontend/src/app/dashboard/glossaries/types.ts
Normal file
73
frontend/src/app/dashboard/glossaries/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface GlossaryTerm {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface Glossary {
|
||||
id: string;
|
||||
name: string;
|
||||
terms: GlossaryTerm[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GlossaryListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
terms_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GlossaryListResponse {
|
||||
data: GlossaryListItem[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GlossaryDetailResponse {
|
||||
data: Glossary;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GlossaryCreateResponse {
|
||||
data: Glossary;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GlossaryUpdateResponse {
|
||||
data: Glossary;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GlossaryTermInput {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface GlossaryTermInputWithId extends GlossaryTermInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GlossaryCreateInput {
|
||||
name: string;
|
||||
terms?: GlossaryTermInput[];
|
||||
}
|
||||
|
||||
export interface GlossaryUpdateInput {
|
||||
name?: string;
|
||||
terms?: GlossaryTermInput[];
|
||||
}
|
||||
|
||||
export const MAX_TERMS_PER_GLOSSARY = 500;
|
||||
|
||||
// Generate unique IDs for React keys
|
||||
let idCounter = 0;
|
||||
export function generateTermId(): string {
|
||||
return `term-${Date.now()}-${++idCounter}`;
|
||||
}
|
||||
180
frontend/src/app/dashboard/glossaries/useGlossaries.ts
Normal file
180
frontend/src/app/dashboard/glossaries/useGlossaries.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { apiClient, ApiClientError } from '@/lib/apiClient';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
import type {
|
||||
GlossaryListItem,
|
||||
Glossary,
|
||||
GlossaryListResponse,
|
||||
GlossaryDetailResponse,
|
||||
GlossaryCreateInput,
|
||||
GlossaryUpdateInput,
|
||||
} from './types';
|
||||
|
||||
const GLOSSARIES_QUERY_KEY = ['glossaries'];
|
||||
|
||||
export type GlossaryErrorCode =
|
||||
| 'PRO_FEATURE_REQUIRED'
|
||||
| 'TERMS_LIMIT_EXCEEDED'
|
||||
| 'GLOSSARY_NOT_FOUND'
|
||||
| 'INVALID_GLOSSARY_ID'
|
||||
| 'UNAUTHORIZED';
|
||||
|
||||
export interface GlossaryError {
|
||||
status: number;
|
||||
code: GlossaryErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface UseGlossariesOptions {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
const { page = 1, perPage = 50 } = options;
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
data: glossariesData,
|
||||
isLoading,
|
||||
error,
|
||||
} = 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;
|
||||
},
|
||||
retry: (failureCount, err) => {
|
||||
if (err.status === 403 || err.status === 401) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
// Handle 401 redirect
|
||||
if (error?.status === 401) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
|
||||
const glossaries = glossariesData?.data ?? [];
|
||||
const total = glossariesData?.meta?.total ?? 0;
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (input: GlossaryCreateInput): Promise<Glossary> => {
|
||||
const response = await apiClient.post<GlossaryDetailResponse>('/api/v1/glossaries', input);
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/glossaries/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const createGlossary = async (input: GlossaryCreateInput) => {
|
||||
return createMutation.mutateAsync(input);
|
||||
};
|
||||
|
||||
const updateGlossary = async (id: string, data: GlossaryUpdateInput) => {
|
||||
return updateMutation.mutateAsync({ id, data });
|
||||
};
|
||||
|
||||
const deleteGlossary = async (id: string) => {
|
||||
return deleteMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const parseError = (error: Error | null): GlossaryError | null => {
|
||||
if (!error) return null;
|
||||
|
||||
const apiError = error as ApiClientError;
|
||||
const status = apiError.status || 500;
|
||||
const code = apiError.code as GlossaryErrorCode | string;
|
||||
const message = apiError.message;
|
||||
|
||||
if (status === 401) {
|
||||
return { status: 401, code: 'UNAUTHORIZED', message: message || 'Session expired' };
|
||||
}
|
||||
if (status === 403 && code === 'PRO_FEATURE_REQUIRED') {
|
||||
return { status: 403, code: 'PRO_FEATURE_REQUIRED', message: message || 'Pro feature required' };
|
||||
}
|
||||
if (status === 400 && code === 'TERMS_LIMIT_EXCEEDED') {
|
||||
return { status: 400, code: 'TERMS_LIMIT_EXCEEDED', message: message || 'Maximum 500 terms per glossary' };
|
||||
}
|
||||
if (status === 404 && code === 'GLOSSARY_NOT_FOUND') {
|
||||
return { status: 404, code: 'GLOSSARY_NOT_FOUND', message: message || 'Glossary not found' };
|
||||
}
|
||||
if (status === 400 && code === 'INVALID_GLOSSARY_ID') {
|
||||
return { status: 400, code: 'INVALID_GLOSSARY_ID', message: message || 'Invalid glossary ID' };
|
||||
}
|
||||
|
||||
return { status, code: code as GlossaryErrorCode, message };
|
||||
};
|
||||
|
||||
return {
|
||||
glossaries,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
errorDetails: parseError(error),
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
createGlossary,
|
||||
updateGlossary,
|
||||
deleteGlossary,
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
parseCreateError: () => parseError(createMutation.error),
|
||||
parseUpdateError: () => parseError(updateMutation.error),
|
||||
parseDeleteError: () => parseError(deleteMutation.error),
|
||||
};
|
||||
}
|
||||
|
||||
export function useGlossary(id: string | null) {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<GlossaryDetailResponse, ApiClientError>({
|
||||
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;
|
||||
},
|
||||
enabled: !!id,
|
||||
retry: (failureCount, err) => {
|
||||
if (err.status === 403 || err.status === 404) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
const glossary = data?.data ?? null;
|
||||
|
||||
return {
|
||||
glossary,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user