Compare commits
14 Commits
96bac2e792
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| ebb2537fda | |||
| 36aeac2c5e | |||
| de9407f974 | |||
| 5e3fb0098b | |||
| a57b8a8e4d | |||
| dde80f6bc3 | |||
| 7398cae359 | |||
| 030950c962 | |||
| 5d6afd2dad | |||
| 07c50151cb | |||
| 299173fff7 | |||
| 9b354befe3 | |||
| 4a8f33d36f | |||
| 489df66c0f |
142
.agents/skills/find-skills/SKILL.md
Normal file
142
.agents/skills/find-skills/SKILL.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: find-skills
|
||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||
---
|
||||
|
||||
# Find Skills
|
||||
|
||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when the user:
|
||||
|
||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||
- Says "find a skill for X" or "is there a skill for X"
|
||||
- Asks "can you do X" where X is a specialized capability
|
||||
- Expresses interest in extending agent capabilities
|
||||
- Wants to search for tools, templates, or workflows
|
||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||
|
||||
## What is the Skills CLI?
|
||||
|
||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||
|
||||
**Key commands:**
|
||||
|
||||
- `npx skills find [query] [--owner <owner>]` - Search for skills interactively or by keyword, optionally scoped to a GitHub owner
|
||||
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||
- `npx skills check` - Check for skill updates
|
||||
- `npx skills update` - Update all installed skills
|
||||
|
||||
**Browse skills at:** https://skills.sh/
|
||||
|
||||
## How to Help Users Find Skills
|
||||
|
||||
### Step 1: Understand What They Need
|
||||
|
||||
When a user asks for help with something, identify:
|
||||
|
||||
1. The domain (e.g., React, testing, design, deployment)
|
||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||
3. Whether this is a common enough task that a skill likely exists
|
||||
|
||||
### Step 2: Check the Leaderboard First
|
||||
|
||||
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
|
||||
|
||||
For example, top skills for web development include:
|
||||
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
|
||||
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
|
||||
|
||||
### Step 3: Search for Skills
|
||||
|
||||
If the leaderboard doesn't cover the user's need, run the find command:
|
||||
|
||||
```bash
|
||||
npx skills find [query] [--owner <owner>]
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||
|
||||
### Step 4: Verify Quality Before Recommending
|
||||
|
||||
**Do not recommend a skill based solely on search results.** Always verify:
|
||||
|
||||
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
|
||||
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
|
||||
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
|
||||
|
||||
### Step 5: Present Options to the User
|
||||
|
||||
When you find relevant skills, present them to the user with:
|
||||
|
||||
1. The skill name and what it does
|
||||
2. The install count and source
|
||||
3. The install command they can run
|
||||
4. A link to learn more at skills.sh
|
||||
|
||||
Example response:
|
||||
|
||||
```
|
||||
I found a skill that might help! The "react-best-practices" skill provides
|
||||
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||
(185K installs)
|
||||
|
||||
To install it:
|
||||
npx skills add vercel-labs/agent-skills@react-best-practices
|
||||
|
||||
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
|
||||
```
|
||||
|
||||
### Step 6: Offer to Install
|
||||
|
||||
If the user wants to proceed, you can install the skill for them:
|
||||
|
||||
```bash
|
||||
npx skills add <owner/repo@skill> -g -y
|
||||
```
|
||||
|
||||
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||
|
||||
## Common Skill Categories
|
||||
|
||||
When searching, consider these common categories:
|
||||
|
||||
| Category | Example Queries |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||
| Testing | testing, jest, playwright, e2e |
|
||||
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||
| Documentation | docs, readme, changelog, api-docs |
|
||||
| Code Quality | review, lint, refactor, best-practices |
|
||||
| Design | ui, ux, design-system, accessibility |
|
||||
| Productivity | workflow, automation, git |
|
||||
|
||||
## Tips for Effective Searches
|
||||
|
||||
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||
|
||||
## When No Skills Are Found
|
||||
|
||||
If no relevant skills exist:
|
||||
|
||||
1. Acknowledge that no existing skill was found
|
||||
2. Offer to help with the task directly using your general capabilities
|
||||
3. Suggest the user could create their own skill with `npx skills init`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
I searched for skills related to "xyz" but didn't find any matches.
|
||||
I can still help you with this task directly! Would you like me to proceed?
|
||||
|
||||
If this is something you do often, you could create your own skill:
|
||||
npx skills init my-xyz-skill
|
||||
```
|
||||
39
.agents/skills/writing-guidelines/SKILL.md
Normal file
39
.agents/skills/writing-guidelines/SKILL.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: writing-guidelines
|
||||
description: Review docs/prose for Writing Guidelines compliance. Use when asked to "review my docs", "check writing style", "audit prose", "review docs voice and tone", or "check this page against the writing handbook".
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
argument-hint: <file-or-pattern>
|
||||
---
|
||||
|
||||
# Writing Guidelines
|
||||
|
||||
Review files for compliance with Writing Guidelines.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Fetch the latest guidelines from the source URL below
|
||||
2. Read the specified files (or prompt user for files/pattern)
|
||||
3. Check against all rules in the fetched guidelines
|
||||
4. Output findings in the terse `file:line` format
|
||||
|
||||
## Guidelines Source
|
||||
|
||||
Fetch fresh guidelines before each review:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/vercel-labs/writing-guidelines/main/command.md
|
||||
```
|
||||
|
||||
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||
|
||||
## Usage
|
||||
|
||||
When a user provides a file or pattern argument:
|
||||
1. Fetch guidelines from the source URL above
|
||||
2. Read the specified files
|
||||
3. Apply all rules from the fetched guidelines
|
||||
4. Output findings using the format specified in the guidelines
|
||||
|
||||
If no files specified, ask the user which files to review.
|
||||
206
_bmad-output/innovation-strategy-2026-06-13.md
Normal file
206
_bmad-output/innovation-strategy-2026-06-13.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Innovation Strategy: office_translator (Wordly.art)
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Strategist:** Sepehr
|
||||
**Strategic Focus:** Identifier comment transformer la préservation parfaite du format Office (aujourd'hui un avantage technique) en un positionnement de marché défendable et un modèle d'affaires scalable, face à des acteurs comme DeepL (qui copie déjà ce créneau) et Google (qui peut le copier à tout moment).
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Strategic Context
|
||||
|
||||
### Current Situation
|
||||
|
||||
**Produit technique mature, pré-lancement commercial.**
|
||||
|
||||
- Stack production-ready : FastAPI + Next.js 15 + PostgreSQL + Redis + Docker, déployé en self-hosted
|
||||
- Différenciateur technique central et vérifiable : **préservation de la mise en forme complexe Excel/Word/PowerPoint** (formules, fusions, styles, images, headers/footers, animations) — c'est le *seul* vrai fossé concurrentiel selon ton positionnement
|
||||
- Architecture multi-providers déjà en place (Google, DeepL, OpenAI, Ollama, DeepSeek, OpenRouter, LibreTranslate, WebLLM en navigateur) — flexibilité réelle mais risque de dilution du message
|
||||
- Capacités secondaires existantes : self-hosting, WebLLM zero-data, vision pour images dans Excel, glossaires techniques
|
||||
- Infrastructure monétisation prête (Stripe) mais **pas encore de clients payants actifs** documentés
|
||||
- Tarification définie : Free / Starter $9 / Pro $29 / Business $79
|
||||
- Marketing plan rédigé mais assets visuels (captures, vidéo démo) non produits
|
||||
- Faiblesses techniques identifiées (credentials en dur, stockage JSON, CORS permissif, pas de Celery)
|
||||
|
||||
### Strategic Challenge
|
||||
|
||||
**Le défi stratégique central : la préservation du format est un avantage technique reproductible.**
|
||||
|
||||
DeepL a déjà empiété sur ce territoire et Google peut le cloner en quelques trimestres. Ton avantage actuel est *technique* (comment tu traduis), pas *positionnel* (où tu te places dans l'esprit du client). Trois sous-défis critiques :
|
||||
|
||||
1. **Quel segment précis est prêt à payer significativement plus pour la préservation du format ?** (Pas "tout le monde qui traduit des documents" — c'est trop large et DeepL/Google les servent déjà.)
|
||||
2. **Quel modèle d'affaires verrouille cet avantage** au point que même un clone gratuit de Google ne puisse pas te déloger ? (Self-hosting enterprise ? Vertical spécialisé ? Données/apprentissage ? Communauté ?)
|
||||
3. **Quel "job to be done" non-traduction ton outil accomplit-il déjà** et que tu n'exploites pas encore commercialement ? (Compliance ? Archivage multilingue ? Workflow documentaire intégré ?)
|
||||
|
||||
Sans réponse tranchée à ces trois questions, tu risques d'être un excellent produit technique avec une proposition de valeur diluée.
|
||||
|
||||
---
|
||||
|
||||
## 📊 MARKET ANALYSIS
|
||||
|
||||
### Market Landscape
|
||||
|
||||
{{market_landscape}}
|
||||
|
||||
### Competitive Dynamics
|
||||
|
||||
{{competitive_dynamics}}
|
||||
|
||||
### Market Opportunities
|
||||
|
||||
{{market_opportunities}}
|
||||
|
||||
### Critical Insights
|
||||
|
||||
{{market_insights}}
|
||||
|
||||
---
|
||||
|
||||
## 💼 BUSINESS MODEL ANALYSIS
|
||||
|
||||
### Current Business Model
|
||||
|
||||
{{current_business_model}}
|
||||
|
||||
### Value Proposition Assessment
|
||||
|
||||
{{value_proposition}}
|
||||
|
||||
### Revenue and Cost Structure
|
||||
|
||||
{{revenue_cost_structure}}
|
||||
|
||||
### Business Model Weaknesses
|
||||
|
||||
{{model_weaknesses}}
|
||||
|
||||
---
|
||||
|
||||
## ⚡ DISRUPTION OPPORTUNITIES
|
||||
|
||||
### Disruption Vectors
|
||||
|
||||
{{disruption_vectors}}
|
||||
|
||||
### Unmet Customer Jobs
|
||||
|
||||
{{unmet_jobs}}
|
||||
|
||||
### Technology Enablers
|
||||
|
||||
{{technology_enablers}}
|
||||
|
||||
### Strategic White Space
|
||||
|
||||
{{strategic_whitespace}}
|
||||
|
||||
---
|
||||
|
||||
## 🚀 INNOVATION OPPORTUNITIES
|
||||
|
||||
### Innovation Initiatives
|
||||
|
||||
{{innovation_initiatives}}
|
||||
|
||||
### Business Model Innovation
|
||||
|
||||
{{business_model_innovation}}
|
||||
|
||||
### Value Chain Opportunities
|
||||
|
||||
{{value_chain_opportunities}}
|
||||
|
||||
### Partnership and Ecosystem Plays
|
||||
|
||||
{{partnership_opportunities}}
|
||||
|
||||
---
|
||||
|
||||
## 🎲 STRATEGIC OPTIONS
|
||||
|
||||
### Option A: {{option_a_name}}
|
||||
|
||||
{{option_a_description}}
|
||||
|
||||
**Pros:** {{option_a_pros}}
|
||||
|
||||
**Cons:** {{option_a_cons}}
|
||||
|
||||
### Option B: {{option_b_name}}
|
||||
|
||||
{{option_b_description}}
|
||||
|
||||
**Pros:** {{option_b_pros}}
|
||||
|
||||
**Cons:** {{option_b_cons}}
|
||||
|
||||
### Option C: {{option_c_name}}
|
||||
|
||||
{{option_c_description}}
|
||||
|
||||
**Pros:** {{option_c_pros}}
|
||||
|
||||
**Cons:** {{option_c_cons}}
|
||||
|
||||
---
|
||||
|
||||
## 🏆 RECOMMENDED STRATEGY
|
||||
|
||||
### Strategic Direction
|
||||
|
||||
{{recommended_strategy}}
|
||||
|
||||
### Key Hypotheses to Validate
|
||||
|
||||
{{key_hypotheses}}
|
||||
|
||||
### Critical Success Factors
|
||||
|
||||
{{success_factors}}
|
||||
|
||||
---
|
||||
|
||||
## 📋 EXECUTION ROADMAP
|
||||
|
||||
### Phase 1: Immediate Impact
|
||||
|
||||
{{phase_1}}
|
||||
|
||||
### Phase 2: Foundation Building
|
||||
|
||||
{{phase_2}}
|
||||
|
||||
### Phase 3: Scale & Optimization
|
||||
|
||||
{{phase_3}}
|
||||
|
||||
---
|
||||
|
||||
## 📈 SUCCESS METRICS
|
||||
|
||||
### Leading Indicators
|
||||
|
||||
{{leading_indicators}}
|
||||
|
||||
### Lagging Indicators
|
||||
|
||||
{{lagging_indicators}}
|
||||
|
||||
### Decision Gates
|
||||
|
||||
{{decision_gates}}
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ RISKS AND MITIGATION
|
||||
|
||||
### Key Risks
|
||||
|
||||
{{key_risks}}
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
{{risk_mitigation}}
|
||||
|
||||
---
|
||||
|
||||
_Generated using BMAD Creative Intelligence Suite - Innovation Strategy Workflow_
|
||||
@@ -41,9 +41,11 @@ function getDisplaySource(
|
||||
glossarySourceLang: string
|
||||
): string {
|
||||
if (!lang || lang === 'multi') return '';
|
||||
if (lang === glossarySourceLang) return term.source;
|
||||
const normalizedLang = lang.toLowerCase();
|
||||
const normalizedSourceLang = glossarySourceLang.toLowerCase();
|
||||
if (normalizedLang === normalizedSourceLang) return term.source;
|
||||
const translations = term.translations || {};
|
||||
return translations[lang] || '';
|
||||
return translations[normalizedLang] || '';
|
||||
}
|
||||
|
||||
/** Target term in the chosen language.
|
||||
@@ -54,9 +56,11 @@ function getDisplayTarget(
|
||||
glossaryTargetLang: string
|
||||
): string {
|
||||
if (!lang) return '';
|
||||
if (lang === 'multi' || lang === glossaryTargetLang) return term.target;
|
||||
const normalizedLang = lang.toLowerCase();
|
||||
const normalizedTargetLang = glossaryTargetLang.toLowerCase();
|
||||
if (normalizedLang === 'multi' || normalizedLang === normalizedTargetLang) return term.target;
|
||||
const translations = term.translations || {};
|
||||
return translations[lang] || '';
|
||||
return translations[normalizedLang] || '';
|
||||
}
|
||||
|
||||
export default function GlossaryDetailPage() {
|
||||
@@ -149,10 +153,12 @@ export default function GlossaryDetailPage() {
|
||||
if (i !== index) return t;
|
||||
const translations = { ...(t.translations || {}) } as Record<string, string>;
|
||||
|
||||
const editLang = field === 'source' ? sourceLanguage : targetLanguage;
|
||||
const editLang = (field === 'source' ? sourceLanguage : targetLanguage).toLowerCase();
|
||||
const srcLang = glossary.source_language.toLowerCase();
|
||||
const tgtLang = glossary.target_language.toLowerCase();
|
||||
|
||||
if (field === 'source') {
|
||||
if (editLang === glossary.source_language) {
|
||||
if (editLang === srcLang) {
|
||||
return { ...t, source: value };
|
||||
} else {
|
||||
if (editLang && editLang !== 'multi') {
|
||||
@@ -161,7 +167,7 @@ export default function GlossaryDetailPage() {
|
||||
return { ...t, translations };
|
||||
}
|
||||
} else {
|
||||
if (editLang === 'multi' || editLang === glossary.target_language) {
|
||||
if (editLang === 'multi' || editLang === tgtLang) {
|
||||
return { ...t, target: value };
|
||||
} else {
|
||||
if (editLang && editLang !== 'multi') {
|
||||
@@ -211,12 +217,24 @@ export default function GlossaryDetailPage() {
|
||||
};
|
||||
|
||||
const handleSourceLanguageChange = (newLang: string) => {
|
||||
// For multilingual glossaries, the selector is a VIEW filter only.
|
||||
// migrateTerms must not be called — it would destroy translation data.
|
||||
if (!glossary || glossary.target_language === 'multi') {
|
||||
setSourceLanguage(newLang);
|
||||
return;
|
||||
}
|
||||
const updated = migrateTerms(terms, sourceLanguage, newLang, targetLanguage, targetLanguage);
|
||||
setSourceLanguage(newLang);
|
||||
setTerms(updated);
|
||||
};
|
||||
|
||||
const handleTargetLanguageChange = (newLang: string) => {
|
||||
// For multilingual glossaries, the selector is a VIEW filter only.
|
||||
// migrateTerms must not be called — it would destroy translation data.
|
||||
if (!glossary || glossary.target_language === 'multi') {
|
||||
setTargetLanguage(newLang);
|
||||
return;
|
||||
}
|
||||
const updated = migrateTerms(terms, sourceLanguage, sourceLanguage, targetLanguage, newLang);
|
||||
setTargetLanguage(newLang);
|
||||
setTerms(updated);
|
||||
|
||||
749
frontend/src/app/dashboard/glossaries/new/page.tsx
Normal file
749
frontend/src/app/dashboard/glossaries/new/page.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
BookText, Upload, PenLine, ArrowRight, ArrowLeft, CheckCircle2,
|
||||
AlertCircle, Loader2, Scale, Cpu, TrendingUp, HeartPulse,
|
||||
Megaphone, Users, FlaskConical, ShoppingCart, X, Plus
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useGlossaries, useGlossaryTemplates } from '../useGlossaries';
|
||||
import { SUPPORTED_LANGUAGES } from '../types';
|
||||
import type { GlossaryTermInput } from '../types';
|
||||
import { parseFileToTerms } from '../csvUtils';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
// ── Icônes par catégorie de modèle ─────────────────────────────────────────
|
||||
const TEMPLATE_ICONS: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
legal: Scale,
|
||||
technology: Cpu,
|
||||
finance: TrendingUp,
|
||||
medical: HeartPulse,
|
||||
marketing: Megaphone,
|
||||
hr: Users,
|
||||
scientific: FlaskConical,
|
||||
ecommerce: ShoppingCart,
|
||||
};
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
type CreationMethod = 'template' | 'file' | 'manual' | null;
|
||||
type Step = 1 | 2;
|
||||
|
||||
export default function NewGlossaryPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { createGlossary, importTemplate, isCreating, isImportingTemplate } = useGlossaries();
|
||||
const { templates, isLoading: isLoadingTemplates } = useGlossaryTemplates();
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [method, setMethod] = useState<CreationMethod>(null);
|
||||
|
||||
// État pour modèle
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
const [isImportingThis, setIsImportingThis] = useState(false);
|
||||
|
||||
// État pour fichier
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [fileStatus, setFileStatus] = useState<'idle' | 'parsing' | 'success' | 'error'>('idle');
|
||||
const [fileError, setFileError] = useState('');
|
||||
const [parsedTerms, setParsedTerms] = useState<GlossaryTermInput[]>([]);
|
||||
const [parsedFileName, setParsedFileName] = useState('');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [fileWizardStep, setFileWizardStep] = useState<1 | 2 | 3>(1);
|
||||
const [fileSrcLang, setFileSrcLang] = useState('fr');
|
||||
const [fileTgtLang, setFileTgtLang] = useState('multi');
|
||||
|
||||
// État pour manuel
|
||||
const [manualName, setManualName] = useState('');
|
||||
const [manualSrc, setManualSrc] = useState('fr');
|
||||
const [manualTgt, setManualTgt] = useState('multi');
|
||||
|
||||
const isProcessing = isCreating || isImportingTemplate || isImportingThis;
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSelectMethod = (m: CreationMethod) => {
|
||||
setMethod(m);
|
||||
setStep(2);
|
||||
// reset
|
||||
setSelectedTemplateId(null);
|
||||
setFileStatus('idle');
|
||||
setParsedTerms([]);
|
||||
setParsedFileName('');
|
||||
setManualName('');
|
||||
setFileWizardStep(1);
|
||||
setFileSrcLang('fr');
|
||||
setFileTgtLang('multi');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setStep(1);
|
||||
setMethod(null);
|
||||
};
|
||||
|
||||
// Importer un modèle
|
||||
const handleImportTemplate = async () => {
|
||||
if (!selectedTemplateId) return;
|
||||
const tpl = templates.find(t => t.id === selectedTemplateId);
|
||||
setIsImportingThis(true);
|
||||
try {
|
||||
await importTemplate(selectedTemplateId, tpl?.name);
|
||||
toast({ title: 'Glossaire importé !', description: `Le modèle « ${tpl?.name} » a été ajouté à vos glossaires.` });
|
||||
router.push('/dashboard/glossaries');
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Erreur', description: 'Impossible d\'importer ce modèle.' });
|
||||
} finally {
|
||||
setIsImportingThis(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Parser un fichier CSV
|
||||
const processFile = async (file: File) => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
if (!ext || !['csv', 'xlsx', 'xls', 'ods', 'txt', 'tsv'].includes(ext)) {
|
||||
setFileStatus('error');
|
||||
setFileError('Format non supporté. Utilisez CSV, Excel (.xlsx), ODS, ou TSV.');
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setFileStatus('error');
|
||||
setFileError('Fichier trop volumineux (maximum 5 MB).');
|
||||
return;
|
||||
}
|
||||
setFileStatus('parsing');
|
||||
setFileError('');
|
||||
try {
|
||||
const terms = await parseFileToTerms(file);
|
||||
if (terms.length === 0) {
|
||||
setFileStatus('error');
|
||||
setFileError('Le fichier est vide ou n\'a pas pu être lu.');
|
||||
return;
|
||||
}
|
||||
setFileStatus('success');
|
||||
setParsedTerms(terms);
|
||||
setParsedFileName(file.name.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' '));
|
||||
} catch {
|
||||
setFileStatus('error');
|
||||
setFileError('Erreur lors de la lecture du fichier.');
|
||||
}
|
||||
};
|
||||
|
||||
// Télécharger exemple CSV
|
||||
const handleDownloadSample = () => {
|
||||
const sample = `source,target\nbénéfice,profit\nflux de trésorerie,cash flow\nbilan,balance sheet\ncompte de résultat,income statement\nrésiliation,termination`;
|
||||
const blob = new Blob([sample], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'exemple_glossaire.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Créer depuis fichier
|
||||
const handleCreateFromFile = async () => {
|
||||
if (!parsedTerms.length) return;
|
||||
try {
|
||||
await createGlossary({
|
||||
name: parsedFileName || 'Mon glossaire',
|
||||
source_language: fileSrcLang,
|
||||
target_language: fileTgtLang,
|
||||
terms: parsedTerms,
|
||||
});
|
||||
toast({ title: 'Glossaire créé !', description: `${parsedTerms.length} termes importés depuis votre fichier.` });
|
||||
router.push('/dashboard/glossaries');
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Erreur', description: 'Impossible de créer le glossaire.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Créer manuellement
|
||||
const handleCreateManual = async () => {
|
||||
if (!manualName.trim()) return;
|
||||
try {
|
||||
await createGlossary({ name: manualName.trim(), source_language: manualSrc, target_language: manualTgt, terms: [] });
|
||||
toast({ title: 'Glossaire créé !', description: `« ${manualName} » est prêt. Ajoutez vos premiers termes.` });
|
||||
router.push('/dashboard/glossaries');
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Erreur', description: 'Impossible de créer le glossaire.' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Rendu ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
|
||||
|
||||
{/* ── Fil d'Ariane ──────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 mb-8 text-[11px] font-medium text-[#555555] dark:text-white/40">
|
||||
<Link href="/dashboard/glossaries" className="hover:text-[#1A1A1A] dark:hover:text-white transition-colors">
|
||||
Mes glossaires
|
||||
</Link>
|
||||
<ArrowRight size={11} />
|
||||
<span className="text-[#1A1A1A] dark:text-white font-bold">Nouveau glossaire</span>
|
||||
</div>
|
||||
|
||||
{/* ── Indicateur d'étapes ────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
{/* Étape 1 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-black transition-all',
|
||||
step >= 1 ? 'bg-[#1A1A1A] text-white' : 'bg-[#EBEBEB] text-[#888888]'
|
||||
)}>
|
||||
{step > 1 ? <CheckCircle2 size={14} /> : '1'}
|
||||
</div>
|
||||
<span className={cn('text-xs font-bold uppercase tracking-wider', step === 1 ? 'text-[#1A1A1A] dark:text-white' : 'text-[#888888]')}>
|
||||
Choisir le type
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-px bg-[#E5E3DF] dark:bg-white/10" />
|
||||
|
||||
{/* Étape 2 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-black transition-all',
|
||||
step === 2 ? 'bg-[#1A1A1A] text-white' : 'bg-[#EBEBEB] text-[#888888]'
|
||||
)}>
|
||||
2
|
||||
</div>
|
||||
<span className={cn('text-xs font-bold uppercase tracking-wider', step === 2 ? 'text-[#1A1A1A] dark:text-white' : 'text-[#888888]')}>
|
||||
Configurer
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ══════════════ ÉTAPE 1 : Choisir le type ══════════════════ */}
|
||||
{step === 1 && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-serif font-medium text-[#1A1A1A] dark:text-white tracking-tight mb-2">
|
||||
Comment voulez-vous créer votre{' '}
|
||||
<span className="italic">glossaire ?</span>
|
||||
</h1>
|
||||
<p className="text-[#555555] dark:text-white/50 text-sm font-light">
|
||||
Choisissez la méthode qui correspond à votre situation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
|
||||
{/* Option 1 — Modèle professionnel */}
|
||||
<button
|
||||
onClick={() => handleSelectMethod('template')}
|
||||
className="group text-left p-7 bg-white dark:bg-[#141414] border-2 border-[#E5E3DF] dark:border-white/10 rounded-2xl hover:border-[#C5A17A] dark:hover:border-brand-accent/50 hover:shadow-lg transition-all cursor-pointer"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-[#F0EDE8] dark:bg-brand-accent/10 flex items-center justify-center text-[#8B6F47] mb-5 group-hover:scale-110 transition-transform">
|
||||
<BookText size={24} />
|
||||
</div>
|
||||
<h2 className="text-base font-serif font-bold text-[#1A1A1A] dark:text-white mb-2 tracking-tight">
|
||||
Depuis un modèle
|
||||
</h2>
|
||||
<p className="text-[12px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
|
||||
Choisissez parmi nos modèles professionnels pré-remplis : RH, Médical, Juridique, Tech…
|
||||
</p>
|
||||
<div className="mt-5 flex items-center gap-1.5 text-[11px] font-bold text-[#8B6F47] dark:text-brand-accent uppercase tracking-wider">
|
||||
Choisir un modèle <ArrowRight size={12} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Option 2 — Fichier CSV */}
|
||||
<button
|
||||
onClick={() => handleSelectMethod('file')}
|
||||
className="group text-left p-7 bg-white dark:bg-[#141414] border-2 border-[#E5E3DF] dark:border-white/10 rounded-2xl hover:border-[#C5A17A] dark:hover:border-brand-accent/50 hover:shadow-lg transition-all cursor-pointer"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-[#F0EDE8] dark:bg-brand-accent/10 flex items-center justify-center text-[#8B6F47] mb-5 group-hover:scale-110 transition-transform">
|
||||
<Upload size={24} />
|
||||
</div>
|
||||
<h2 className="text-base font-serif font-bold text-[#1A1A1A] dark:text-white mb-2 tracking-tight">
|
||||
Importer un fichier
|
||||
</h2>
|
||||
<p className="text-[12px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
|
||||
Importez vos propres termes depuis un fichier CSV, Excel (.xlsx) ou ODS existant.
|
||||
</p>
|
||||
<div className="mt-5 flex items-center gap-1.5 text-[11px] font-bold text-[#8B6F47] dark:text-brand-accent uppercase tracking-wider">
|
||||
Importer un fichier <ArrowRight size={12} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Option 3 — Manuel */}
|
||||
<button
|
||||
onClick={() => handleSelectMethod('manual')}
|
||||
className="group text-left p-7 bg-white dark:bg-[#141414] border-2 border-[#E5E3DF] dark:border-white/10 rounded-2xl hover:border-[#C5A17A] dark:hover:border-brand-accent/50 hover:shadow-lg transition-all cursor-pointer"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-[#F0EDE8] dark:bg-brand-accent/10 flex items-center justify-center text-[#8B6F47] mb-5 group-hover:scale-110 transition-transform">
|
||||
<PenLine size={24} />
|
||||
</div>
|
||||
<h2 className="text-base font-serif font-bold text-[#1A1A1A] dark:text-white mb-2 tracking-tight">
|
||||
Créer manuellement
|
||||
</h2>
|
||||
<p className="text-[12px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
|
||||
Partez de zéro et ajoutez vos termes un par un directement dans l'éditeur.
|
||||
</p>
|
||||
<div className="mt-5 flex items-center gap-1.5 text-[11px] font-bold text-[#8B6F47] dark:text-brand-accent uppercase tracking-wider">
|
||||
Créer vide <ArrowRight size={12} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══════════════ ÉTAPE 2 : Configurer ═══════════════════════ */}
|
||||
{step === 2 && (
|
||||
<div className="animate-fade-in">
|
||||
|
||||
{/* Bouton retour */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-[#555555] hover:text-[#1A1A1A] dark:hover:text-white transition-colors mb-8 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft size={14} /> Retour
|
||||
</button>
|
||||
|
||||
{/* ── CAS A : Depuis un modèle ─── */}
|
||||
{method === 'template' && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-serif font-medium text-[#1A1A1A] dark:text-white mb-2">
|
||||
Choisissez un <span className="italic">modèle professionnel</span>
|
||||
</h1>
|
||||
<p className="text-[#555555] dark:text-white/50 text-sm font-light mb-8">
|
||||
Sélectionnez le modèle le plus adapté à vos documents. Vous pourrez modifier les termes après l'import.
|
||||
</p>
|
||||
|
||||
{isLoadingTemplates ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-8 animate-spin text-[#C5A17A]" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="p-12 text-center bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl">
|
||||
<p className="text-sm text-[#555555] dark:text-white/40">
|
||||
Tous les modèles disponibles ont déjà été importés.
|
||||
</p>
|
||||
<button onClick={handleBack} className="mt-4 text-[11px] font-bold text-[#7A5C35] hover:underline cursor-pointer">
|
||||
← Choisir une autre méthode
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
||||
{templates.map((tpl) => {
|
||||
const Icon = TEMPLATE_ICONS[tpl.id] || BookText;
|
||||
const isSelected = selectedTemplateId === tpl.id;
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => setSelectedTemplateId(isSelected ? null : tpl.id)}
|
||||
className={cn(
|
||||
'text-left p-5 rounded-xl border-2 transition-all cursor-pointer',
|
||||
isSelected
|
||||
? 'border-[#1A1A1A] dark:border-white bg-[#F5F3EF] dark:bg-white/5 shadow-md'
|
||||
: 'border-[#E5E3DF] dark:border-white/10 bg-white dark:bg-[#141414] hover:border-[#C5A17A] dark:hover:border-brand-accent/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
|
||||
isSelected ? 'bg-[#1A1A1A] text-white' : 'bg-[#F0EDE8] dark:bg-brand-accent/10 text-[#8B6F47]'
|
||||
)}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-serif font-bold text-[#1A1A1A] dark:text-white leading-tight">
|
||||
{tpl.name.split(' - ')[0]}
|
||||
</p>
|
||||
<span className="shrink-0 text-[9px] font-mono font-semibold text-[#666666] dark:text-white/40">
|
||||
{tpl.terms_count} termes
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/40 font-light mt-1 line-clamp-2">
|
||||
{tpl.description}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckCircle2 size={18} className="text-[#1A1A1A] dark:text-white shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bouton d'import */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
{selectedTemplateId && (
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/40 font-light">
|
||||
Modèle sélectionné : <strong className="text-[#1A1A1A] dark:text-white">{templates.find(t => t.id === selectedTemplateId)?.name.split(' - ')[0]}</strong>
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleImportTemplate}
|
||||
disabled={!selectedTemplateId || isProcessing}
|
||||
className="flex items-center gap-2 px-7 py-3 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isImportingThis ? <><Loader2 size={14} className="animate-spin" /> Import en cours…</> : <><CheckCircle2 size={14} /> Importer ce modèle</>}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CAS B : Depuis un fichier — Wizard 3 sous-étapes ─── */}
|
||||
{method === 'file' && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-serif font-medium text-[#1A1A1A] dark:text-white mb-2">
|
||||
Importez votre <span className="italic">fichier de termes</span>
|
||||
</h1>
|
||||
|
||||
{/* Indicateur sous-étapes */}
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
{([1, 2, 3] as const).map((s, i) => {
|
||||
const labels = ['Format', 'Fichier', 'Configurer'];
|
||||
const done = fileWizardStep > s;
|
||||
const active = fileWizardStep === s;
|
||||
return (
|
||||
<>
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
<div className={cn(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-black transition-all',
|
||||
done ? 'bg-emerald-600 text-white' : active ? 'bg-[#1A1A1A] text-white' : 'bg-[#EBEBEB] dark:bg-white/10 text-[#888]'
|
||||
)}>
|
||||
{done ? <CheckCircle2 size={12} /> : s}
|
||||
</div>
|
||||
<span className={cn('text-[10px] font-bold uppercase tracking-wider hidden sm:block',
|
||||
active ? 'text-[#1A1A1A] dark:text-white' : 'text-[#AAAAAA]'
|
||||
)}>{labels[i]}</span>
|
||||
</div>
|
||||
{i < 2 && <div className="flex-1 h-px bg-[#E5E3DF] dark:bg-white/10" />}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Sous-étape 1 : Format ─── */}
|
||||
{fileWizardStep === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div className="bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl p-6 space-y-5">
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-3">Format attendu</p>
|
||||
<p className="text-[12px] text-[#555555] dark:text-white/50 font-light mb-4 leading-relaxed">
|
||||
Votre fichier doit avoir <strong className="text-[#1A1A1A] dark:text-white">2 colonnes</strong> : la première pour les termes source, la seconde pour les termes cibles.
|
||||
La première ligne peut être un en-tête (elle sera ignorée automatiquement).
|
||||
</p>
|
||||
|
||||
{/* Tableau exemple */}
|
||||
<div className="rounded-xl overflow-hidden border border-[#E5E3DF] dark:border-white/10 text-[11px] font-mono">
|
||||
<div className="grid grid-cols-2 bg-[#F5F3EF] dark:bg-white/5 border-b border-[#E5E3DF] dark:border-white/10">
|
||||
<div className="px-4 py-2 font-bold text-[#8B6F47] uppercase tracking-wider">source</div>
|
||||
<div className="px-4 py-2 font-bold text-[#8B6F47] uppercase tracking-wider border-l border-[#E5E3DF] dark:border-white/10">target</div>
|
||||
</div>
|
||||
{[['bénéfice', 'profit'], ['flux de trésorerie', 'cash flow'], ['bilan', 'balance sheet'], ['résiliation', 'termination']].map(([s, t]) => (
|
||||
<div key={s} className="grid grid-cols-2 border-b border-[#E5E3DF] dark:border-white/5 last:border-0 hover:bg-[#FAFAF8] dark:hover:bg-white/[0.02]">
|
||||
<div className="px-4 py-2 text-[#1A1A1A] dark:text-white">{s}</div>
|
||||
<div className="px-4 py-2 text-[#555555] dark:text-white/60 border-l border-[#E5E3DF] dark:border-white/10">{t}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formats acceptés */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['CSV (.csv)', 'Excel (.xlsx)', 'Excel (.xls)', 'ODS (.ods)', 'TSV (.tsv)', 'Texte (.txt)'].map(f => (
|
||||
<span key={f} className="px-2.5 py-1 rounded-lg bg-[#F0EDE8] dark:bg-white/5 text-[10px] font-bold text-[#8B6F47] dark:text-brand-accent uppercase tracking-wider">{f}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Règles */}
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
'Séparateur : virgule (CSV) ou tabulation (TSV)',
|
||||
'Encodage recommandé : UTF-8',
|
||||
'Taille maximale : 5 MB',
|
||||
'Limite : 500 termes par glossaire',
|
||||
'Les champs contenant des virgules doivent être entre guillemets',
|
||||
].map(rule => (
|
||||
<div key={rule} className="flex items-start gap-2 text-[11px] text-[#555555] dark:text-white/50">
|
||||
<CheckCircle2 size={12} className="text-emerald-500 mt-0.5 shrink-0" />
|
||||
{rule}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Télécharger exemple */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleDownloadSample}
|
||||
className="flex items-center gap-2 text-[11px] font-bold text-[#8B6F47] dark:text-brand-accent hover:underline cursor-pointer"
|
||||
>
|
||||
<Upload size={12} /> Télécharger un exemple CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFileWizardStep(2)}
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all cursor-pointer"
|
||||
>
|
||||
Suivant <ArrowRight size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Sous-étape 2 : Fichier ─── */}
|
||||
{fileWizardStep === 2 && (
|
||||
<div className="space-y-5">
|
||||
{/* Zone de dépôt */}
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); setIsDragging(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) processFile(f);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-4 p-12 rounded-2xl border-2 border-dashed cursor-pointer transition-all min-h-[220px]',
|
||||
isDragging ? 'border-[#C5A17A] bg-[#F5F0EA]' : 'border-[#D9D6D0] dark:border-white/10 hover:border-[#C5A17A] hover:bg-[#FAFAF8] dark:hover:bg-white/[0.02]',
|
||||
fileStatus === 'error' && 'border-red-400 bg-red-50 dark:bg-red-500/5'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
|
||||
className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) processFile(f); e.target.value = ''; }}
|
||||
/>
|
||||
{fileStatus === 'parsing' && (
|
||||
<><Loader2 size={32} className="animate-spin text-[#C5A17A]" /><p className="text-sm font-medium text-[#555555] dark:text-white/50">Lecture du fichier…</p></>
|
||||
)}
|
||||
{fileStatus === 'success' && (
|
||||
<>
|
||||
<CheckCircle2 size={36} className="text-emerald-600" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold text-emerald-700 dark:text-emerald-400">{parsedTerms.length} termes détectés</p>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">Cliquez pour changer de fichier</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{fileStatus === 'error' && (
|
||||
<>
|
||||
<AlertCircle size={36} className="text-red-500" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-red-600">{fileError}</p>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">Cliquez pour réessayer</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{fileStatus === 'idle' && (
|
||||
<>
|
||||
<div className="w-16 h-16 rounded-2xl bg-[#F0EDE8] dark:bg-white/5 flex items-center justify-center text-[#8B6F47]">
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-serif font-bold text-[#1A1A1A] dark:text-white">Glissez votre fichier ici</p>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">CSV, Excel, ODS, TSV — max 5 MB</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#AAAAAA] font-light">ou cliquez pour parcourir</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button onClick={() => setFileWizardStep(1)} className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-[#555555] hover:text-[#1A1A1A] dark:hover:text-white transition-colors cursor-pointer">
|
||||
<ArrowLeft size={13} /> Retour
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFileWizardStep(3)}
|
||||
disabled={fileStatus !== 'success'}
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
Suivant <ArrowRight size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Sous-étape 3 : Configurer ─── */}
|
||||
{fileWizardStep === 3 && (
|
||||
<div className="space-y-5">
|
||||
<div className="bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl p-6 space-y-6">
|
||||
|
||||
{/* Résumé fichier */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20">
|
||||
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-emerald-800 dark:text-emerald-400">{parsedTerms.length} termes prêts à importer</p>
|
||||
<button onClick={() => { setFileWizardStep(2); setFileStatus('idle'); setParsedTerms([]); }} className="text-[10px] text-emerald-700 dark:text-emerald-500 hover:underline cursor-pointer">
|
||||
Changer de fichier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nom */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
|
||||
Nom du glossaire <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={parsedFileName}
|
||||
onChange={e => setParsedFileName(e.target.value)}
|
||||
placeholder="Ex : Termes RH internes, Glossaire juridique…"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm placeholder:text-[#AAAAAA] dark:placeholder:text-white/25 focus:outline-none focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Langues */}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
|
||||
Langue source
|
||||
</label>
|
||||
<select
|
||||
value={fileSrcLang}
|
||||
onChange={e => setFileSrcLang(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.filter(l => l.code !== 'multi').map(l => (
|
||||
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-[#AAAAAA] dark:text-white/30 mt-1.5 font-light">Langue de la 1ère colonne de votre fichier</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
|
||||
Langue cible
|
||||
</label>
|
||||
<select
|
||||
value={fileTgtLang}
|
||||
onChange={e => setFileTgtLang(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-[#AAAAAA] dark:text-white/30 mt-1.5 font-light">Langue de la 2ème colonne de votre fichier</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3.5 rounded-xl bg-[#F5F3EF] dark:bg-white/[0.02] border border-[#D9D6D0] dark:border-white/5">
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
|
||||
<strong className="font-bold text-[#1A1A1A] dark:text-white/80">Conseil :</strong>{' '}
|
||||
Choisissez <strong>Multilingue</strong> comme langue cible si vous prévoyez d'utiliser ce glossaire pour traduire vers plusieurs langues. Vous pourrez ajouter des traductions dans l'éditeur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button onClick={() => setFileWizardStep(2)} className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-[#555555] hover:text-[#1A1A1A] dark:hover:text-white transition-colors cursor-pointer">
|
||||
<ArrowLeft size={13} /> Retour
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateFromFile}
|
||||
disabled={!parsedFileName.trim() || isProcessing}
|
||||
className="flex items-center gap-2 px-7 py-3 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isCreating ? <><Loader2 size={14} className="animate-spin" /> Création…</> : <><CheckCircle2 size={14} /> Créer le glossaire</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CAS C : Manuel ─── */}
|
||||
{method === 'manual' && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-serif font-medium text-[#1A1A1A] dark:text-white mb-2">
|
||||
Créez votre glossaire <span className="italic">manuellement</span>
|
||||
</h1>
|
||||
<p className="text-[#555555] dark:text-white/50 text-sm font-light mb-8">
|
||||
Donnez un nom à votre glossaire et choisissez les langues. Vous ajouterez les termes dans l'éditeur.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl p-8 space-y-6">
|
||||
|
||||
{/* Nom */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
|
||||
Nom du glossaire <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={manualName}
|
||||
onChange={e => setManualName(e.target.value)}
|
||||
placeholder="Ex : Termes juridiques internes, Glossaire RH…"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm placeholder:text-[#AAAAAA] dark:placeholder:text-white/25 focus:outline-none focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Langues */}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
|
||||
Langue source
|
||||
</label>
|
||||
<select
|
||||
value={manualSrc}
|
||||
onChange={e => setManualSrc(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.filter(l => l.code !== 'multi').map(l => (
|
||||
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
|
||||
Langue cible
|
||||
</label>
|
||||
<select
|
||||
value={manualTgt}
|
||||
onChange={e => setManualTgt(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3.5 rounded-xl bg-[#F5F3EF] dark:bg-white/[0.02] border border-[#D9D6D0] dark:border-white/5">
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
|
||||
<strong className="font-bold text-[#1A1A1A] dark:text-white/80">Que se passe-t-il ensuite ?</strong>{' '}
|
||||
Un glossaire vide sera créé. Vous serez redirigé vers l'éditeur pour ajouter vos termes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
onClick={handleCreateManual}
|
||||
disabled={!manualName.trim() || isProcessing}
|
||||
className="flex items-center gap-2 px-7 py-3 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isCreating ? <><Loader2 size={14} className="animate-spin" /> Création…</> : <><Plus size={14} /> Créer le glossaire</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useMemo } 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
|
||||
Library, Calendar, Hash, MessageSquare, Save, Trash2, Loader2,
|
||||
CheckCircle2, AlertCircle, ArrowRight, Info, Search, Plus,
|
||||
Sparkles, Zap
|
||||
} 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, useGlossaryTemplates } from './useGlossaries';
|
||||
import type { GlossaryListItem, GlossaryTermInput } from './types';
|
||||
import { useGlossaries } from './useGlossaries';
|
||||
import type { GlossaryListItem } 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<string, React.ComponentType<{ className?: string }>> = {
|
||||
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<HTMLInputElement>(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');
|
||||
}
|
||||
};
|
||||
// ── Chips de suggestions pour les consignes de contexte ─────────────────────
|
||||
const CONTEXT_SUGGESTIONS = [
|
||||
{ label: 'Ton formel', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' },
|
||||
{ label: 'Noms propres', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' },
|
||||
{ label: 'Chiffres', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' },
|
||||
{ label: 'Placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' },
|
||||
{ label: 'Termes techniques', value: 'Conserve les termes techniques en langue originale et ne les traduis pas.' },
|
||||
{ label: 'Style concis', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
|
||||
];
|
||||
|
||||
function renderTitle(title: string) {
|
||||
const lastSpaceIndex = title.lastIndexOf(' ');
|
||||
if (lastSpaceIndex === -1) return title;
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e) => { 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'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
|
||||
className="hidden"
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) processFile(file); e.target.value = ''; }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{status === 'parsing' ? (
|
||||
<>
|
||||
<Loader2 className="size-6 animate-spin text-brand-accent" />
|
||||
<p className="text-[11px] text-brand-dark/50 dark:text-white/40 font-light">{t('glossaries.dialog.parsing') || 'Analyse…'}</p>
|
||||
</>
|
||||
) : status === 'error' ? (
|
||||
<>
|
||||
<AlertCircle className="size-6 text-destructive" />
|
||||
<p className="text-[11px] font-medium text-destructive leading-tight">{errorMsg}</p>
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-brand-dark/40 dark:text-white/35">{t('glossaries.dialog.retry') || 'Réessayer'}</span>
|
||||
</>
|
||||
) : status === 'success' ? (
|
||||
<>
|
||||
<CheckCircle2 className="size-6 text-emerald-600 animate-bounce" />
|
||||
<p className="text-[11px] font-bold text-emerald-700 dark:text-emerald-400">{t('glossaries.toast.imported') || 'Importé avec succès'}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-accent/10 dark:bg-brand-accent/15 flex items-center justify-center text-brand-accent shrink-0 animate-pulse">
|
||||
<Upload className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-serif font-semibold text-brand-dark dark:text-white leading-tight tracking-tight">{t('glossaries.dialog.tabFile') || 'Glissez un fichier CSV'}</p>
|
||||
<p className="text-[10px] text-brand-dark/40 dark:text-white/30 mt-1 font-light leading-snug">{t('glossaries.dialog.dropFormats') || 'Format supporté: CSV, Excel'}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
{title.substring(0, lastSpaceIndex)}{' '}
|
||||
<span className="italic">{title.substring(lastSpaceIndex + 1)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,25 +43,14 @@ export default function GlossariesPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const {
|
||||
glossaries,
|
||||
total,
|
||||
isLoading: isLoadingGlossaries,
|
||||
isCreating,
|
||||
isImportingTemplate,
|
||||
createGlossary,
|
||||
importTemplate,
|
||||
} = useGlossaries();
|
||||
const { templates, isLoading: isLoadingTemplates } = useGlossaryTemplates();
|
||||
const { glossaries, isLoading: isLoadingGlossaries } = useGlossaries();
|
||||
const { toast } = useToast();
|
||||
const { settings, updateSettings } = useTranslationStore();
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [systemPrompt, setSystemPrompt] = useState(settings.systemPrompt);
|
||||
const [isSavingPrompt, setIsSavingPrompt] = useState(false);
|
||||
const [promptSaved, setPromptSaved] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [importingPresetId, setImportingPresetId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'glossaries' | 'context'>('glossaries');
|
||||
|
||||
const isPro = user?.tier === 'pro';
|
||||
@@ -186,79 +85,12 @@ export default function GlossariesPage() {
|
||||
setSystemPrompt('');
|
||||
};
|
||||
|
||||
const handleCreateGlossary = async (data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => {
|
||||
try {
|
||||
await createGlossary(data);
|
||||
setCreateDialogOpen(false);
|
||||
toast({
|
||||
title: t('glossaries.toast.created'),
|
||||
description: t('glossaries.toast.createdDesc', { name: data.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('glossaries.toast.error'),
|
||||
description: t('glossaries.toast.errorCreate'),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const handleAddSuggestion = (value: string) => {
|
||||
const current = systemPrompt.trim();
|
||||
const newPrompt = current ? `${current}\n${value}` : value;
|
||||
setSystemPrompt(newPrompt);
|
||||
};
|
||||
|
||||
const handleImportPreset = async (templateId: string, name?: string) => {
|
||||
setImportingPresetId(templateId);
|
||||
try {
|
||||
await importTemplate(templateId, name);
|
||||
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'),
|
||||
});
|
||||
} finally {
|
||||
setImportingPresetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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 availableTemplates = useMemo(() => {
|
||||
return templates.filter(t => !importedTemplateIds.has(t.id));
|
||||
}, [templates, importedTemplateIds]);
|
||||
|
||||
const filteredGlossaries = useMemo(() => {
|
||||
if (!searchQuery.trim()) return glossaries;
|
||||
const q = searchQuery.toLowerCase();
|
||||
@@ -270,7 +102,7 @@ export default function GlossariesPage() {
|
||||
<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-brand-muted border-t-brand-accent mx-auto"></div>
|
||||
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light">{t('glossaries.loading')}</p>
|
||||
<p className="text-xs text-[#555555] dark:text-white/40 font-light">{t('glossaries.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -280,149 +112,159 @@ export default function GlossariesPage() {
|
||||
return <ProUpgradePrompt />;
|
||||
}
|
||||
|
||||
const isProcessing = isCreating || isImportingTemplate || !!importingPresetId;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6 lg:p-8">
|
||||
|
||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||
{/* ── Header ────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end mb-10 gap-6">
|
||||
<div>
|
||||
<span className="accent-pill mb-3 block w-fit font-medium text-[10px] uppercase tracking-widest">
|
||||
{t('glossaries.yourGlossaries') || "Vos Glossaires"}
|
||||
{t('glossaries.yourGlossaries') || 'Vos Glossaires'}
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||||
{renderTitle(t('glossaries.title') || "Glossaires & Contexte")}
|
||||
{renderTitle(t('glossaries.title') || 'Glossaires & Contexte')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
||||
{t('glossaries.description') || "Gérez vos glossaires et instructions de contexte pour des traductions plus précises."}
|
||||
<p className="text-[#555555] dark:text-white/60 text-sm font-light leading-relaxed">
|
||||
{t('glossaries.description') || 'Gérez vos glossaires et instructions de contexte pour des traductions plus précises.'}
|
||||
</p>
|
||||
</div>
|
||||
{/* Bouton principal — unique point d'entrée pour créer */}
|
||||
<Link
|
||||
href="/dashboard/glossaries/new"
|
||||
className="shrink-0 flex items-center gap-2 px-5 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Nouveau glossaire
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ── How it works ───────────────────────────────────────── */}
|
||||
<div className="mb-10 p-5 rounded-2xl bg-brand-accent/5 dark:bg-brand-accent/10 border border-brand-accent/20 dark:border-brand-accent/15">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Info size={15} className="text-brand-accent shrink-0" />
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-brand-accent">{t('glossaries.howItWorks.title')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Step 1 */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-7 h-7 rounded-full bg-brand-accent text-white flex items-center justify-center text-[11px] font-black shrink-0 mt-0.5">1</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-brand-dark dark:text-white mb-1">{t('glossaries.howItWorks.step1Title')}</p>
|
||||
<p className="text-[11px] text-brand-dark/55 dark:text-white/50 font-light leading-relaxed">
|
||||
{t('glossaries.howItWorks.step1Desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Arrow */}
|
||||
<div className="hidden sm:flex items-center justify-center">
|
||||
<ArrowRight size={18} className="text-brand-accent/40" />
|
||||
</div>
|
||||
{/* Step 2 */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-7 h-7 rounded-full bg-brand-accent text-white flex items-center justify-center text-[11px] font-black shrink-0 mt-0.5">2</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-brand-dark dark:text-white mb-1">{t('glossaries.howItWorks.step2Title')}</p>
|
||||
<p className="text-[11px] text-brand-dark/55 dark:text-white/50 font-light leading-relaxed">
|
||||
{t('glossaries.howItWorks.step2Desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-brand-accent/10">
|
||||
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light">
|
||||
{t('glossaries.howItWorks.warning')}
|
||||
</p>
|
||||
{/* ── Bandeau informatif ────────────────────────────────────── */}
|
||||
<div className="mb-8 flex items-center gap-3 p-4 rounded-xl bg-[#F5F0EA] dark:bg-brand-accent/10 border border-[#D4B896] dark:border-brand-accent/20">
|
||||
<div className="w-7 h-7 rounded-full bg-[#8B6F47] text-white flex items-center justify-center shrink-0">
|
||||
<Info size={13} />
|
||||
</div>
|
||||
<p className="text-[11px] text-[#2D2D2D] dark:text-white/70 font-medium leading-relaxed flex-1">
|
||||
<strong className="font-bold text-[#1A1A1A] dark:text-white">Pour utiliser un glossaire dans une traduction :</strong>{' '}
|
||||
cliquez sur <span className="font-bold text-[#7A5C35] dark:text-brand-accent">« Utiliser »</span> sur la carte souhaitée.
|
||||
Vous serez redirigé sur la page Traduire avec ce glossaire déjà sélectionné.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/translate"
|
||||
className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-[#7A5C35] dark:text-brand-accent hover:underline whitespace-nowrap"
|
||||
>
|
||||
Aller traduire <ArrowRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ── Tab Switcher ───────────────────────────────────────── */}
|
||||
<div className="flex border-b border-black/5 dark:border-white/5 mb-8">
|
||||
{/* ── Onglets ───────────────────────────────────────────────── */}
|
||||
<div className="flex border-b border-black/10 dark:border-white/10 mb-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('glossaries')}
|
||||
className={cn(
|
||||
"pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer",
|
||||
'pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer',
|
||||
activeTab === 'glossaries'
|
||||
? "border-brand-accent text-brand-dark dark:text-white"
|
||||
: "border-transparent text-brand-dark/40 dark:text-white/40 hover:text-brand-dark/70 dark:hover:text-white/70"
|
||||
? 'border-brand-accent text-[#1A1A1A] dark:text-white'
|
||||
: 'border-transparent text-[#555555] dark:text-white/40 hover:text-[#1A1A1A] dark:hover:text-white/80'
|
||||
)}
|
||||
>
|
||||
{t('glossaries.tabs.glossaries') || "Glossaires terminologiques"}
|
||||
{t('glossaries.tabs.glossaries') || 'Glossaires terminologiques'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('context')}
|
||||
className={cn(
|
||||
"pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer",
|
||||
'pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer',
|
||||
activeTab === 'context'
|
||||
? "border-brand-accent text-brand-dark dark:text-white"
|
||||
: "border-transparent text-brand-dark/40 dark:text-white/40 hover:text-brand-dark/70 dark:hover:text-white/70"
|
||||
? 'border-brand-accent text-[#1A1A1A] dark:text-white'
|
||||
: 'border-transparent text-[#555555] dark:text-white/40 hover:text-[#1A1A1A] dark:hover:text-white/80'
|
||||
)}
|
||||
>
|
||||
{t('glossaries.tabs.context') || "Consignes de contexte (IA)"}
|
||||
{t('glossaries.tabs.context') || 'Consignes de contexte (IA)'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
{activeTab === 'context' ? (
|
||||
/* ── System Prompt (Context) ─────────────────────────────── */
|
||||
|
||||
/* ── Onglet Consignes de Contexte ─────────────────────── */
|
||||
<section className="editorial-card p-8 lg:p-10 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3 text-brand-accent">
|
||||
<MessageSquare size={18} />
|
||||
<h3 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||||
{t('context.instructions.title')}
|
||||
</h3>
|
||||
|
||||
{/* En-tête section */}
|
||||
<div className="flex items-start justify-between mb-6 gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center text-[#8B6F47] shrink-0">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-serif font-semibold text-[#1A1A1A] dark:text-white tracking-tight">
|
||||
Consignes de contexte
|
||||
</h2>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/50 font-light mt-0.5">
|
||||
Ces instructions sont envoyées automatiquement à l'IA avant chaque traduction en mode Pro LLM.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Badge d'état */}
|
||||
{promptHasUnsavedChanges ? (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-500/10 px-3 py-1 rounded-full border border-amber-500/20">
|
||||
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-700 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10 px-3 py-1.5 rounded-full border border-amber-300 dark:border-amber-500/20">
|
||||
<AlertCircle size={11} />
|
||||
{t('glossaries.status.unsaved')}
|
||||
Non sauvegardé
|
||||
</span>
|
||||
) : promptIsActive ? (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-3 py-1 rounded-full border border-emerald-500/20">
|
||||
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10 px-3 py-1.5 rounded-full border border-emerald-300 dark:border-emerald-500/20">
|
||||
<CheckCircle2 size={11} />
|
||||
{t('glossaries.status.active')}
|
||||
Actif
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/30 dark:text-white/30 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/5 dark:border-white/5">
|
||||
{t('glossaries.status.inactive')}
|
||||
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-[#555555] bg-[#EBEBEB] dark:text-white/30 dark:bg-white/5 px-3 py-1.5 rounded-full border border-[#CCCCCC] dark:border-white/10">
|
||||
Inactif
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Explanation box */}
|
||||
<div className="mb-5 p-3.5 rounded-xl bg-brand-muted/40 dark:bg-white/[0.03] border border-black/5 dark:border-white/5">
|
||||
<p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed">
|
||||
<strong className="font-bold text-brand-dark/80 dark:text-white/80">{t('glossaries.instructions.whatForBold')}</strong> {t('glossaries.instructions.whatForDesc')}
|
||||
</p>
|
||||
<p className="text-[11px] text-brand-accent/80 dark:text-brand-accent/70 font-medium mt-2 italic">
|
||||
{t('glossaries.instructions.example')}
|
||||
</p>
|
||||
<p className="text-[10px] text-brand-accent font-bold mt-2.5 flex items-center gap-1.5 border-t border-black/5 dark:border-white/5 pt-2">
|
||||
<Info size={11} />
|
||||
Remarque : Ces consignes s'appliquent automatiquement à toutes vos traductions réalisées en mode Pro LLM.
|
||||
</p>
|
||||
{/* Chips de suggestions rapides */}
|
||||
<div className="mb-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles size={13} className="text-[#8B6F47]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-[#555555] dark:text-white/40">
|
||||
Suggestions rapides — cliquez pour ajouter
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CONTEXT_SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
onClick={() => handleAddSuggestion(s.value)}
|
||||
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-[#3D3D3D] dark:text-white/70 bg-[#F0EDE8] dark:bg-white/5 border border-[#D9D5CE] dark:border-white/10 hover:bg-[#E8E2DA] hover:border-[#C5A17A] dark:hover:bg-brand-accent/10 dark:hover:border-brand-accent/30 transition-all cursor-pointer"
|
||||
>
|
||||
+ {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone de texte */}
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={e => { setSystemPrompt(e.target.value); setPromptSaved(false); }}
|
||||
placeholder={t('context.instructions.placeholder')}
|
||||
className="w-full h-40 p-4 bg-brand-muted/30 dark:bg-white/[0.02] rounded-xl border border-black/5 dark:border-white/10 text-xs focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30 transition-all outline-none resize-y"
|
||||
placeholder="Ex : Vous traduisez des documents techniques HVAC. Utilisez une terminologie d'ingénierie précise et un ton professionnel…"
|
||||
className="w-full h-44 p-4 bg-[#FAFAF8] dark:bg-white/[0.02] rounded-xl border border-[#D9D6D0] dark:border-white/10 text-[#1A1A1A] dark:text-white text-xs leading-relaxed focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50 transition-all outline-none resize-y placeholder:text-[#AAAAAA] dark:placeholder:text-white/25"
|
||||
/>
|
||||
|
||||
{/* Pied de section */}
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<p className="text-[10px] text-brand-dark/30 dark:text-white/25 font-light">
|
||||
{systemPrompt.length > 0 ? t('glossaries.instructions.charCount', { count: systemPrompt.length }) : t('glossaries.instructions.emptyHint')}
|
||||
<p className="text-[10px] text-[#777777] dark:text-white/25 font-light">
|
||||
{systemPrompt.length > 0
|
||||
? `${systemPrompt.length} caractères`
|
||||
: 'Aucune consigne enregistrée'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleClearPrompt}
|
||||
className="px-5 py-2.5 bg-brand-muted dark:bg-white/5 text-brand-dark/50 dark:text-white/40 rounded-lg text-xs font-bold uppercase tracking-wider hover:text-brand-dark dark:hover:text-white transition-all cursor-pointer"
|
||||
disabled={!systemPrompt}
|
||||
className="px-5 py-2.5 bg-[#EBEBEB] dark:bg-white/5 text-[#444444] dark:text-white/40 rounded-lg text-xs font-bold uppercase tracking-wider hover:text-[#1A1A1A] hover:bg-[#E0E0E0] dark:hover:text-white transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={12} className="inline mr-1.5" />{t('glossaries.instructions.clearAll')}
|
||||
<Trash2 size={12} className="inline mr-1.5" />
|
||||
Tout effacer
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePrompt}
|
||||
@@ -430,323 +272,171 @@ export default function GlossariesPage() {
|
||||
className="premium-button px-8 py-2.5 text-xs uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold"
|
||||
>
|
||||
{isSavingPrompt
|
||||
? <><Loader2 size={14} className="animate-spin" /> {t('glossaries.instructions.saving')}</>
|
||||
? <><Loader2 size={14} className="animate-spin" /> Enregistrement…</>
|
||||
: promptSaved
|
||||
? <><CheckCircle2 size={14} /> {t('glossaries.instructions.saved')}</>
|
||||
: <><Save size={14} /> {t('context.save')}</>
|
||||
? <><CheckCircle2 size={14} /> Enregistré</>
|
||||
: <><Save size={14} /> Enregistrer</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
) : (
|
||||
<div className="space-y-12 animate-fade-in">
|
||||
{/* ── Section 1 : Vos Glossaires (En premier !) ───────────────── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5 gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||||
{t('glossaries.grid.title')} <span className="italic">{t('glossaries.grid.titleHighlight')}</span>
|
||||
</h2>
|
||||
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light mt-1">
|
||||
{glossaries.length > 0
|
||||
? t('glossaries.grid.countWithAction', { count: glossaries.length, plural: glossaries.length > 1 ? 's' : '' })
|
||||
: t('glossaries.grid.emptyAction')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{currentTargetInfo && (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-brand-dark/50 dark:text-white/40 bg-brand-muted dark:bg-white/5 border border-black/5 dark:border-white/5 px-3 py-1.5 rounded-full">
|
||||
<span>{t('glossaries.grid.activeTranslation')}</span>
|
||||
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar (only if more than 3 glossaries) */}
|
||||
{glossaries.length > 3 && (
|
||||
<div className="relative mb-5">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-brand-dark/30 dark:text-white/30" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('glossaries.grid.searchPlaceholder') || "Rechercher un glossaire…"}
|
||||
className="w-full pl-9 pr-3 py-2.5 text-xs rounded-lg border border-black/5 dark:border-white/10 bg-white dark:bg-[#141414] focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/* ── Onglet Glossaires ──────────────────────────────────── */
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
{glossaries.length === 0 ? (
|
||||
<div className="editorial-card p-12 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
|
||||
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-xl flex items-center justify-center text-brand-accent mx-auto mb-4">
|
||||
<Library size={24} />
|
||||
</div>
|
||||
<p className="text-base font-serif font-medium text-brand-dark dark:text-white mb-1">{t('glossaries.empty')}</p>
|
||||
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light mb-5">{t('glossaries.emptyDesc')}</p>
|
||||
<button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="premium-button px-6 py-2.5 text-[11px] uppercase tracking-widest !rounded-lg inline-flex items-center gap-2 cursor-pointer font-bold"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t('glossaries.createNew') || "Créer un glossaire"}
|
||||
</button>
|
||||
</div>
|
||||
) : filteredGlossaries.length === 0 ? (
|
||||
<div className="editorial-card p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
|
||||
<p className="text-xs text-brand-dark/50 dark:text-white/50 font-light">
|
||||
{t('glossaries.grid.noResults') || "Aucun résultat pour cette recherche."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredGlossaries.map((glossary: GlossaryListItem) => {
|
||||
const termCount = glossary.terms_count ?? 0;
|
||||
const srcInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language);
|
||||
const tgtInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language);
|
||||
const isMultilingual = glossary.target_language === 'multi';
|
||||
const matchesTarget = currentTargetLang && (isMultilingual || glossary.target_language === currentTargetLang);
|
||||
const mismatch = currentTargetLang && !isMultilingual && glossary.target_language && glossary.target_language !== currentTargetLang;
|
||||
return (
|
||||
<div
|
||||
key={glossary.id}
|
||||
className={cn(
|
||||
'editorial-card p-6 bg-white dark:bg-[#141414] border rounded-2xl shadow-sm group transition-all relative text-left w-full flex flex-col justify-between min-h-[200px]',
|
||||
matchesTarget
|
||||
? 'border-brand-accent/40 ring-1 ring-brand-accent/20 hover:border-brand-accent/60'
|
||||
: mismatch
|
||||
? 'border-amber-300/40 dark:border-amber-500/20 hover:border-amber-400/60 opacity-75 hover:opacity-100'
|
||||
: 'border-black/5 dark:border-white/5 hover:border-brand-accent/30'
|
||||
)}
|
||||
>
|
||||
{matchesTarget && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider">
|
||||
<CheckCircle2 size={9} /> {t('glossaries.badge.compatible')}
|
||||
</div>
|
||||
)}
|
||||
{mismatch && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider">
|
||||
<AlertCircle size={9} /> {t('glossaries.badge.otherTarget')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cursor-pointer flex-1" onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-brand-muted dark:bg-white/10 rounded-xl flex items-center justify-center text-brand-accent shrink-0">
|
||||
<Library size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<h3 className="text-sm font-serif font-semibold text-brand-dark dark:text-white tracking-tight leading-snug line-clamp-2 group-hover:text-brand-accent transition-colors">
|
||||
{glossary.name}
|
||||
</h3>
|
||||
<p className="text-[11px] text-brand-dark/40 dark:text-white/40 font-medium flex items-center gap-1 mt-0.5">
|
||||
<span>{srcInfo?.flag ?? '🌐'}</span>
|
||||
<span>{srcInfo?.label ?? glossary.source_language}</span>
|
||||
<span className="text-brand-accent font-bold">→</span>
|
||||
<span>{tgtInfo?.flag ?? '🌐'}</span>
|
||||
<span>{tgtInfo?.label ?? glossary.target_language}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pb-4 text-xs text-brand-dark/40 dark:text-white/40">
|
||||
<span className="flex items-center gap-1 font-semibold text-brand-dark/65 dark:text-white/65">
|
||||
<Hash size={12} className="text-brand-accent" />
|
||||
{termCount} {t('glossaries.defineTerms') || "termes"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono text-[9px] opacity-75">
|
||||
<Calendar size={11} />
|
||||
{new Date(glossary.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4 border-t border-black/5 dark:border-white/10 mt-auto shrink-0 w-full">
|
||||
<Link
|
||||
href={`/dashboard/glossaries/${glossary.id}`}
|
||||
className="flex-1 text-center py-2 rounded-lg bg-brand-muted/65 dark:bg-white/5 hover:bg-brand-accent/10 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all"
|
||||
>
|
||||
Détails / Éditer
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/translate?glossaryId=${glossary.id}`}
|
||||
className="flex-1 text-center py-2 rounded-lg bg-brand-accent hover:bg-brand-accent/90 text-white text-[10px] font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-1.5"
|
||||
>
|
||||
Traduire
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Section 2 : Créer ou Importer ─────────────────────────── */}
|
||||
<section className="editorial-card p-8 lg:p-10 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 text-brand-accent mb-2">
|
||||
<Zap size={18} />
|
||||
<h2 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||||
{t('glossaries.presets.whatForBold') || 'Créer un nouveau glossaire'}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs text-brand-dark/50 dark:text-white/40 font-light">
|
||||
{t('glossaries.presets.whatForDesc') || 'Choisissez un modèle professionnel pré-rempli ou importez vos propres termes.'}
|
||||
{/* En-tête de section + compteur */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-serif font-medium text-[#1A1A1A] dark:text-white tracking-tight">
|
||||
{t('glossaries.grid.title') || 'Vos'}{' '}
|
||||
<span className="italic">{t('glossaries.grid.titleHighlight') || 'glossaires'}</span>
|
||||
</h2>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/40 font-light mt-1">
|
||||
{glossaries.length > 0
|
||||
? `${glossaries.length} glossaire${glossaries.length > 1 ? 's' : ''} — cliquez sur une carte pour la modifier`
|
||||
: 'Aucun glossaire — créez-en un pour commencer'}
|
||||
</p>
|
||||
</div>
|
||||
{currentTargetInfo && (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-[#444444] dark:text-white/40 bg-[#EBEBEB] dark:bg-white/5 border border-[#CCCCCC] dark:border-white/5 px-3 py-1.5 rounded-full shrink-0">
|
||||
<span>Traduction active :</span>
|
||||
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Grille des Modèles (2/3 de largeur) */}
|
||||
<div className="lg:col-span-2 space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-brand-accent">
|
||||
<BookText size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">{t('context.presets.title') || 'Modèles professionnels'}</span>
|
||||
</div>
|
||||
|
||||
{isLoadingTemplates ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-brand-muted" />
|
||||
</div>
|
||||
) : availableTemplates.length === 0 ? (
|
||||
<div className="p-8 bg-brand-muted/20 dark:bg-white/[0.01] border border-black/5 dark:border-white/5 rounded-xl text-center w-full">
|
||||
<p className="text-xs text-brand-dark/40 dark:text-white/30 font-light">
|
||||
{t('glossaries.presets.allImported') || 'Tous les modèles professionnels ont été importés.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{availableTemplates.map((template) => {
|
||||
const Icon = TEMPLATE_ICONS[template.id] || BookText;
|
||||
const isImported = importedTemplateIds.has(template.id);
|
||||
const isProcessingThis = importingPresetId === template.id;
|
||||
{/* Barre de recherche (si > 3 glossaires) */}
|
||||
{glossaries.length > 3 && (
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#888888] dark:text-white/30" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher un glossaire…"
|
||||
className="w-full pl-9 pr-3 py-2.5 text-xs rounded-lg border border-[#D9D6D0] dark:border-white/10 bg-white dark:bg-[#141414] text-[#1A1A1A] dark:text-white placeholder:text-[#AAAAAA] dark:placeholder:text-white/25 focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className={cn(
|
||||
'relative p-4 rounded-xl text-left border transition-all min-h-[110px] flex flex-col justify-between group',
|
||||
isImported
|
||||
? 'bg-emerald-500/5 dark:bg-emerald-500/5 border-emerald-500/20 dark:border-emerald-500/10 opacity-90'
|
||||
: 'bg-brand-muted/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:border-brand-accent/20 hover:bg-brand-accent/[0.02]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="p-1.5 bg-brand-accent/10 rounded-lg text-brand-accent group-hover:scale-115 transition-transform">
|
||||
{isProcessingThis ? <Loader2 size={16} className="animate-spin" /> : <Icon className="size-4" />}
|
||||
</div>
|
||||
{isImported ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
|
||||
<CheckCircle2 size={9} /> {t('glossaries.presets.alreadyImported') || 'Importé'}
|
||||
</span>
|
||||
{(() => {
|
||||
const matchingGlossary = glossaries.find((g) => g.template_id === template.id);
|
||||
return matchingGlossary ? (
|
||||
<Link
|
||||
href={`/dashboard/glossaries/${matchingGlossary.id}`}
|
||||
className="text-[9px] font-bold uppercase tracking-wider text-brand-accent hover:underline flex items-center gap-0.5"
|
||||
>
|
||||
Modifier
|
||||
</Link>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleImportPreset(template.id, template.name)}
|
||||
className="accent-pill !px-3 !py-1 !text-[9px] font-bold uppercase tracking-wider bg-brand-accent text-white hover:bg-brand-accent/90 rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
{t('glossaries.presets.importBtn') || 'Importer'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between items-baseline gap-2">
|
||||
<p className="text-xs font-serif font-bold text-brand-dark dark:text-white leading-tight">
|
||||
{template.name.split(' - ')[0]}
|
||||
</p>
|
||||
<span className="text-[9px] text-brand-dark/40 dark:text-white/30 font-semibold font-mono shrink-0">
|
||||
{template.terms_count} {t('glossaries.defineTerms') || 'termes'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-brand-dark/45 dark:text-white/35 font-light leading-normal mt-1 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* État vide */}
|
||||
{glossaries.length === 0 ? (
|
||||
<div className="editorial-card p-16 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
|
||||
<div className="w-14 h-14 bg-[#F0EDE8] dark:bg-white/10 rounded-2xl flex items-center justify-center text-[#8B6F47] mx-auto mb-5">
|
||||
<Library size={28} />
|
||||
</div>
|
||||
<p className="text-base font-serif font-semibold text-[#1A1A1A] dark:text-white mb-2">
|
||||
{t('glossaries.empty') || 'Aucun glossaire'}
|
||||
</p>
|
||||
<p className="text-sm text-[#555555] dark:text-white/40 font-light mb-6 max-w-xs mx-auto">
|
||||
{t('glossaries.emptyDesc') || 'Créez votre premier glossaire pour garantir une terminologie cohérente dans vos traductions.'}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/glossaries/new"
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Créer mon premier glossaire
|
||||
</Link>
|
||||
</div>
|
||||
) : filteredGlossaries.length === 0 ? (
|
||||
<div className="p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl text-center">
|
||||
<p className="text-sm text-[#555555] dark:text-white/40 font-light">
|
||||
Aucun résultat pour « {searchQuery} »
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{filteredGlossaries.map((glossary: GlossaryListItem) => {
|
||||
const termCount = glossary.terms_count ?? 0;
|
||||
const srcInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language);
|
||||
const tgtInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language);
|
||||
const isMultilingual = glossary.target_language === 'multi';
|
||||
const matchesTarget = currentTargetLang && (isMultilingual || glossary.target_language === currentTargetLang);
|
||||
const mismatch = currentTargetLang && !isMultilingual && glossary.target_language && glossary.target_language !== currentTargetLang;
|
||||
|
||||
{/* Fichier & Manuel (1/3 de largeur) */}
|
||||
<div className="space-y-5">
|
||||
{/* Import Fichier */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-1.5 text-brand-accent">
|
||||
<Upload size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">{t('glossaries.dialog.tabFile') || 'Importer un fichier'}</span>
|
||||
</div>
|
||||
<FileUploadZone
|
||||
onTermsParsed={handleFileTermsImport}
|
||||
disabled={isProcessing}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Création Manuelle */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-1.5 text-brand-accent">
|
||||
<PenLine size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">{t('glossaries.dialog.tabManual') || 'Création manuelle'}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
disabled={isProcessing}
|
||||
className="editorial-card w-full flex items-center justify-between p-5 text-left transition-all hover:border-brand-accent/30 hover:bg-brand-muted/30 dark:hover:bg-white/[0.02] cursor-pointer group border-black/5 dark:border-white/5"
|
||||
return (
|
||||
<div
|
||||
key={glossary.id}
|
||||
className={cn(
|
||||
'bg-white dark:bg-[#141414] border rounded-2xl p-6 shadow-sm group transition-all relative flex flex-col justify-between min-h-[200px]',
|
||||
matchesTarget
|
||||
? 'border-brand-accent/50 ring-1 ring-brand-accent/20 hover:border-brand-accent/70'
|
||||
: mismatch
|
||||
? 'border-amber-300/50 dark:border-amber-500/20 hover:border-amber-400/70 opacity-80 hover:opacity-100'
|
||||
: 'border-[#E5E3DF] dark:border-white/5 hover:border-[#C5A17A]/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-brand-accent/10 dark:bg-brand-accent/15 flex items-center justify-center text-brand-accent shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Plus size={16} />
|
||||
{/* Badge compatible / autre langue */}
|
||||
{matchesTarget && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider border border-emerald-300 dark:border-emerald-500/20">
|
||||
<CheckCircle2 size={9} /> Compatible
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-serif font-bold text-brand-dark dark:text-white leading-tight">{t('glossaries.createNew') || 'Créer manuellement'}</p>
|
||||
<p className="text-[10px] text-brand-dark/40 dark:text-white/35 mt-1 font-light leading-tight">{t('glossaries.dialog.createEmpty') || 'À partir de zéro'}</p>
|
||||
)}
|
||||
{mismatch && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 bg-amber-100 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider border border-amber-300 dark:border-amber-500/20">
|
||||
<AlertCircle size={9} /> Autre langue
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Corps cliquable → page de détail */}
|
||||
<div className="cursor-pointer flex-1" onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-[#F0EDE8] dark:bg-white/10 rounded-xl flex items-center justify-center text-[#8B6F47] shrink-0">
|
||||
<Library size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<h3 className="text-sm font-serif font-semibold text-[#1A1A1A] dark:text-white tracking-tight leading-snug line-clamp-2 group-hover:text-[#8B6F47] transition-colors">
|
||||
{glossary.name}
|
||||
</h3>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/50 font-medium flex items-center gap-1 mt-0.5">
|
||||
<span>{srcInfo?.flag ?? '🌐'}</span>
|
||||
<span>{srcInfo?.label ?? glossary.source_language}</span>
|
||||
<span className="text-[#7A5C35] dark:text-brand-accent font-bold">→</span>
|
||||
<span>{tgtInfo?.flag ?? '🌐'}</span>
|
||||
<span>{tgtInfo?.label ?? glossary.target_language}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pb-4">
|
||||
<span className="flex items-center gap-1 text-xs font-semibold text-[#333333] dark:text-white/65">
|
||||
<Hash size={12} className="text-[#8B6F47] dark:text-brand-accent" />
|
||||
{termCount} terme{termCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono text-[9px] text-[#666666] dark:text-white/40">
|
||||
<Calendar size={11} />
|
||||
{new Date(glossary.created_at).toLocaleDateString('fr-FR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="size-4 text-brand-dark/30 dark:text-white/20 group-hover:text-brand-accent group-hover:translate-x-1 transition-all" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4 border-t border-[#F0EDE8] dark:border-white/10 mt-auto shrink-0">
|
||||
<Link
|
||||
href={`/dashboard/glossaries/${glossary.id}`}
|
||||
className="flex-none px-3 py-2 rounded-lg bg-[#EBEBEB] dark:bg-white/5 hover:bg-[#E0D9D1] text-[#333333] dark:text-white/50 hover:text-[#1A1A1A] text-[10px] font-bold uppercase tracking-wider transition-all border border-[#CCCCCC] dark:border-white/10"
|
||||
>
|
||||
Éditer
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/translate?glossaryId=${glossary.id}`}
|
||||
className="flex-1 text-center py-2 rounded-lg bg-[#1A1A1A] hover:bg-[#333333] text-white text-[10px] font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-1.5"
|
||||
>
|
||||
Utiliser <ArrowRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<CreateGlossaryDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onCreate={handleCreateGlossary}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTitle(title: string) {
|
||||
const lastSpaceIndex = title.lastIndexOf(' ');
|
||||
if (lastSpaceIndex === -1) return title;
|
||||
const firstPart = title.substring(0, lastSpaceIndex);
|
||||
const lastWord = title.substring(lastSpaceIndex + 1);
|
||||
return (
|
||||
<>
|
||||
{firstPart} <span className="italic">{lastWord}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
"glossaries.upgrade.proLabel": "Pro",
|
||||
"glossaries.upgrade.upgradeBtn": "Upgrade to Pro",
|
||||
"glossaries.loading": "Loading...",
|
||||
"glossaries.tabs.glossaries": "Terminology Glossaries",
|
||||
"glossaries.tabs.context": "Context Instructions (AI)",
|
||||
"glossaries.howItWorks.title": "How these settings are used",
|
||||
"glossaries.howItWorks.step1Title": "Configure here",
|
||||
"glossaries.howItWorks.step1Desc": "Write your context instructions or create/import a glossary of terms.",
|
||||
@@ -114,6 +116,8 @@
|
||||
"glossaries.presets.clickHint": "Click a card → glossary created → select it in Translate",
|
||||
"glossaries.presets.creating": "Creating...",
|
||||
"glossaries.presets.alreadyImported": "Imported",
|
||||
"glossaries.presets.importBtn": "Import",
|
||||
"glossaries.presets.allImported": "All professional templates have been imported.",
|
||||
"glossaries.presets.it.title": "IT / Software",
|
||||
"glossaries.presets.it.desc": "Development, infrastructure, DevOps",
|
||||
"glossaries.presets.legal.title": "Legal / Contracts",
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
"glossaries.upgrade.proLabel": "Pro",
|
||||
"glossaries.upgrade.upgradeBtn": "Passer à Pro",
|
||||
"glossaries.loading": "Chargement...",
|
||||
"glossaries.tabs.glossaries": "Glossaires terminologiques",
|
||||
"glossaries.tabs.context": "Consignes de contexte (IA)",
|
||||
"glossaries.howItWorks.title": "Comment ces paramètres sont utilisés",
|
||||
"glossaries.howItWorks.step1Title": "Configurez ici",
|
||||
"glossaries.howItWorks.step1Desc": "Rédigez vos instructions de contexte ou créez/importez un glossaire de termes.",
|
||||
@@ -114,6 +116,8 @@
|
||||
"glossaries.presets.clickHint": "Cliquez sur une carte → glossaire créé → sélectionnez-le dans Traduire",
|
||||
"glossaries.presets.creating": "Création…",
|
||||
"glossaries.presets.alreadyImported": "Déjà importé",
|
||||
"glossaries.presets.importBtn": "Importer",
|
||||
"glossaries.presets.allImported": "Tous les modèles professionnels ont été importés.",
|
||||
"glossaries.presets.it.title": "IT / Logiciel",
|
||||
"glossaries.presets.it.desc": "Développement, infrastructure, DevOps",
|
||||
"glossaries.presets.legal.title": "Juridique / Contrats",
|
||||
|
||||
103
scripts/migrate_glossaries_to_multilingual_by_translation.py
Normal file
103
scripts/migrate_glossaries_to_multilingual_by_translation.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migrate and translate all non-multilingual glossaries in the database to multilingual glossaries.
|
||||
It uses the free Google Translate provider to fill in the translations for target languages
|
||||
(de, es, it, pt, nl, ru, ja, ko, zh, ar, fa, en) in the glossary_terms table.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from database.connection import get_sync_session
|
||||
from database.models import Glossary, GlossaryTerm
|
||||
from services.providers.google_provider import get_legacy_google_adapter
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("translate_non_multilingual_glossaries")
|
||||
|
||||
TARGET_LANGUAGES = ["de", "es", "it", "pt", "nl", "ru", "ja", "ko", "zh", "ar", "fa"]
|
||||
|
||||
def translate_term(adapter, text_to_translate: str, src_lang: str, target_langs: list[str]) -> dict:
|
||||
translations = {}
|
||||
for lang in target_langs:
|
||||
try:
|
||||
translated = adapter.translate(text_to_translate, target_language=lang, source_language=src_lang)
|
||||
translations[lang] = translated.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to translate '{text_to_translate}' to {lang}: {e}")
|
||||
translations[lang] = ""
|
||||
return translations
|
||||
|
||||
def main():
|
||||
logger.info("Connecting to database and fetching all glossaries...")
|
||||
|
||||
adapter = get_legacy_google_adapter()
|
||||
|
||||
with get_sync_session() as session:
|
||||
# Find all glossaries
|
||||
all_glossaries = session.query(Glossary).all()
|
||||
|
||||
if not all_glossaries:
|
||||
logger.info("No glossaries found in database.")
|
||||
return 0
|
||||
|
||||
logger.info(f"Found {len(all_glossaries)} glossaries to process.")
|
||||
|
||||
for glossary in all_glossaries:
|
||||
logger.info(f"Processing glossary '{glossary.name}' (ID: {glossary.id})")
|
||||
|
||||
# Change target_language to 'multi' if not already
|
||||
if glossary.target_language != 'multi':
|
||||
glossary.target_language = 'multi'
|
||||
|
||||
# Rename the glossary to indicate it's now multilingual (e.g. replace "Anglais" with "Multilingue")
|
||||
if "Anglais" in glossary.name:
|
||||
glossary.name = glossary.name.replace("Anglais", "Multilingue")
|
||||
elif "English" in glossary.name:
|
||||
glossary.name = glossary.name.replace("English", "Multilingue")
|
||||
else:
|
||||
glossary.name = f"{glossary.name} → Multilingue"
|
||||
|
||||
src_lang = glossary.source_language or "fr"
|
||||
|
||||
terms = session.query(GlossaryTerm).filter(GlossaryTerm.glossary_id == glossary.id).all()
|
||||
logger.info(f"Checking translations for {len(terms)} terms in '{glossary.name}'...")
|
||||
|
||||
updated_terms_count = 0
|
||||
for idx, term in enumerate(terms):
|
||||
translations = term.translations or {}
|
||||
|
||||
# Check if we already have translations for the target languages
|
||||
missing_langs = [lang for lang in TARGET_LANGUAGES if lang not in translations or not translations[lang]]
|
||||
|
||||
if missing_langs:
|
||||
# Translate
|
||||
logger.info(f" [{idx+1}/{len(terms)}] Translating '{term.source}' to {missing_langs}...")
|
||||
new_translations = translate_term(adapter, term.source, src_lang, missing_langs)
|
||||
translations.update(new_translations)
|
||||
updated_terms_count += 1
|
||||
|
||||
# Ensure the original default target (e.g. English translation) is in the translations dict under 'en'
|
||||
if 'en' not in translations or not translations['en']:
|
||||
translations['en'] = term.target
|
||||
|
||||
term.translations = translations
|
||||
|
||||
if updated_terms_count > 0:
|
||||
session.commit()
|
||||
logger.info(f"Glossary '{glossary.name}' updated with {updated_terms_count} translated terms.")
|
||||
else:
|
||||
logger.info(f"No translations were missing for glossary '{glossary.name}'.")
|
||||
|
||||
logger.info("Migration complete!")
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -5,6 +5,18 @@
|
||||
"source": "vercel-labs/agent-browser",
|
||||
"sourceType": "github",
|
||||
"computedHash": "0eafe0971692cf8fcecb75f955595821452a835791d315db738f89ee49063a34"
|
||||
},
|
||||
"find-skills": {
|
||||
"source": "vercel-labs/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/find-skills/SKILL.md",
|
||||
"computedHash": "781bd6d3f9b19f8c9af6b53d8d0e4876d0183841b565db34ca7092ffa412d111"
|
||||
},
|
||||
"writing-guidelines": {
|
||||
"source": "vercel-labs/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/writing-guidelines/SKILL.md",
|
||||
"computedHash": "25aa3a33a97bddbcb1847ce4a6169106ec25e5b19b15b99c66e75ab383866ef2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user