From 1fe714aa1a5c9429004d31804648890dee23f43c Mon Sep 17 00:00:00 2001 From: sepehr Date: Sat, 20 Jun 2026 12:27:07 +0200 Subject: [PATCH] ux(glossaries): simplify dialog, auto-save detail import, show templates and upload zone directly on main page --- .../glossaries/CreateGlossaryDialog.tsx | 452 ++---------------- .../app/dashboard/glossaries/[id]/page.tsx | 14 +- .../src/app/dashboard/glossaries/page.tsx | 280 ++++++++++- 3 files changed, 314 insertions(+), 432 deletions(-) diff --git a/frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx b/frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx index 86bb856..d6452ed 100644 --- a/frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx +++ b/frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback } from 'react'; import { Dialog, DialogContent, @@ -11,297 +11,35 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; 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'; - +import { Loader2, PenLine } from 'lucide-react'; import { SUPPORTED_LANGUAGES } from './types'; interface CreateGlossaryDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onCreate: (data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => Promise; - onImportTemplate: (templateId: string, name?: string) => Promise; isCreating: boolean; - isImportingTemplate: boolean; -} - -const TEMPLATE_ICONS: Record = { - legal: , - technology: , - finance: , - medical: , - marketing: , - hr: , - scientific: , - ecommerce: , -}; - -type FileStatus = 'idle' | 'parsing' | 'success' | 'error'; - -const MAX_FILE_SIZE_MB = 5; - -function TemplateCard({ - template, - onSelect, - isLoading, - isSelected, - termsLabel, -}: { - template: GlossaryTemplate; - onSelect: (t: GlossaryTemplate) => void; - isLoading: boolean; - isSelected: boolean; - termsLabel: string; -}) { - const icon = TEMPLATE_ICONS[template.id] ?? ; - - return ( - - ); -} - -function FileUploadZone({ - onTermsParsed, - disabled, -}: { - onTermsParsed: (terms: GlossaryTermInput[], filename: string) => void; - disabled: boolean; -}) { - const { t } = useI18n(); - const fileInputRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [status, setStatus] = useState('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) => { - const file = e.target.files?.[0]; - if (file) processFile(file); - e.target.value = ''; - }, [processFile]); - - const reset = () => { - setStatus('idle'); - setParsedFile(null); - setErrorMsg(''); - }; - - return ( -
-
{ 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-2xl border-2 border-dashed p-8 text-center transition-all cursor-pointer', - isDragging ? 'border-brand-accent bg-brand-accent/5' : 'border-brand-dark/10 dark:border-white/10 hover:border-brand-accent/50 hover:bg-brand-muted/30 dark:hover:bg-white/[0.02]', - disabled && 'opacity-50 cursor-not-allowed', - status === 'success' && 'border-emerald-400/50 bg-emerald-50 dark:bg-emerald-900/10', - status === 'error' && 'border-destructive/30 bg-destructive/5' - )} - > - - - {status === 'parsing' && ( - <> - -

{t('glossaries.dialog.parsing')}

- - )} - - {status === 'success' && parsedFile && ( - <> - -
-

- {t('glossaries.dialog.termsImported', { count: String(parsedFile.count) })} -

-

{parsedFile.name}

-
- - - )} - - {status === 'error' && ( - <> - -

{errorMsg}

- - - )} - - {status === 'idle' && ( - <> -
- -
-
-

{t('glossaries.dialog.dropTitle')}

-

{t('glossaries.dialog.dropOr')}

-
-

{t('glossaries.dialog.dropFormats')}

- - )} -
- -
-

{t('glossaries.dialog.formatTitle')}

-

{t('glossaries.dialog.formatDesc')}

-
-
source,target
-
server,server
-
database,database
-
-

{t('glossaries.dialog.formatNote')}

-
-
- ); } 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 [sourceLanguage, setSourceLanguage] = useState('fr'); const [targetLanguage, setTargetLanguage] = useState('multi'); const [terms, setTerms] = useState([{ source: '', target: '' }]); - const [fileTerms, setFileTerms] = useState([]); - const [selectedTemplate, setSelectedTemplate] = useState(null); - - const { templates, isLoading: isLoadingTemplates } = useGlossaryTemplates(); - - const isProcessing = isCreating || isImportingTemplate; const reset = useCallback(() => { setName(''); - setNameAutoFilled(false); setSourceLanguage('fr'); setTargetLanguage('multi'); setTerms([{ source: '', target: '' }]); - setFileTerms([]); - setSelectedTemplate(null); - setActiveTab('templates'); }, []); const handleOpenChange = useCallback((newOpen: boolean) => { @@ -309,100 +47,45 @@ export function CreateGlossaryDialog({ onOpenChange(newOpen); }, [onOpenChange, reset]); - 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()); + const termsToSave = terms.filter(t => t.source.trim() && t.target.trim()); await onCreate({ name: name.trim(), source_language: sourceLanguage, target_language: targetLanguage, terms: termsToSave }); reset(); - }, [activeTab, selectedTemplate, name, fileTerms, terms, sourceLanguage, targetLanguage, onCreate, onImportTemplate, reset]); + }, [name, terms, sourceLanguage, targetLanguage, onCreate, 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 canSubmit = !!(name.trim() && !isCreating && 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'); - })(); - - const tabs = [ - { id: 'templates' as const, label: t('glossaries.dialog.tabTemplates'), icon: }, - { id: 'file' as const, label: t('glossaries.dialog.tabFile'), icon: }, - { id: 'manual' as const, label: t('glossaries.dialog.tabManual'), icon: }, - ]; + const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length; + const submitLabel = isCreating + ? t('glossaries.dialog.creating') || 'Création…' + : validTermsCount > 0 + ? t('glossaries.dialog.createBtn', { count: String(validTermsCount) }) || `Créer (${validTermsCount})` + : t('glossaries.dialog.createEmpty') || 'Créer vide'; return ( - - {/* ── Header ────────────────────────────────────── */} - {t('glossaries.dialog.title')} + {t('glossaries.dialog.title') || 'Nouveau glossaire'} - {t('glossaries.dialog.description')} + {t('glossaries.dialog.description') || 'Créez un glossaire pour vos traductions'} - {/* ── Name + Languages ──────────────────────────── */}
{ setName(e.target.value); setNameAutoFilled(false); }} - placeholder={t('glossaries.dialog.namePlaceholder')} - disabled={isProcessing} + onChange={(e) => setName(e.target.value)} + placeholder={t('glossaries.dialog.namePlaceholder') || 'Mon glossaire'} + disabled={isCreating} className="mt-1.5 bg-brand-muted/30 dark:bg-white/[0.03] border-black/5 dark:border-white/10 rounded-xl focus:ring-brand-accent/20 focus:border-brand-accent/30" />
@@ -410,13 +93,13 @@ export function CreateGlossaryDialog({
setTargetLanguage(e.target.value)} - disabled={isProcessing} + disabled={isCreating} className="w-full h-10 rounded-xl border border-black/5 dark:border-white/10 bg-brand-muted/30 dark:bg-white/[0.03] px-3 text-sm text-brand-dark dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30 transition-all" > {SUPPORTED_LANGUAGES.map(l => ( @@ -444,94 +127,28 @@ export function CreateGlossaryDialog({
- {/* ── Tab Navigation ────────────────────────────── */} -
-
- {tabs.map((tab) => ( - - ))} -
-
- - {/* ── Tab Content ───────────────────────────────── */}
- -
); diff --git a/frontend/src/app/dashboard/glossaries/[id]/page.tsx b/frontend/src/app/dashboard/glossaries/[id]/page.tsx index 6719661..ddd0bb1 100644 --- a/frontend/src/app/dashboard/glossaries/[id]/page.tsx +++ b/frontend/src/app/dashboard/glossaries/[id]/page.tsx @@ -237,6 +237,7 @@ export default function GlossaryDetailPage() { const handleImportClick = () => fileInputRef.current?.click(); const handleFileChange = async (e: React.ChangeEvent) => { + if (!glossary) return; const file = e.target.files?.[0]; if (!file) return; e.target.value = ''; @@ -260,17 +261,24 @@ export default function GlossaryDetailPage() { }); return; } + // Auto-save immediately to DB + await updateGlossary(glossary.id, { + name: name.trim(), + source_language: sourceLanguage, + target_language: targetLanguage, + terms: parsed, + }); setTerms(parsed); toast({ title: t('glossaries.detail.importedTitle') || 'Importé', description: t('glossaries.detail.importedDesc', { count: String(parsed.length) }) || - `${parsed.length} termes importés.`, + `${parsed.length} termes importés et enregistrés.`, }); } catch { toast({ variant: 'destructive', - title: t('glossaries.detail.importErrorTitle') || 'Erreur', - description: t('glossaries.detail.importErrorDesc') || 'Impossible de lire le fichier.', + title: t('glossaries.toast.error') || 'Erreur', + description: t('glossaries.toast.errorUpdate') || 'Impossible de mettre à jour le glossaire.', }); } }; diff --git a/frontend/src/app/dashboard/glossaries/page.tsx b/frontend/src/app/dashboard/glossaries/page.tsx index fc3e634..363d0d9 100644 --- a/frontend/src/app/dashboard/glossaries/page.tsx +++ b/frontend/src/app/dashboard/glossaries/page.tsx @@ -1,23 +1,133 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { BookText, Plus, Library, Calendar, Hash, MessageSquare, Save, Trash2, Loader2, CheckCircle2, AlertCircle, ArrowRight, Info, ExternalLink, Search, + Upload, Scale, Cpu, TrendingUp, HeartPulse, Megaphone, Users, FlaskConical, ShoppingCart, Zap, PenLine } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { cn } from '@/lib/utils'; import { useUser } from '@/app/dashboard/useUser'; import { useI18n } from '@/lib/i18n'; -import { useGlossaries } from './useGlossaries'; -import type { GlossaryListItem } from './types'; +import { useGlossaries, useGlossaryTemplates } from './useGlossaries'; +import type { GlossaryListItem, GlossaryTermInput } from './types'; import { ProUpgradePrompt } from './ProUpgradePrompt'; import { CreateGlossaryDialog } from './CreateGlossaryDialog'; import { useToast } from '@/components/ui/toast'; import { SUPPORTED_LANGUAGES } from './types'; import { useTranslationStore } from '@/lib/store'; +import { parseFileToTerms } from './csvUtils'; + +const TEMPLATE_ICONS: Record> = { + legal: Scale, + technology: Cpu, + finance: TrendingUp, + medical: HeartPulse, + marketing: Megaphone, + hr: Users, + scientific: FlaskConical, + ecommerce: ShoppingCart, +}; + +function FileUploadZone({ + onTermsParsed, + disabled, + t, +}: { + onTermsParsed: (terms: GlossaryTermInput[], filename: string) => void; + disabled: boolean; + t: (key: string, params?: any) => string; +}) { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [status, setStatus] = useState<'idle' | 'parsing' | 'success' | 'error'>('idle'); + const [errorMsg, setErrorMsg] = useState(''); + + const processFile = 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') || 'Format non supporté'); + return; + } + if (file.size > 5 * 1024 * 1024) { + setStatus('error'); + setErrorMsg(t('glossaries.dialog.errorSize', { max: '5' }) || 'Fichier trop volumineux (max 5MB)'); + return; + } + setStatus('parsing'); + setErrorMsg(''); + try { + const terms = await parseFileToTerms(file); + if (terms.length === 0) { + setStatus('error'); + setErrorMsg(t('glossaries.dialog.errorEmpty') || 'Fichier vide'); + return; + } + setStatus('success'); + onTermsParsed(terms, file.name); + setTimeout(() => setStatus('idle'), 2000); + } catch { + setStatus('error'); + setErrorMsg(t('glossaries.dialog.errorRead') || 'Erreur de lecture'); + } + }; + + return ( +
{ e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (file) processFile(file); }} + onClick={() => !disabled && fileInputRef.current?.click()} + className={cn( + 'editorial-card flex flex-col items-center justify-center gap-3 p-6 text-center transition-all cursor-pointer min-h-[140px]', + isDragging ? 'border-brand-accent bg-brand-accent/5' : 'border-black/5 dark:border-white/5 hover:border-brand-accent/30 hover:bg-brand-muted/30 dark:hover:bg-white/[0.02]', + disabled && 'opacity-50 cursor-not-allowed', + status === 'error' && 'border-destructive/30 bg-destructive/5' + )} + > + { const file = e.target.files?.[0]; if (file) processFile(file); e.target.value = ''; }} + disabled={disabled} + /> + {status === 'parsing' ? ( + <> + +

{t('glossaries.dialog.parsing') || 'Analyse…'}

+ + ) : status === 'error' ? ( + <> + +

{errorMsg}

+ {t('glossaries.dialog.retry') || 'Réessayer'} + + ) : status === 'success' ? ( + <> + +

{t('glossaries.toast.imported') || 'Importé avec succès'}

+ + ) : ( + <> +
+ +
+
+

{t('glossaries.dialog.tabFile') || 'Glissez un fichier CSV'}

+

{t('glossaries.dialog.dropFormats') || 'Format supporté: CSV, Excel'}

+
+ + )} +
+ ); +} export default function GlossariesPage() { const { t } = useI18n(); @@ -32,6 +142,7 @@ export default function GlossariesPage() { createGlossary, importTemplate, } = useGlossaries(); + const { templates, isLoading: isLoadingTemplates } = useGlossaryTemplates(); const { toast } = useToast(); const { settings, updateSettings } = useTranslationStore(); @@ -40,6 +151,7 @@ export default function GlossariesPage() { const [isSavingPrompt, setIsSavingPrompt] = useState(false); const [promptSaved, setPromptSaved] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [importingPresetId, setImportingPresetId] = useState(null); const isPro = user?.tier === 'pro'; const isLoading = isLoadingUser || isLoadingGlossaries; @@ -73,7 +185,7 @@ export default function GlossariesPage() { setSystemPrompt(''); }; - const handleCreateGlossary = async (data: { name: string; source_language: string; target_language: string; terms: { source: string; target: string; translations?: Record }[] }) => { + const handleCreateGlossary = async (data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => { try { await createGlossary(data); setCreateDialogOpen(false); @@ -91,10 +203,10 @@ export default function GlossariesPage() { } }; - const handleImportTemplate = async (templateId: string, name?: string) => { + const handleImportPreset = async (templateId: string, name?: string) => { + setImportingPresetId(templateId); try { await importTemplate(templateId, name); - setCreateDialogOpen(false); toast({ title: t('glossaries.toast.imported'), description: name @@ -107,11 +219,41 @@ export default function GlossariesPage() { title: t('glossaries.toast.error'), description: t('glossaries.toast.errorImport'), }); - throw error; + } finally { + setImportingPresetId(null); } }; - // Filtered list (search) + const handleFileTermsImport = async (parsedTerms: GlossaryTermInput[], filename: string) => { + try { + const baseName = filename.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' '); + await createGlossary({ + name: baseName, + source_language: 'fr', + target_language: 'multi', + terms: parsedTerms, + }); + toast({ + title: t('glossaries.toast.created'), + description: t('glossaries.toast.createdDesc', { name: baseName }), + }); + } catch (error) { + toast({ + variant: 'destructive', + title: t('glossaries.toast.error'), + description: t('glossaries.toast.errorCreate'), + }); + } + }; + + const importedTemplateIds = useMemo(() => { + return new Set( + glossaries + .map((g: GlossaryListItem) => g.template_id) + .filter(Boolean) as string[] + ); + }, [glossaries]); + const filteredGlossaries = useMemo(() => { if (!searchQuery.trim()) return glossaries; const q = searchQuery.toLowerCase(); @@ -133,6 +275,8 @@ export default function GlossariesPage() { return ; } + const isProcessing = isCreating || isImportingTemplate || !!importingPresetId; + return (
@@ -151,7 +295,7 @@ export default function GlossariesPage() {
+ ); + })} + + )} + + + {/* Fichier & Manuel (1/3 de largeur) */} +
+ {/* Import Fichier */} +
+
+ + {t('glossaries.dialog.tabFile') || 'Importer un fichier'} +
+ +
+ + {/* Création Manuelle */} +
+
+ + {t('glossaries.dialog.tabManual') || 'Création manuelle'} +
+ +
+
+ + + {/* ── Your Glossaries ────────────────────────────────────── */}
@@ -426,9 +686,7 @@ export default function GlossariesPage() { open={createDialogOpen} onOpenChange={setCreateDialogOpen} onCreate={handleCreateGlossary} - onImportTemplate={handleImportTemplate} isCreating={isCreating} - isImportingTemplate={isImportingTemplate} />
);