Compare commits
54 Commits
1d883c3c69
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| ebb2537fda | |||
| 36aeac2c5e | |||
| de9407f974 | |||
| 5e3fb0098b | |||
| a57b8a8e4d | |||
| dde80f6bc3 | |||
| 7398cae359 | |||
| 030950c962 | |||
| 5d6afd2dad | |||
| 07c50151cb | |||
| 299173fff7 | |||
| 9b354befe3 | |||
| 4a8f33d36f | |||
| 489df66c0f | |||
| 96bac2e792 | |||
| d78f08e24f | |||
| c17dd2c6e1 | |||
| 1fe714aa1a | |||
| d505b479cd | |||
| 81cb4e09b7 | |||
| a20ebe2295 | |||
| 233a054e34 | |||
| f85e5eef9b | |||
| b9446f166d | |||
| adc3583358 | |||
| cb8ce697d2 | |||
| f05399aeba | |||
| 45e44dd7b2 | |||
| fa637abff0 | |||
| eda6821632 | |||
| 9b0b2ae6f9 | |||
| c7506e6aca | |||
| 136d40c7d8 | |||
| 5fd087979b | |||
| feea02033b | |||
| 5b8c29dae6 | |||
| 29753881a6 | |||
| 8659b6761f | |||
| fa19f33ab0 | |||
| 9bb02927c3 | |||
| ddf6b8f6bc | |||
| 3f980ad537 | |||
|
|
fb6740f333 | ||
|
|
79848230c0 | ||
|
|
e11a6b16a0 | ||
| 80b49ee354 | |||
| c7299228cd | |||
|
|
02a4a7ded8 | ||
| 670d3f4376 | |||
|
|
e497f2d218 | ||
| 23373ab91a | |||
| 9104f6123d | |||
| e7b5ea9a61 | |||
| 8805044bb6 |
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.
|
||||||
@@ -183,6 +183,11 @@ STRIPE_PRICE_BUSINESS_MONTHLY=price_xxx
|
|||||||
STRIPE_PRICE_BUSINESS_YEARLY=price_xxx
|
STRIPE_PRICE_BUSINESS_YEARLY=price_xxx
|
||||||
|
|
||||||
# ============== Monitoring ==============
|
# ============== Monitoring ==============
|
||||||
|
# Optional. Default: admin. Grafana admin username.
|
||||||
|
GRAFANA_USER=admin
|
||||||
|
# Required when Grafana is enabled. If absent: Grafana may fail to start. Generate with: secrets.token_urlsafe(32)
|
||||||
|
GRAFANA_PASSWORD=
|
||||||
|
|
||||||
# Optional. Default: INFO. Log format: json or console (default from ENV/LOG_FORMAT).
|
# Optional. Default: INFO. Log format: json or console (default from ENV/LOG_FORMAT).
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
LOG_FORMAT=json
|
LOG_FORMAT=json
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -74,3 +74,18 @@ uv.lock
|
|||||||
# Screenshots and debug files at root
|
# Screenshots and debug files at root
|
||||||
*.png
|
*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
|
|
||||||
|
# SSL Keys & Certificates
|
||||||
|
*.key
|
||||||
|
*.cer
|
||||||
|
*.crt
|
||||||
|
*.pem
|
||||||
|
*_ssl_certificate*.zip
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Auto-generated pnpm workspace file (placeholders)
|
||||||
|
frontend/pnpm-workspace.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@
|
|||||||
│
|
│
|
||||||
┌─────────────┼─────────────┐
|
┌─────────────┼─────────────┐
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
Google DeepL LibreTranslate
|
Google DeepL Cloud LLMs
|
||||||
Translate (API Key) (Self-hosted)
|
Translate (API Key) (OpenAI, DeepSeek...)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Component Breakdown
|
## Component Breakdown
|
||||||
@@ -126,10 +126,10 @@ Output: Translated .pptx with identical design
|
|||||||
- Better context understanding
|
- Better context understanding
|
||||||
- Requires paid API key
|
- Requires paid API key
|
||||||
|
|
||||||
3. **LibreTranslate** (Self-hosted)
|
3. **Cloud LLMs** (OpenAI, DeepSeek, OpenRouter, Minimax, Zai)
|
||||||
- Open-source alternative
|
- Custom translation prompts support (glossary, style preservation)
|
||||||
- Full control and privacy
|
- Contextual understanding and formatting preservation
|
||||||
- Requires local installation
|
- Requires respective API keys
|
||||||
|
|
||||||
### 4. Utility Layer
|
### 4. Utility Layer
|
||||||
|
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
# Guide de Deploiement - Wordly.art (Homelab)
|
|
||||||
> NPM + IONOS + Docker + Stripe + API Keys + NAS Backup + Monitoring
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Internet
|
|
||||||
|
|
|
||||||
| DNS IONOS: wordly.art -> IP fixe
|
|
||||||
|
|
|
||||||
[Routeur/Box] (port forwarding 80+443 -> machine NPM)
|
|
||||||
|
|
|
||||||
+-- Machine 1: Nginx Proxy Manager (NPM)
|
|
||||||
| - SSL Let's Encrypt auto
|
|
||||||
| - Reverse proxy
|
|
||||||
|
|
|
||||||
+-- Machine 2/3: Docker (192.168.1.151)
|
|
||||||
|
|
|
||||||
+-- wordly-backend (FastAPI :8000)
|
|
||||||
+-- wordly-frontend (Next.js :3000)
|
|
||||||
+-- wordly-postgres (PostgreSQL :5432)
|
|
||||||
+-- wordly-redis (Redis :6379)
|
|
||||||
+-- wordly-prometheus (:9090 interne)
|
|
||||||
+-- wordly-grafana (:3001)
|
|
||||||
+-- wordly-node-exporter + cadvisor
|
|
||||||
|
|
|
||||||
+-- Backup quotidien -> NAS
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Etape 1 : DNS IONOS
|
|
||||||
|
|
||||||
1. Se connecter sur **ionos.fr** > **Domaines & SSL** > **wordly.art** > **DNS**
|
|
||||||
2. Modifier les enregistrements **A** existants :
|
|
||||||
|
|
||||||
| Type | Nom (hote) | Valeur | Attention |
|
|
||||||
|------|-----------|--------|-----------|
|
|
||||||
| **A** | *(vide ou @)* | Ton IP fixe | Pas dans "Sous-domaines", dans DNS principal |
|
|
||||||
| **A** | `www` | Ton IP fixe | Dans Sous-domaines, taper juste `www` |
|
|
||||||
| **A** | `monitoring` | Ton IP fixe | Dans Sous-domaines, taper juste `monitoring` |
|
|
||||||
|
|
||||||
> Ton IP fixe : ouvre https://api.ipify.org depuis ton reseau
|
|
||||||
|
|
||||||
Ne **pas** toucher aux lignes Mail (MX, SPF, DKIM, autodiscover).
|
|
||||||
|
|
||||||
Verifier apres 10 min :
|
|
||||||
```bash
|
|
||||||
nslookup wordly.art
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Etape 2 : Port forwarding + NPM
|
|
||||||
|
|
||||||
### 2.1 Routeur : ouvrir les ports
|
|
||||||
|
|
||||||
Sur ton routeur/box (souvent `192.168.1.1`) :
|
|
||||||
|
|
||||||
| Port externe | Port interne | IP destination |
|
|
||||||
|-------------|-------------|---------------|
|
|
||||||
| **80** | 80 | IP de la machine NPM |
|
|
||||||
| **443** | 443 | IP de la machine NPM |
|
|
||||||
|
|
||||||
### 2.2 NPM : creer les Proxy Hosts
|
|
||||||
|
|
||||||
Interface NPM : **http://IP_NPM:81**
|
|
||||||
|
|
||||||
#### Proxy Host 1 : wordly.art
|
|
||||||
|
|
||||||
**Details :**
|
|
||||||
- Domain Names : `wordly.art`
|
|
||||||
- Scheme : `http`
|
|
||||||
- Forward Hostname/IP : `192.168.1.151` (IP du serveur Wordly)
|
|
||||||
- Forward Port : `3000`
|
|
||||||
- Cocher Block Common Exploits + Websockets
|
|
||||||
|
|
||||||
**SSL :**
|
|
||||||
- Request a new SSL Certificate
|
|
||||||
- Cocher Force SSL + HTTP/2 + HSTS
|
|
||||||
- Email : `admin@wordly.art`
|
|
||||||
|
|
||||||
**Advanced** - coller cette config nginx :
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://192.168.1.151:8000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_buffer_size 128k;
|
|
||||||
proxy_buffers 4 256k;
|
|
||||||
proxy_busy_buffers_size 256k;
|
|
||||||
add_header Access-Control-Allow-Origin https://wordly.art always;
|
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
|
||||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-API-Key" always;
|
|
||||||
add_header Access-Control-Allow-Credentials "true" always;
|
|
||||||
if ($request_method = 'OPTIONS') { return 204; }
|
|
||||||
}
|
|
||||||
|
|
||||||
location /translate {
|
|
||||||
proxy_pass http://192.168.1.151:8000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 600s;
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
client_max_body_size 100M;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /health {
|
|
||||||
proxy_pass http://192.168.1.151:8000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
}
|
|
||||||
|
|
||||||
location /docs { proxy_pass http://192.168.1.151:8000; proxy_set_header Host $host; }
|
|
||||||
location /redoc { proxy_pass http://192.168.1.151:8000; proxy_set_header Host $host; }
|
|
||||||
location /openapi.json { proxy_pass http://192.168.1.151:8000; proxy_set_header Host $host; }
|
|
||||||
location /admin { proxy_pass http://192.168.1.151:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
|
|
||||||
location /_next/static/ { proxy_pass http://192.168.1.151:3000; add_header Cache-Control "public, max-age=31536000, immutable"; }
|
|
||||||
```
|
|
||||||
|
|
||||||
> Si NPM et Wordly sont sur la meme machine Docker, remplace `192.168.1.151:8000` par `wordly-backend:8000` etc.
|
|
||||||
|
|
||||||
#### Proxy Host 2 : www.wordly.art
|
|
||||||
|
|
||||||
Meme config, meme certificat SSL. Dans Advanced, ajoute :
|
|
||||||
```nginx
|
|
||||||
return 301 https://wordly.art$request_uri;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Proxy Host 3 : monitoring.wordly.art
|
|
||||||
|
|
||||||
- Forward : `192.168.1.151:3001`
|
|
||||||
- SSL Let's Encrypt
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Etape 3 : Serveur Docker (192.168.1.151)
|
|
||||||
|
|
||||||
### 3.1 Installer Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
sudo usermod -aG docker $USER
|
|
||||||
newgrp docker
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Cloner le projet
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone -b production-deployment https://gitea.parsanet.org/sepehr/office_translator.git /opt/wordly
|
|
||||||
cd /opt/wordly
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 Configurer le .env avec le wizard
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/setup-env.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Le wizard demande le domaine, le mot de passe admin, le service de traduction, et genere tous les secrets automatiquement.
|
|
||||||
|
|
||||||
Ou configurer manuellement :
|
|
||||||
```bash
|
|
||||||
cp .env.production .env
|
|
||||||
nano .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 Lancer l'application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
docker compose ps # Verifier que tout est "Up (healthy)"
|
|
||||||
curl http://localhost:8000/health
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Etape 4 : Cles API - Services de traduction
|
|
||||||
|
|
||||||
L'application supporte **7 providers de traduction**. Tu n'es pas oblige de tous les configurer - commence avec 1 ou 2.
|
|
||||||
|
|
||||||
### Gerer les cles
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/wordly
|
|
||||||
bash scripts/manage-keys.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Menu interactif pour ajouter/modifier/supprimer des cles API.
|
|
||||||
|
|
||||||
### Ou trouver les cles
|
|
||||||
|
|
||||||
| Provider | URL d'inscription | Cle a recuperer | Cout |
|
|
||||||
|----------|-------------------|-----------------|------|
|
|
||||||
| **Google** | Aucune inscription | Aucune cle (gratuit via deep_translator) | Gratuit |
|
|
||||||
| **DeepL** | https://www.deepl.com/pro-api | Clé API DeepL (format: `xxx-xxx-...`) | Gratuit 500k car/mois, puis 5.49EUR/mois |
|
|
||||||
| **OpenAI** | https://platform.openai.com/api-keys | `sk-...` | ~0.15$/1000 traductions |
|
|
||||||
| **DeepSeek** | https://platform.deepseek.com/api_keys | `sk-...` | ~0.14$/M tokens (tres bon rapport Q/P) |
|
|
||||||
| **Minimax** | https://platform.minimaxi.com/ | Cle API | Variable |
|
|
||||||
| **OpenRouter** | https://openrouter.ai/keys | `sk-or-...` | Multi-modeles, pay-per-use |
|
|
||||||
|
|
||||||
### Activer un provider dans le .env
|
|
||||||
|
|
||||||
Exemple pour DeepSeek :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Dans le .env
|
|
||||||
DEEPSEEK_ENABLED=true
|
|
||||||
DEEPSEEK_API_KEY=sk-votre-cle-ici
|
|
||||||
DEEPSEEK_MODEL=deepseek-chat
|
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis `docker compose restart backend`.
|
|
||||||
|
|
||||||
### Recommandation pour demarrer
|
|
||||||
|
|
||||||
1. **Google** (active par defaut, gratuit, aucune cle)
|
|
||||||
2. **DeepSeek** (ajouter quand tu veux meilleure qualite, tres peu cher)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Etape 5 : Stripe - Systeme de paiement
|
|
||||||
|
|
||||||
Stripe gere les abonnements (Starter, Pro, Business). Si tu ne configures pas Stripe, l'app fonctionne en mode gratuit sans limitation d'abonnement.
|
|
||||||
|
|
||||||
### 5.1 Creer un compte Stripe
|
|
||||||
|
|
||||||
1. Aller sur **https://dashboard.stripe.com/register**
|
|
||||||
2. Creer un compte avec ton email
|
|
||||||
3. Activer le compte (verifie identite + banque pour recevoir les paiements)
|
|
||||||
|
|
||||||
### 5.2 Recuperer les cles Stripe
|
|
||||||
|
|
||||||
Dans le dashboard Stripe : **Developers** > **API keys**
|
|
||||||
|
|
||||||
| Cle | Nom dans .env | Description |
|
|
||||||
|-----|---------------|-------------|
|
|
||||||
| `sk_test_...` ou `sk_live_...` | `STRIPE_SECRET_KEY` | Cle secrete (attention: NE PAS utiliser la cle publique `pk_...`) |
|
|
||||||
| `whsec_...` | `STRIPE_WEBHOOK_SECRET` | Cle du webhook (voir section 5.4) |
|
|
||||||
|
|
||||||
> **Test vs Live** : commence en mode test (`sk_test_...`), passe en live quand tout fonctionne.
|
|
||||||
|
|
||||||
### 5.3 Creer les produits et forfaits
|
|
||||||
|
|
||||||
Dans Stripe : **Products** > **Add product**
|
|
||||||
|
|
||||||
Cree 3 produits :
|
|
||||||
|
|
||||||
**Produit 1 : Starter**
|
|
||||||
- Name : `Starter`
|
|
||||||
- Price : `9.00 EUR` / Monthly (recurring)
|
|
||||||
- Une fois cree, clique sur le prix et copie le **Price ID** (`price_...`)
|
|
||||||
|
|
||||||
**Produit 2 : Pro**
|
|
||||||
- Name : `Pro`
|
|
||||||
- Price : `19.00 EUR` / Monthly
|
|
||||||
|
|
||||||
**Produit 3 : Business**
|
|
||||||
- Name : `Business`
|
|
||||||
- Price : `49.00 EUR` / Monthly
|
|
||||||
|
|
||||||
Copie les 3 Price IDs.
|
|
||||||
|
|
||||||
### 5.4 Configurer le Webhook Stripe
|
|
||||||
|
|
||||||
Le webhook permet a Stripe de notifier ton app quand un paiement reussit, un abonnement est annule, etc.
|
|
||||||
|
|
||||||
Dans Stripe : **Developers** > **Webhooks** > **Add endpoint**
|
|
||||||
|
|
||||||
- **Endpoint URL** : `https://wordly.art/api/v1/stripe/webhook`
|
|
||||||
- **Events a ecouter** (cliquer "Select events") :
|
|
||||||
- `checkout.session.completed`
|
|
||||||
- `customer.subscription.updated`
|
|
||||||
- `customer.subscription.deleted`
|
|
||||||
- `invoice.payment_succeeded`
|
|
||||||
- `invoice.payment_failed`
|
|
||||||
|
|
||||||
Une fois le webhook cree, clique dessus et copie le **Signing secret** (`whsec_...`).
|
|
||||||
|
|
||||||
### 5.5 Ajouter les cles Stripe dans le .env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/wordly
|
|
||||||
bash scripts/manage-keys.sh
|
|
||||||
# Choisir option 4 (Stripe)
|
|
||||||
```
|
|
||||||
|
|
||||||
Ou manuellement dans `.env` :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
STRIPE_SECRET_KEY=sk_live_votre_cle_secrete
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_votre_signing_secret
|
|
||||||
STRIPE_STARTER_PRICE_ID=price_votre_price_id_starter
|
|
||||||
STRIPE_PRO_PRICE_ID=price_votre_price_id_pro
|
|
||||||
STRIPE_BUSINESS_PRICE_ID=price_votre_price_id_business
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis `docker compose restart backend`.
|
|
||||||
|
|
||||||
### 5.6 Tester Stripe en mode test
|
|
||||||
|
|
||||||
1. Utilise `sk_test_...` comme STRIPE_SECRET_KEY
|
|
||||||
2. Cree des produits en mode test (meme process)
|
|
||||||
3. Pour tester un paiement, Stripe fournit des cartes test :
|
|
||||||
- **Succes** : `4242 4242 4242 4242`
|
|
||||||
- **Echec** : `4000 0000 0000 0002`
|
|
||||||
- Exp : n'importe quelle date future
|
|
||||||
- CVC : n'importe quel nombre
|
|
||||||
4. Verifie dans Stripe Dashboard > Payments que les paiements apparaissent
|
|
||||||
|
|
||||||
### 5.7 Passer en production
|
|
||||||
|
|
||||||
Quand tout fonctionne en test :
|
|
||||||
|
|
||||||
1. Dans Stripe Dashboard, clique **"Activate your account"** en haut a gauche
|
|
||||||
2. Remplie les infos business (identite, banque)
|
|
||||||
3. Change dans le .env : `sk_test_...` -> `sk_live_...`
|
|
||||||
4. Refais les webhooks et produits en mode live
|
|
||||||
5. `docker compose restart backend`
|
|
||||||
|
|
||||||
### 5.8 Checklist Stripe
|
|
||||||
|
|
||||||
- [ ] Compte Stripe cree et verifie
|
|
||||||
- [ ] 3 produits crees (Starter 9EUR, Pro 19EUR, Business 49EUR)
|
|
||||||
- [ ] 3 Price IDs copies
|
|
||||||
- [ ] STRIPE_SECRET_KEY configure
|
|
||||||
- [ ] Webhook configure avec les 5 events
|
|
||||||
- [ ] STRIPE_WEBHOOK_SECRET configure
|
|
||||||
- [ ] 3 STRIPE_*_PRICE_ID configures
|
|
||||||
- [ ] Backend redemarre
|
|
||||||
- [ ] Test avec carte 4242 reussi
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Etape 6 : Backup automatique vers NAS
|
|
||||||
|
|
||||||
### 6.1 Monter le NAS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install cifs-utils
|
|
||||||
sudo mkdir -p /mnt/nas-backups/wordly
|
|
||||||
|
|
||||||
sudo tee /etc/nas-credentials <<EOF
|
|
||||||
username=wordly-backup
|
|
||||||
password=MOT_DE_PASSE_NAS
|
|
||||||
domain=WORKGROUP
|
|
||||||
EOF
|
|
||||||
sudo chmod 600 /etc/nas-credentials
|
|
||||||
|
|
||||||
echo "//IP_DU_NAS/wordly-backups /mnt/nas-backups/wordly cifs credentials=/etc/nas-credentials,uid=$(id -u),gid=$(id -g),iocharset=utf8,vers=3.0,noperm 0 0" | sudo tee -a /etc/fstab
|
|
||||||
sudo mount /mnt/nas-backups/wordly
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Tester et programmer
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/wordly
|
|
||||||
bash scripts/backup-to-nas.sh --full
|
|
||||||
ls -lh /mnt/nas-backups/wordly/daily/
|
|
||||||
|
|
||||||
# Programmer le cron quotidien a 3h
|
|
||||||
crontab -e
|
|
||||||
# Ajouter :
|
|
||||||
0 3 * * * /opt/wordly/scripts/backup-to-nas.sh >> /var/log/wordly-backup.log 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Etape 7 : Monitoring (Prometheus + Grafana)
|
|
||||||
|
|
||||||
### 7.1 Lancer
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/wordly
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Acceder
|
|
||||||
|
|
||||||
- **URL** : `https://monitoring.wordly.art`
|
|
||||||
- **Login** : `admin` / mot de passe defini dans le .env
|
|
||||||
|
|
||||||
### 7.3 Dashboards
|
|
||||||
|
|
||||||
| Dashboard | Contenu |
|
|
||||||
|-----------|---------|
|
|
||||||
| Wordly - Application | Traductions, latence, providers, taux d'erreur |
|
|
||||||
| Wordly - Infrastructure | CPU, RAM, disque, reseau, status containers |
|
|
||||||
|
|
||||||
Alertes pre-configures : backend down, erreur > 10%, RAM > 90%, disque < 15%.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Etape 8 : Gitea Actions - Deploiement automatique
|
|
||||||
|
|
||||||
### 8.1 Installer le runner sur 192.168.1.151
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /opt/gitea-runner && cd /opt/gitea-runner
|
|
||||||
wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64
|
|
||||||
chmod +x act_runner-linux-amd64 && mv act_runner-linux-amd64 act_runner
|
|
||||||
./act_runner generate-config > config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 Enregistrer le runner
|
|
||||||
|
|
||||||
1. Gitea > office_translator > Settings > Actions > Runners > Create new Runner
|
|
||||||
2. Copier le token
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/gitea-runner
|
|
||||||
./act_runner register \
|
|
||||||
--instance https://gitea.parsanet.org \
|
|
||||||
--token LE_TOKEN \
|
|
||||||
--name homelab-runner \
|
|
||||||
--labels self-hosted
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 Service systemd
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo tee /etc/systemd/system/gitea-runner.service <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Gitea Act Runner
|
|
||||||
After=docker.service
|
|
||||||
Requires=docker.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=/opt/gitea-runner
|
|
||||||
ExecStart=/opt/gitea-runner/act_runner daemon --config config.yaml
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now gitea-runner
|
|
||||||
sudo systemctl status gitea-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
Desormais, chaque `git push` sur `production-deployment` deploye automatiquement.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Etape 9 : Operations courantes
|
|
||||||
|
|
||||||
| Action | Commande |
|
|
||||||
|--------|----------|
|
|
||||||
| Voir les logs | `docker compose logs -f --tail=100` |
|
|
||||||
| Redemarrer backend | `docker compose restart backend` |
|
|
||||||
| Mettre a jour | `cd /opt/wordly && git pull && docker compose up -d --build` |
|
|
||||||
| Restaurer backup | `bash scripts/backup-to-nas.sh --restore` |
|
|
||||||
| Gerer les cles API | `bash scripts/manage-keys.sh` |
|
|
||||||
| Changer mot de passe admin | `bash scripts/manage-keys.sh` > option 5 |
|
|
||||||
| Espace disque | `df -h && docker system df` |
|
|
||||||
| SSL | NPM gere le renouvellement auto |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Checklist complete
|
|
||||||
|
|
||||||
### DNS IONOS
|
|
||||||
- [ ] A record `@` -> IP fixe
|
|
||||||
- [ ] A record `www` -> IP fixe
|
|
||||||
- [ ] A record `monitoring` -> IP fixe
|
|
||||||
- [ ] Propagation verifiee
|
|
||||||
|
|
||||||
### Routeur + NPM
|
|
||||||
- [ ] Ports 80+443 ouverts vers machine NPM
|
|
||||||
- [ ] Proxy Host wordly.art avec custom config
|
|
||||||
- [ ] SSL Let's Encrypt obtenu
|
|
||||||
- [ ] Proxy Host monitoring.wordly.art
|
|
||||||
|
|
||||||
### Serveur Docker
|
|
||||||
- [ ] Docker installe
|
|
||||||
- [ ] Code clone dans /opt/wordly
|
|
||||||
- [ ] `bash scripts/setup-env.sh` executed
|
|
||||||
- [ ] `docker compose up -d --build` reussi
|
|
||||||
- [ ] Tous les containers healthy
|
|
||||||
|
|
||||||
### Cles API (minimum 1 provider)
|
|
||||||
- [ ] Provider de traduction configure et teste
|
|
||||||
- [ ] `bash scripts/manage-keys.sh` pour verifier
|
|
||||||
|
|
||||||
### Stripe (optionnel)
|
|
||||||
- [ ] Compte Stripe cree et active
|
|
||||||
- [ ] 3 produits + Price IDs crees
|
|
||||||
- [ ] Webhook configure (5 events)
|
|
||||||
- [ ] Cles Stripe dans le .env
|
|
||||||
- [ ] Test avec carte 4242 reussi
|
|
||||||
|
|
||||||
### Backup NAS
|
|
||||||
- [ ] NAS monte sur /mnt/nas-backups/wordly
|
|
||||||
- [ ] Backup manuel OK
|
|
||||||
- [ ] Cron programme a 3h
|
|
||||||
|
|
||||||
### Monitoring
|
|
||||||
- [ ] `docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d`
|
|
||||||
- [ ] Grafana accessible sur monitoring.wordly.art
|
|
||||||
|
|
||||||
### CI/CD
|
|
||||||
- [ ] Gitea runner installe et enregistre
|
|
||||||
- [ ] Service systemd active
|
|
||||||
|
|
||||||
### Verification finale
|
|
||||||
- [ ] https://wordly.art accessible depuis Internet
|
|
||||||
- [ ] HTTPS OK (cadenas vert)
|
|
||||||
- [ ] Inscription utilisateur OK
|
|
||||||
- [ ] Upload + traduction OK
|
|
||||||
- [ ] Page admin /admin OK
|
|
||||||
- [ ] Monitoring OK
|
|
||||||
319
DISASTER_RECOVERY.md
Normal file
319
DISASTER_RECOVERY.md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# Disaster Recovery — Wordly.art
|
||||||
|
## Guide opérationnel complet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Internet ]
|
||||||
|
│
|
||||||
|
▼ (80/443)
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ NPM dédié : 192.168.1.184 │ ← STABLE (ne tombe pas)
|
||||||
|
│ Interface admin : :81 │
|
||||||
|
└────────────┬───────────────────────┘
|
||||||
|
│ Forward Hostname → IP du serveur actif
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Serveur APP : 192.168.1.151 │ ← PEUT CRASHER
|
||||||
|
│ Docker: postgres, redis, │
|
||||||
|
│ backend:8001, frontend:3000│
|
||||||
|
└────────────┬───────────────────────┘
|
||||||
|
│ rsync SSH toutes les 6h (cron)
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────┐
|
||||||
|
│ NAS Synology : 192.168.1.146 │ ← SOURCE DE VÉRITÉ
|
||||||
|
│ Chemin réel : /volume1/backups/wordly │
|
||||||
|
│ Accès : SSH key (wordly-backup@nas) │
|
||||||
|
│ Pas de montage CIFS — rsync direct │
|
||||||
|
└────────────┬───────────────────────────────────┘
|
||||||
|
│ (en cas de crash de .151)
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Serveur SECOURS : 192.168.1.98 │ ← Docker déjà installé
|
||||||
|
│ Restaure via rsync SSH depuis NAS│
|
||||||
|
│ → NPM redirigé automatiquement │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi rsync SSH et pas CIFS/SMB ?**
|
||||||
|
- Pas de montage à gérer, pas de `/etc/fstab` à configurer
|
||||||
|
- Fonctionne même si le NAS redémarre (pas de montage stale)
|
||||||
|
- Chemin exact `/volume1/backups/wordly` utilisable directement
|
||||||
|
- SSH chiffré, clé sans mot de passe pour l'automatisation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RPO / RTO
|
||||||
|
|
||||||
|
| Scénario | Données perdues max | Temps de remise en route | Procédure |
|
||||||
|
|----------|--------------------|--------------------------|-|
|
||||||
|
| Container crashe | 0 | ~30s | Autorestart Docker |
|
||||||
|
| Process PostgreSQL crashe | 0–5s | ~1 min | Autorestart + WAL |
|
||||||
|
| Corruption DB partielle | 0–6h | ~5 min | Restore depuis NAS |
|
||||||
|
| Serveur .151 mort | 0–6h | **~25 min** | Restore NAS sur .98 + NPM auto |
|
||||||
|
| Erreur humaine (DROP) | 0–6h | ~5 min | Restore snapshot précédent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce qui est sauvegardé
|
||||||
|
|
||||||
|
| Composant | Sauvegardé | Fréquence |
|
||||||
|
|-----------|-----------|-----------|
|
||||||
|
| PostgreSQL `pg_dump` | ✅ | Toutes les 6h |
|
||||||
|
| `.env` (secrets, clés API, Stripe...) | ✅ | Dans chaque archive DR |
|
||||||
|
| `docker-compose.yml` | ✅ | Dans chaque archive DR |
|
||||||
|
| Dossier `docker/` (configs) | ✅ | Dans chaque archive DR |
|
||||||
|
| Redis | ❌ | Cache — sessions perdues à la restore (reconnexion users) |
|
||||||
|
| Config NPM | ❌ | NPM sur .184 (stable). Seul Forward Host change via API. |
|
||||||
|
| Métriques Prometheus | ❌ | Non critique, repart de zéro |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. SETUP INITIAL (une seule fois sur .151)
|
||||||
|
|
||||||
|
### Étape 1 : Créer le compte sur le NAS Synology
|
||||||
|
|
||||||
|
**Connectez-vous à l'interface DSM : `http://192.168.1.146:5000`**
|
||||||
|
|
||||||
|
#### 1a. Créer l'utilisateur dédié
|
||||||
|
|
||||||
|
```
|
||||||
|
DSM → Panneau de configuration → Utilisateurs et groupes → Créer
|
||||||
|
Nom d'utilisateur : wordly-backup
|
||||||
|
Mot de passe : [choisissez un mot de passe fort]
|
||||||
|
☑ L'utilisateur ne peut pas changer son mot de passe
|
||||||
|
→ Suivant
|
||||||
|
|
||||||
|
Permissions sur les dossiers partagés :
|
||||||
|
backups → ☑ Lecture/Écriture
|
||||||
|
→ Suivant → Terminer
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1b. Activer SSH sur le NAS
|
||||||
|
|
||||||
|
```
|
||||||
|
DSM → Panneau de configuration → Terminal et SNMP
|
||||||
|
☑ Activer le service SSH
|
||||||
|
Port : 22 (ou autre si vous avez changé)
|
||||||
|
→ Appliquer
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1c. Créer le dossier wordly sur le NAS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Depuis votre poste (ou n'importe quelle machine sur le réseau) :
|
||||||
|
ssh admin@192.168.1.146
|
||||||
|
mkdir -p /volume1/backups/wordly/snapshots
|
||||||
|
mkdir -p /volume1/backups/wordly/scripts
|
||||||
|
chown -R wordly-backup:users /volume1/backups/wordly
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Étape 2 : Configurer les variables dans `.env` sur `.151`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── NAS SSH ───────────────────────────────────
|
||||||
|
NAS_HOST=192.168.1.146
|
||||||
|
NAS_USER=wordly-backup
|
||||||
|
NAS_PATH=/volume1/backups/wordly
|
||||||
|
NAS_SSH_PORT=22
|
||||||
|
NAS_SSH_KEY=/root/.ssh/wordly_nas_key
|
||||||
|
|
||||||
|
# ── Alertes Telegram ──────────────────────────
|
||||||
|
TELEGRAM_BOT_TOKEN= # Voir section "Créer un bot Telegram" ci-dessous
|
||||||
|
TELEGRAM_CHAT_ID= # Votre chat ID personnel
|
||||||
|
|
||||||
|
# ── NPM Failover API ──────────────────────────
|
||||||
|
NPM_API_URL=http://192.168.1.184:81/api
|
||||||
|
NPM_ADMIN_EMAIL=admin@wordly.art
|
||||||
|
NPM_ADMIN_PASSWORD=VotreMotDePasseNPM
|
||||||
|
NPM_PROXY_HOST_DOMAIN=wordly.art
|
||||||
|
|
||||||
|
# ── Rétention ────────────────────────────────
|
||||||
|
DAILY_RETENTION=7
|
||||||
|
WEEKLY_RETENTION=4
|
||||||
|
MONTHLY_RETENTION=6
|
||||||
|
DR_RETENTION_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Étape 3 : Créer un bot Telegram (5 minutes)
|
||||||
|
|
||||||
|
1. Ouvrir Telegram → chercher **@BotFather**
|
||||||
|
2. Envoyer `/newbot`
|
||||||
|
3. Nom : `Wordly Monitoring` / Username : `wordly_monitor_bot`
|
||||||
|
4. Copier le token → `TELEGRAM_BOT_TOKEN`
|
||||||
|
5. Envoyer un message à votre bot
|
||||||
|
6. Aller sur `https://api.telegram.org/bot<TOKEN>/getUpdates`
|
||||||
|
7. Copier le `chat.id` → `TELEGRAM_CHAT_ID`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Étape 4 : Configurer SSH sans mot de passe vers le NAS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sur le serveur .151 (en root)
|
||||||
|
sudo bash scripts/setup-nas.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce script :
|
||||||
|
- Génère une clé SSH dédiée : `/root/.ssh/wordly_nas_key`
|
||||||
|
- La copie sur le NAS (**mot de passe demandé une seule fois**)
|
||||||
|
- Teste la connexion sans mot de passe
|
||||||
|
- Crée la structure de dossiers sur le NAS
|
||||||
|
- Configure `~/.ssh/config` avec l'alias `wordly-nas`
|
||||||
|
- Copie les scripts sur le NAS (disponibles depuis `.98` pour la restauration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Étape 5 : Tester le premier backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup-to-nas.sh --full
|
||||||
|
|
||||||
|
# Vérifier que l'archive est bien arrivée sur le NAS
|
||||||
|
bash scripts/backup-to-nas.sh --list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Étape 6 : Tester la vérification automatique
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/verify-backups.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Étape 7 : Tester le failover NPM (sans rien changer)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/npm-failover.sh --dry-run --target-ip 192.168.1.98
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Étape 8 : Activer les crons
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/install-crontab.sh
|
||||||
|
crontab -l # Vérifier
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. VÉRIFICATION QUOTIDIENNE (automatique)
|
||||||
|
|
||||||
|
```
|
||||||
|
0 */6 * * * backup-to-nas.sh → Snapshot DB + archive → NAS via rsync SSH
|
||||||
|
30 */6 * * * verify-backups.sh → 8 vérifications + alerte Telegram si erreur
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. RESTAURATION D'URGENCE (quand .151 est mort)
|
||||||
|
|
||||||
|
> **Durée estimée : 20–25 minutes**
|
||||||
|
|
||||||
|
### Sur le serveur de secours `192.168.1.98`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installer les prérequis (Docker déjà installé)
|
||||||
|
apt-get install -y rsync jq
|
||||||
|
|
||||||
|
# 2. Récupérer la clé SSH depuis le NAS (ou depuis une autre source sécurisée)
|
||||||
|
# Option A : copier la clé depuis un endroit sûr (gestionnaire de mots de passe, etc.)
|
||||||
|
mkdir -p /root/.ssh && chmod 700 /root/.ssh
|
||||||
|
# collez le contenu de /root/.ssh/wordly_nas_key ici
|
||||||
|
nano /root/.ssh/wordly_nas_key
|
||||||
|
chmod 600 /root/.ssh/wordly_nas_key
|
||||||
|
|
||||||
|
# 3. Tester la connexion NAS
|
||||||
|
ssh -i /root/.ssh/wordly_nas_key wordly-backup@192.168.1.146 "echo OK"
|
||||||
|
|
||||||
|
# 4. Voir les archives disponibles
|
||||||
|
ssh -i /root/.ssh/wordly_nas_key wordly-backup@192.168.1.146 \
|
||||||
|
"ls -lht /volume1/backups/wordly/snapshots/ | head -10"
|
||||||
|
|
||||||
|
# 5. Télécharger la dernière archive depuis le NAS
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -i /root/.ssh/wordly_nas_key" \
|
||||||
|
wordly-backup@192.168.1.146:/volume1/backups/wordly/snapshots/wordly_dr_TIMESTAMP.tar.gz \
|
||||||
|
/tmp/
|
||||||
|
|
||||||
|
# 6. Télécharger les scripts de restauration depuis le NAS
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -i /root/.ssh/wordly_nas_key" \
|
||||||
|
wordly-backup@192.168.1.146:/volume1/backups/wordly/scripts/ \
|
||||||
|
/opt/wordly/scripts/
|
||||||
|
|
||||||
|
# 7. Lancer la restauration complète
|
||||||
|
bash /opt/wordly/scripts/disaster-recovery.sh \
|
||||||
|
--restore /tmp/wordly_dr_TIMESTAMP.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
**Le script fait automatiquement :**
|
||||||
|
1. Extrait `.env`, `docker-compose.yml`, configs Docker
|
||||||
|
2. Lance tous les containers Docker
|
||||||
|
3. Attend que PostgreSQL soit healthy
|
||||||
|
4. Restaure le dump SQL
|
||||||
|
5. Health check sur `http://localhost:8001/health` (max 180s)
|
||||||
|
6. **Si OK → appelle NPM API → bascule le trafic vers `192.168.1.98`**
|
||||||
|
7. **Alerte Telegram : "✅ Wordly.art DR COMPLET"**
|
||||||
|
|
||||||
|
**Si NPM failover automatique échoue (dernier recours) :**
|
||||||
|
```
|
||||||
|
http://192.168.1.184:81 → Proxy Hosts → wordly.art → Edit
|
||||||
|
Forward Hostname : 192.168.1.98
|
||||||
|
→ Save
|
||||||
|
# Changement immédiat, 0 redémarrage nécessaire
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. CONSERVATION DE LA CLÉ SSH NAS
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> La clé `/root/.ssh/wordly_nas_key` est **critique** pour la restauration depuis `.98`.
|
||||||
|
> Conservez-la dans au minimum 2 endroits sécurisés :
|
||||||
|
> - Gestionnaire de mots de passe (Bitwarden, 1Password, etc.)
|
||||||
|
> - Coffre-fort KeePass chiffré sur un support physique
|
||||||
|
>
|
||||||
|
> Sans cette clé, vous ne pouvez pas accéder aux archives sur le NAS depuis `.98`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. SCRIPTS DE RÉFÉRENCE
|
||||||
|
|
||||||
|
| Script | Usage | Déclenchement |
|
||||||
|
|--------|-------|---------------|
|
||||||
|
| `setup-nas.sh` | Configure SSH → NAS, génère clé, copie scripts | **Once** (root requis) |
|
||||||
|
| `backup-to-nas.sh` | pg_dump + archive DR → NAS via rsync SSH | Cron toutes les 6h |
|
||||||
|
| `backup-to-nas.sh --list` | Lister les archives disponibles sur le NAS | Manuel |
|
||||||
|
| `verify-backups.sh` | 8 checks intégrité + Telegram | Cron toutes les 6h+30m |
|
||||||
|
| `disaster-recovery.sh --backup` | Archive DR → NAS | Inclus dans backup-to-nas |
|
||||||
|
| `disaster-recovery.sh --restore <archive>` | Restauration complète | **Urgence** |
|
||||||
|
| `npm-failover.sh --target-ip <IP>` | Bascule NPM vers une IP | Appelé automatiquement |
|
||||||
|
| `npm-failover.sh --dry-run --target-ip <IP>` | Test sans modifier NPM | Test initial |
|
||||||
|
| `install-crontab.sh` | Installe les crons | **Once** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. LOGS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs backup (sur .151)
|
||||||
|
tail -f /var/log/wordly-backup.log
|
||||||
|
|
||||||
|
# Logs vérification (sur .151)
|
||||||
|
tail -f /var/log/wordly-verify.log
|
||||||
|
|
||||||
|
# Logs Docker (sur le serveur actif)
|
||||||
|
docker compose logs -f backend
|
||||||
|
docker compose logs -f postgres
|
||||||
|
```
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
| Google Translate | Classique | Non (via deep_translator) |
|
| Google Translate | Classique | Non (via deep_translator) |
|
||||||
| DeepL | Classique | Oui (`DEEPL_API_KEY`) |
|
| DeepL | Classique | Oui (`DEEPL_API_KEY`) |
|
||||||
| OpenAI (GPT-4o-mini) | LLM | Oui (`OPENAI_API_KEY`) |
|
| OpenAI (GPT-4o-mini) | LLM | Oui (`OPENAI_API_KEY`) |
|
||||||
| Ollama (Llama3) | LLM local | Non (local) |
|
| DeepSeek / Minimax | LLM | Oui (`DEEPSEEK_API_KEY` / `MINIMAX_API_KEY`) |
|
||||||
| OpenRouter | LLM | Oui (`OPENROUTER_API_KEY`) |
|
| OpenRouter | LLM | Oui (`OPENROUTER_API_KEY`) |
|
||||||
|
|
||||||
Les fournisseurs sont utilisés via une **chaîne de fallback** configurable (si un échoue, le suivant prend le relais).
|
Les fournisseurs sont utilisés via une **chaîne de fallback** configurable (si un échoue, le suivant prend le relais).
|
||||||
@@ -165,21 +165,7 @@ Le frontend est accessible sur http://localhost:3000
|
|||||||
|
|
||||||
## Services optionnels en production
|
## Services optionnels en production
|
||||||
|
|
||||||
### Ollama (LLM local)
|
|
||||||
|
|
||||||
Pour utiliser un modèle LLM local (Ollama), ajouter le profil `with-ollama` :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --profile with-ollama up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Configurer dans `.env` :
|
|
||||||
|
|
||||||
```env
|
|
||||||
TRANSLATION_SERVICE=ollama
|
|
||||||
OLLAMA_BASE_URL=http://ollama:11434
|
|
||||||
OLLAMA_MODEL=llama3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitoring (Prometheus + Grafana)
|
### Monitoring (Prometheus + Grafana)
|
||||||
|
|
||||||
@@ -210,7 +196,7 @@ docker compose --profile with-monitoring up -d
|
|||||||
3. **Choisir la langue source** et la **langue cible**
|
3. **Choisir la langue source** et la **langue cible**
|
||||||
4. **Sélectionner le mode de traduction** :
|
4. **Sélectionner le mode de traduction** :
|
||||||
- **Classique** : Google Translate / DeepL (rapide, < 15 sec)
|
- **Classique** : Google Translate / DeepL (rapide, < 15 sec)
|
||||||
- **LLM** : Ollama / OpenAI (1-3 min, meilleure qualité contextuelle)
|
- **LLM** : OpenAI / DeepSeek / Minimax (1-3 min, meilleure qualité contextuelle)
|
||||||
5. Cliquer sur **Traduire**
|
5. Cliquer sur **Traduire**
|
||||||
6. Suivre la **barre de progression** en temps réel
|
6. Suivre la **barre de progression** en temps réel
|
||||||
7. **Télécharger** le fichier traduit une fois terminé
|
7. **Télécharger** le fichier traduit une fois terminé
|
||||||
@@ -295,7 +281,7 @@ Générer des clés API pour accéder au service programmatiquement :
|
|||||||
- Vérifier que le fournisseur de traduction est configuré (`TRANSLATION_SERVICE` dans `.env`)
|
- Vérifier que le fournisseur de traduction est configuré (`TRANSLATION_SERVICE` dans `.env`)
|
||||||
- Pour Google Translate : aucune clé nécessaire
|
- Pour Google Translate : aucune clé nécessaire
|
||||||
- Pour DeepL/OpenAI : vérifier que la clé API est valide
|
- Pour DeepL/OpenAI : vérifier que la clé API est valide
|
||||||
- Pour Ollama : vérifier que le service est lancé et que le modèle est téléchargé
|
- Pour DeepSeek/Minimax : vérifier que la clé API correspondante est configurée
|
||||||
|
|
||||||
### Logs
|
### Logs
|
||||||
|
|
||||||
@@ -338,7 +324,7 @@ office_translator/
|
|||||||
├── alembic/ # Migrations de base de données
|
├── alembic/ # Migrations de base de données
|
||||||
├── routes/ # Endpoints API
|
├── routes/ # Endpoints API
|
||||||
├── services/ # Logique métier
|
├── services/ # Logique métier
|
||||||
│ ├── providers/ # Fournisseurs de traduction (Google, DeepL, OpenAI, Ollama)
|
│ ├── providers/ # Fournisseurs de traduction (Google, DeepL, OpenAI, DeepSeek, etc.)
|
||||||
│ ├── translation_service.py
|
│ ├── translation_service.py
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── translators/ # Traducteurs de documents (Excel, Word, PowerPoint)
|
├── translators/ # Traducteurs de documents (Excel, Word, PowerPoint)
|
||||||
|
|||||||
@@ -11,9 +11,8 @@
|
|||||||
|
|
||||||
### Ce qui nous différencie
|
### Ce qui nous différencie
|
||||||
- **Préservation du format** : Contrairement à Google Translate ou DeepL qui détruisent les layouts complexes, notre moteur maintient la structure exacte
|
- **Préservation du format** : Contrairement à Google Translate ou DeepL qui détruisent les layouts complexes, notre moteur maintient la structure exacte
|
||||||
- **Multi-providers** : L'utilisateur choisit son moteur (Google, DeepL, OpenAI, Ollama/local) selon son budget et ses besoins
|
- **Multi-providers** : L'utilisateur choisit son moteur (Google, DeepL, OpenAI, DeepSeek, OpenRouter, Minimax, Zai) selon son budget et ses besoins
|
||||||
- **Glossaires techniques** : Terminologie personnalisée (HVAC, IT, Juridique, Médical)
|
- **Glossaires techniques** : Terminologie personnalisée (HVAC, IT, Juridique, Médical)
|
||||||
- **Self-hostable** : Peut être déployé en privé pour les entreprises soucieuses de confidentialité
|
|
||||||
|
|
||||||
### Marché cible
|
### Marché cible
|
||||||
| Segment | Taille estimée | Priorité |
|
| Segment | Taille estimée | Priorité |
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
| 1 | **Page d'accueil / Hero** | Landing page, réseaux sociaux | Montrer l'interface épurée avec le drop-zone de fichier |
|
| 1 | **Page d'accueil / Hero** | Landing page, réseaux sociaux | Montrer l'interface épurée avec le drop-zone de fichier |
|
||||||
| 2 | **Upload en cours** | Démonstration du workflow | Fichier Excel chargé avec sélection langue source/cible |
|
| 2 | **Upload en cours** | Démonstration du workflow | Fichier Excel chargé avec sélection langue source/cible |
|
||||||
| 3 | **Résultat côte à côte** | Preuve de qualité | Document original vs traduit, montrer que le format est intact |
|
| 3 | **Résultat côte à côte** | Preuve de qualité | Document original vs traduit, montrer que le format est intact |
|
||||||
| 4 | **Sélection du provider** | Fonctionnalité clé | Dropdown Google/DeepL/OpenAI/Ollama avec prix affichés |
|
| 4 | **Sélection du provider** | Fonctionnalité clé | Dropdown Google/DeepL/OpenAI/DeepSeek avec prix affichés |
|
||||||
| 5 | **Glossaire technique** | Différenciation | Interface de gestion des glossaires personnalisés |
|
| 5 | **Glossaire technique** | Différenciation | Interface de gestion des glossaires personnalisés |
|
||||||
| 6 | **Dashboard admin** | Crédibilité entreprise | Vue monitoring avec statistiques d'utilisation |
|
| 6 | **Dashboard admin** | Crédibilité entreprise | Vue monitoring avec statistiques d'utilisation |
|
||||||
| 7 | **Page pricing/forfaits** | Conversion | Les 3 tiers (Starter/Pro/Business) clairement affichés |
|
| 7 | **Page pricing/forfaits** | Conversion | Les 3 tiers (Starter/Pro/Business) clairement affichés |
|
||||||
@@ -81,7 +80,7 @@
|
|||||||
|-------|--------|----------|
|
|-------|--------|----------|
|
||||||
| **Landing page** | Mettre en place avec captures d'écran + vidéo + formulaire email | CRITIQUE |
|
| **Landing page** | Mettre en place avec captures d'écran + vidéo + formulaire email | CRITIQUE |
|
||||||
| **Product Hunt** | Préparer le launch (assets, description, maker comment) | HAUTE |
|
| **Product Hunt** | Préparer le launch (assets, description, maker comment) | HAUTE |
|
||||||
| **Reddit** | Posts dans r/SideProject, r/selfhosted, r/translator | HAUTE |
|
| **Reddit** | Posts dans r/SideProject, r/saas, r/translator | HAUTE |
|
||||||
| **Hacker News** | Préparer un "Show HN" technique | HAUTE |
|
| **Hacker News** | Préparer un "Show HN" technique | HAUTE |
|
||||||
| **Twitter/X** | Thread de lancement avec démo GIF | MOYENNE |
|
| **Twitter/X** | Thread de lancement avec démo GIF | MOYENNE |
|
||||||
| **LinkedIn** | Post professionnel ciblant PME et agences | MOYENNE |
|
| **LinkedIn** | Post professionnel ciblant PME et agences | MOYENNE |
|
||||||
@@ -192,7 +191,7 @@
|
|||||||
| **Google Translate (docs)** | Gratuit, connu | Détruit les formats complexes | Préservation du format |
|
| **Google Translate (docs)** | Gratuit, connu | Détruit les formats complexes | Préservation du format |
|
||||||
| **DeepL (docs)** | Qualité de traduction | Cher, formatage limité | Multi-provider + prix |
|
| **DeepL (docs)** | Qualité de traduction | Cher, formatage limité | Multi-provider + prix |
|
||||||
| **DocTranslator** | Simple | Qualité inégale, publicité | Interface pro + glossaires |
|
| **DocTranslator** | Simple | Qualité inégale, publicité | Interface pro + glossaires |
|
||||||
| **Smartcat** | Complet | Complexe, cher | Simplicité + self-hostable |
|
| **Smartcat** | Complet | Complexe, cher | Simplicité + multi-providers |
|
||||||
| **Transifex** | Enterprise | Trop cher pour PME | Prix accessible |
|
| **Transifex** | Enterprise | Trop cher pour PME | Prix accessible |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
109
PROCEDURE_RESTAURATION.md
Normal file
109
PROCEDURE_RESTAURATION.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Procédure de Restauration — Wordly.art
|
||||||
|
|
||||||
|
Ce document décrit comment restaurer rapidement et facilement l'application Wordly.art à partir des sauvegardes stockées sur le NAS (`192.168.1.146`), soit en cas de corruption de données sur le serveur principal, soit en cas de sinistre total nécessitant le basculement sur le serveur de secours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Table des matières
|
||||||
|
1. [Restauration sur le serveur principal (192.168.1.151)](#1-restauration-sur-le-serveur-principal-1921681151)
|
||||||
|
2. [Restauration d'urgence sur le serveur de secours (192.168.1.98)](#2-restauration-durgence-sur-le-serveur-de-secours-192168198)
|
||||||
|
3. [Vérifications post-restauration](#3-vérifications-post-restauration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Restauration sur le serveur principal (192.168.1.151)
|
||||||
|
|
||||||
|
> 💡 **Cas d'usage :** Corruption de la base de données, erreur humaine (ex: suppression accidentelle), ou besoin de revenir à un état antérieur sur le serveur de production actif.
|
||||||
|
|
||||||
|
### Étape 1.1 : Trouver le fichier de sauvegarde à restaurer
|
||||||
|
Connectez-vous en SSH sur le serveur principal (`192.168.1.151`) et listez les archives disponibles sur le NAS :
|
||||||
|
```bash
|
||||||
|
cd /opt/wordly
|
||||||
|
bash scripts/backup-to-nas.sh --list
|
||||||
|
```
|
||||||
|
*Notez le nom de l'archive que vous souhaitez restaurer (ex: `wordly_dr_20260607_120000.tar.gz`).*
|
||||||
|
|
||||||
|
### Étape 1.2 : Exécuter la restauration
|
||||||
|
Lancez la commande suivante en remplaçant `NOM_ARCHIVE.tar.gz` par le nom du fichier choisi :
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/disaster-recovery.sh --restore /volume1/backups/wordly/snapshots/NOM_ARCHIVE.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 1.3 : Confirmer
|
||||||
|
Le script vous demandera une confirmation de sécurité. Saisissez textuellement :
|
||||||
|
```text
|
||||||
|
RESTORE-ALL
|
||||||
|
```
|
||||||
|
Le script arrête automatiquement les conteneurs, restaure la configuration, recrée la base de données PostgreSQL, réinjecte les données, puis relance l'application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Restauration d'urgence sur le serveur de secours (192.168.1.98)
|
||||||
|
|
||||||
|
> ⚠️ **Cas d'usage :** Le serveur principal (`192.168.1.151`) est hors service (panne matérielle, panne réseau majeure, etc.). Vous devez reconstruire l'application sur le serveur `.98` et y rediriger le trafic utilisateur.
|
||||||
|
|
||||||
|
### Étape 2.1 : Se connecter au serveur de secours
|
||||||
|
Connectez-vous en root sur le serveur de secours :
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.98
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 2.2 : Récupérer les outils et les sauvegardes depuis le NAS
|
||||||
|
Grâce à la clé SSH configurée, téléchargez les scripts de restauration et les archives depuis le NAS :
|
||||||
|
```bash
|
||||||
|
# 1. Créer le répertoire pour les scripts
|
||||||
|
mkdir -p /opt/wordly/scripts
|
||||||
|
|
||||||
|
# 2. Télécharger les scripts depuis le NAS
|
||||||
|
rsync -az -e "ssh -i /root/.ssh/wordly_nas_key" wordly-backup@192.168.1.146:/volume1/backups/wordly/scripts/ /opt/wordly/scripts/
|
||||||
|
|
||||||
|
# 3. Télécharger les fichiers de sauvegarde dans le dossier /tmp/
|
||||||
|
rsync -az -e "ssh -i /root/.ssh/wordly_nas_key" wordly-backup@192.168.1.146:/volume1/backups/wordly/snapshots/ /tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 2.3 : Choisir la sauvegarde et lancer la restauration
|
||||||
|
Listez les fichiers téléchargés dans `/tmp` pour identifier la dernière sauvegarde :
|
||||||
|
```bash
|
||||||
|
ls -lht /tmp/wordly_dr_*.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Exécutez la restauration complète en remplaçant `NOM_ARCHIVE.tar.gz` :
|
||||||
|
```bash
|
||||||
|
bash /opt/wordly/scripts/disaster-recovery.sh --restore /tmp/NOM_ARCHIVE.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 2.4 : Confirmer
|
||||||
|
Saisissez la confirmation de sécurité :
|
||||||
|
```text
|
||||||
|
RESTORE-ALL
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚙️ Automatisation effectuée par le script :
|
||||||
|
Le script effectue automatiquement les opérations suivantes :
|
||||||
|
1. Extraction de l'environnement (`.env`), du `docker-compose.yml` et des configurations Docker.
|
||||||
|
2. Démarrage des conteneurs applicatifs et de la base de données.
|
||||||
|
3. Restauration de la base de données PostgreSQL.
|
||||||
|
4. Redémarrage et vérification de la santé de l'application (HTTP Health Check).
|
||||||
|
5. **Appel de l'API de Nginx Proxy Manager (`192.168.1.184`) pour rediriger automatiquement le domaine `wordly.art` vers l'IP du serveur de secours (`192.168.1.98`)**.
|
||||||
|
6. Envoi d'une alerte de succès sur votre Telegram.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vérifications post-restauration
|
||||||
|
|
||||||
|
Si le script se termine avec succès, l'application est en ligne. Vous pouvez valider manuellement avec les commandes suivantes :
|
||||||
|
|
||||||
|
* **Vérifier l'état des conteneurs :**
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
* **Tester l'accès HTTP en local :**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/health
|
||||||
|
```
|
||||||
|
* **En cas d'erreur de redirection automatique de NPM (Dernier recours) :**
|
||||||
|
Si la redirection automatique échoue, connectez-vous sur l'interface d'administration NPM :
|
||||||
|
* URL : `http://192.168.1.184:81`
|
||||||
|
* Allez dans **Proxy Hosts** -> `wordly.art` -> **Edit**
|
||||||
|
* Modifiez le champ **Forward Hostname/IP** en y mettant l'IP du serveur actif (soit `.151`, soit `.98`).
|
||||||
|
* Cliquez sur **Save** (la modification est instantanée).
|
||||||
461
README.md
461
README.md
@@ -1,389 +1,82 @@
|
|||||||
# 📄 Document Translation API
|
# 📄 Wordly.art — Document Translation Portal
|
||||||
|
|
||||||
A powerful SaaS-ready Python API for translating complex structured documents (Excel, Word, PowerPoint) while **strictly preserving** the original formatting, layout, and embedded media.
|
Wordly.art est une application complète et prête pour la production (SaaS-ready) permettant de traduire des documents bureautiques complexes (Excel, Word, PowerPoint) tout en **préservant strictement** la mise en page originale, le style, les formules et les médias intégrés.
|
||||||
|
|
||||||
## ✨ Features
|
Ce fichier sert de **portail central** pour accéder à toutes les documentations techniques, guides d'exploitation, de déploiement et de secours de l'application.
|
||||||
|
|
||||||
### 🔄 Multiple Translation Providers
|
|
||||||
| Provider | Type | Description |
|
|
||||||
|----------|------|-------------|
|
|
||||||
| **Google Translate** | Cloud | Free, fast, reliable |
|
|
||||||
| **Ollama** | Local LLM | Privacy-focused, customizable with system prompts |
|
|
||||||
| **WebLLM** | Browser | Runs entirely in browser using WebGPU |
|
|
||||||
| **DeepL** | Cloud | High-quality translations (API key required) |
|
|
||||||
| **LibreTranslate** | Self-hosted | Open-source alternative |
|
|
||||||
| **OpenAI** | Cloud | GPT-4o/4o-mini with vision support |
|
|
||||||
|
|
||||||
### 📊 Excel Translation (.xlsx)
|
|
||||||
- ✅ Translates all cell content and sheet names
|
|
||||||
- ✅ Preserves cell merging, formulas, and styles
|
|
||||||
- ✅ Maintains font styles, colors, and borders
|
|
||||||
- ✅ Image text extraction with vision models
|
|
||||||
- ✅ Adds translated image text as comments
|
|
||||||
|
|
||||||
### 📝 Word Translation (.docx)
|
|
||||||
- ✅ Translates body text, headers, footers, and tables
|
|
||||||
- ✅ Preserves heading styles and paragraph formatting
|
|
||||||
- ✅ Maintains lists, images, charts, and SmartArt
|
|
||||||
- ✅ Image text extraction and translation
|
|
||||||
|
|
||||||
### 📽️ PowerPoint Translation (.pptx)
|
|
||||||
- ✅ Translates slide titles, body text, and speaker notes
|
|
||||||
- ✅ Preserves slide layouts, transitions, and animations
|
|
||||||
- ✅ Image text extraction with text boxes added below images
|
|
||||||
- ✅ Keeps layering order and positions
|
|
||||||
|
|
||||||
### 🧠 LLM Features (Ollama/WebLLM/OpenAI)
|
|
||||||
- ✅ **Custom System Prompts**: Provide context for better translations
|
|
||||||
- ✅ **Technical Glossary**: Define term mappings (e.g., `batterie=coil`)
|
|
||||||
- ✅ **Presets**: HVAC, IT, Legal, Medical terminology
|
|
||||||
- ✅ **Vision Models**: Translate text within images (gemma3, qwen3-vl, gpt-4o)
|
|
||||||
|
|
||||||
### 🏢 SaaS-Ready Features
|
|
||||||
- 🚦 **Rate Limiting**: Per-client IP with token bucket and sliding window algorithms
|
|
||||||
- 🔒 **Security Headers**: CSP, XSS protection, HSTS support
|
|
||||||
- 🧹 **Auto Cleanup**: Automatic file cleanup with TTL tracking
|
|
||||||
- 📊 **Monitoring**: Health checks, metrics, and system status
|
|
||||||
- 🔐 **Admin Dashboard**: Secure admin panel with authentication
|
|
||||||
- 📝 **Request Logging**: Structured logging with unique request IDs
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Clone the repository
|
|
||||||
git clone https://gitea.parsanet.org/sepehr/office_translator.git
|
|
||||||
cd office_translator
|
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
python -m venv venv
|
|
||||||
.\venv\Scripts\Activate.ps1
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Run the API
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The API starts on `http://localhost:8000`
|
|
||||||
|
|
||||||
### Frontend Setup
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend runs on `http://localhost:3000`
|
|
||||||
|
|
||||||
## 📚 API Documentation
|
|
||||||
|
|
||||||
- **Swagger UI**: http://localhost:8000/docs
|
|
||||||
- **ReDoc**: http://localhost:8000/redoc
|
|
||||||
|
|
||||||
## 🔧 API Endpoints
|
|
||||||
|
|
||||||
### Translation
|
|
||||||
|
|
||||||
#### POST /translate
|
|
||||||
Translate a document with full customization.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:8000/translate" \
|
|
||||||
-F "file=@document.xlsx" \
|
|
||||||
-F "target_language=en" \
|
|
||||||
-F "provider=ollama" \
|
|
||||||
-F "ollama_model=gemma3:12b" \
|
|
||||||
-F "translate_images=true" \
|
|
||||||
-F "system_prompt=You are translating HVAC documents."
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitoring
|
|
||||||
|
|
||||||
#### GET /health
|
|
||||||
Comprehensive health check with system status.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "healthy",
|
|
||||||
"translation_service": "google",
|
|
||||||
"memory": {"system_percent": 34.1, "system_available_gb": 61.7},
|
|
||||||
"disk": {"total_files": 0, "total_size_mb": 0},
|
|
||||||
"cleanup_service": {"is_running": true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### GET /metrics
|
|
||||||
System metrics and statistics.
|
|
||||||
|
|
||||||
#### GET /rate-limit/status
|
|
||||||
Current rate limit status for the requesting client.
|
|
||||||
|
|
||||||
### Admin Endpoints (Authentication Required)
|
|
||||||
|
|
||||||
#### POST /admin/login
|
|
||||||
Login to admin dashboard.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:8000/admin/login" \
|
|
||||||
-F "username=admin" \
|
|
||||||
-F "password=your_password"
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"token": "your_bearer_token",
|
|
||||||
"expires_in": 86400
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### GET /admin/dashboard
|
|
||||||
Get comprehensive dashboard data (requires Bearer token).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "http://localhost:8000/admin/dashboard" \
|
|
||||||
-H "Authorization: Bearer your_token"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### POST /admin/cleanup/trigger
|
|
||||||
Manually trigger file cleanup.
|
|
||||||
|
|
||||||
#### GET /admin/files/tracked
|
|
||||||
List currently tracked files.
|
|
||||||
|
|
||||||
## 🌐 Supported Languages
|
|
||||||
|
|
||||||
| Code | Language | Code | Language |
|
|
||||||
|------|----------|------|----------|
|
|
||||||
| en | English | fr | French |
|
|
||||||
| fa | Persian/Farsi | es | Spanish |
|
|
||||||
| de | German | it | Italian |
|
|
||||||
| pt | Portuguese | ru | Russian |
|
|
||||||
| zh | Chinese | ja | Japanese |
|
|
||||||
| ko | Korean | ar | Arabic |
|
|
||||||
|
|
||||||
## ⚙️ Configuration
|
|
||||||
|
|
||||||
### Environment Variables (.env)
|
|
||||||
|
|
||||||
1. **Copy** `.env.example` to `.env`: `cp .env.example .env`
|
|
||||||
2. **Fill** required variables (see comments in `.env.example`: Required vs Optional).
|
|
||||||
3. In **production** (`ENV=production`), missing required vars (e.g. `JWT_SECRET_KEY`, `ADMIN_USERNAME`, `ADMIN_PASSWORD` or `ADMIN_PASSWORD_HASH`, `ADMIN_TOKEN_SECRET`, `DATABASE_URL`, and `REDIS_URL` if rate limiting is on) cause the app to **fail at startup** with a clear message listing them (Story 6.6, NFR10).
|
|
||||||
|
|
||||||
```env
|
|
||||||
# ============== Translation Services ==============
|
|
||||||
TRANSLATION_SERVICE=google
|
|
||||||
DEEPL_API_KEY=your_deepl_api_key_here
|
|
||||||
|
|
||||||
# Ollama Configuration
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
OLLAMA_MODEL=llama3
|
|
||||||
OLLAMA_VISION_MODEL=llava
|
|
||||||
|
|
||||||
# ============== File Limits ==============
|
|
||||||
MAX_FILE_SIZE_MB=50
|
|
||||||
|
|
||||||
# ============== Rate Limiting (SaaS) ==============
|
|
||||||
RATE_LIMIT_ENABLED=true
|
|
||||||
RATE_LIMIT_PER_MINUTE=30
|
|
||||||
RATE_LIMIT_PER_HOUR=200
|
|
||||||
TRANSLATIONS_PER_MINUTE=10
|
|
||||||
TRANSLATIONS_PER_HOUR=50
|
|
||||||
MAX_CONCURRENT_TRANSLATIONS=5
|
|
||||||
|
|
||||||
# ============== Cleanup Service ==============
|
|
||||||
CLEANUP_ENABLED=true
|
|
||||||
CLEANUP_INTERVAL_MINUTES=15
|
|
||||||
FILE_TTL_MINUTES=60
|
|
||||||
INPUT_FILE_TTL_MINUTES=30
|
|
||||||
OUTPUT_FILE_TTL_MINUTES=120
|
|
||||||
|
|
||||||
# ============== Security ==============
|
|
||||||
# When behind Nginx (production), HSTS is set by the proxy; ENABLE_HSTS applies when running the app without a reverse proxy (e.g. dev).
|
|
||||||
ENABLE_HSTS=false
|
|
||||||
# Use "*" only for local development; set explicit origins in production (see .env.example).
|
|
||||||
CORS_ORIGINS=*
|
|
||||||
|
|
||||||
# ============== Admin Authentication ==============
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=changeme123 # Change in production!
|
|
||||||
# Or use a SHA256 hash:
|
|
||||||
# ADMIN_PASSWORD_HASH=your_sha256_hash
|
|
||||||
|
|
||||||
# ============== Monitoring ==============
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
ENABLE_REQUEST_LOGGING=true
|
|
||||||
MAX_MEMORY_PERCENT=80
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ollama Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Ollama (Windows)
|
|
||||||
winget install Ollama.Ollama
|
|
||||||
|
|
||||||
# Pull a model
|
|
||||||
ollama pull llama3.2
|
|
||||||
|
|
||||||
# For vision/image translation
|
|
||||||
ollama pull gemma3:12b
|
|
||||||
# or
|
|
||||||
ollama pull qwen3-vl:8b
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Using System Prompts & Glossary
|
|
||||||
|
|
||||||
### Example: HVAC Translation
|
|
||||||
|
|
||||||
**System Prompt:**
|
|
||||||
```
|
|
||||||
You are translating HVAC technical documents.
|
|
||||||
Use precise technical terminology.
|
|
||||||
Keep unit measurements (kW, m³/h, Pa) unchanged.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Glossary:**
|
|
||||||
```
|
|
||||||
batterie=coil
|
|
||||||
groupe froid=chiller
|
|
||||||
CTA=AHU (Air Handling Unit)
|
|
||||||
échangeur=heat exchanger
|
|
||||||
vanne 3 voies=3-way valve
|
|
||||||
```
|
|
||||||
|
|
||||||
### Presets Available
|
|
||||||
- 🔧 **HVAC**: Heating, Ventilation, Air Conditioning
|
|
||||||
- 💻 **IT**: Software and technology
|
|
||||||
- ⚖️ **Legal**: Legal documents
|
|
||||||
- 🏥 **Medical**: Healthcare terminology
|
|
||||||
|
|
||||||
## <20> Admin Dashboard
|
|
||||||
|
|
||||||
Access the admin dashboard at `/admin` in the frontend. Features:
|
|
||||||
|
|
||||||
- **System Status**: Health, uptime, and issues
|
|
||||||
- **Memory & Disk Monitoring**: Real-time usage stats
|
|
||||||
- **Translation Statistics**: Total translations, success rate
|
|
||||||
- **Rate Limit Management**: View active clients and limits
|
|
||||||
- **Cleanup Service**: Monitor and trigger manual cleanup
|
|
||||||
|
|
||||||
### Default Credentials
|
|
||||||
- **Username**: admin
|
|
||||||
- **Password**: changeme123
|
|
||||||
|
|
||||||
⚠️ **Change the default password in production!**
|
|
||||||
|
|
||||||
## 🏗️ Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
Translate/
|
|
||||||
├── main.py # FastAPI application with SaaS features
|
|
||||||
├── config.py # Configuration with SaaS settings
|
|
||||||
├── requirements.txt # Dependencies
|
|
||||||
├── mcp_server.py # MCP server implementation
|
|
||||||
├── middleware/ # SaaS middleware
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── rate_limiting.py # Rate limiting with token bucket
|
|
||||||
│ ├── validation.py # Input validation
|
|
||||||
│ ├── security.py # Security headers & logging
|
|
||||||
│ └── cleanup.py # Auto cleanup service
|
|
||||||
├── services/
|
|
||||||
│ └── translation_service.py # Translation providers
|
|
||||||
├── translators/
|
|
||||||
│ ├── excel_translator.py # Excel with image support
|
|
||||||
│ ├── word_translator.py # Word with image support
|
|
||||||
│ └── pptx_translator.py # PowerPoint with image support
|
|
||||||
├── frontend/ # Next.js frontend
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── app/
|
|
||||||
│ │ │ ├── page.tsx # Main translation page
|
|
||||||
│ │ │ ├── admin/ # Admin dashboard
|
|
||||||
│ │ │ └── settings/ # Settings pages
|
|
||||||
│ │ └── components/
|
|
||||||
│ └── package.json
|
|
||||||
├── static/
|
|
||||||
│ └── webllm.html # WebLLM standalone interface
|
|
||||||
├── uploads/ # Temporary uploads (auto-cleaned)
|
|
||||||
└── outputs/ # Translated files (auto-cleaned)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- **FastAPI**: Modern async web framework
|
|
||||||
- **openpyxl**: Excel manipulation
|
|
||||||
- **python-docx**: Word documents
|
|
||||||
- **python-pptx**: PowerPoint presentations
|
|
||||||
- **deep-translator**: Google/DeepL/Libre translation
|
|
||||||
- **psutil**: System monitoring
|
|
||||||
- **python-magic**: File type validation
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **Next.js 15**: React framework
|
|
||||||
- **Tailwind CSS**: Styling
|
|
||||||
- **Lucide Icons**: Icon library
|
|
||||||
- **WebLLM**: Browser-based LLM
|
|
||||||
|
|
||||||
## 🔌 MCP Integration
|
|
||||||
|
|
||||||
This API can be used as an MCP (Model Context Protocol) server for AI assistants.
|
|
||||||
|
|
||||||
### VS Code Configuration
|
|
||||||
|
|
||||||
Add to your VS Code `settings.json` or `.vscode/mcp.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"servers": {
|
|
||||||
"document-translator": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "python",
|
|
||||||
"args": ["mcp_server.py"],
|
|
||||||
"cwd": "D:/Translate",
|
|
||||||
"env": {
|
|
||||||
"PYTHONPATH": "D:/Translate"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Production Deployment
|
|
||||||
|
|
||||||
### Security Checklist
|
|
||||||
- [ ] Change `ADMIN_PASSWORD` or set `ADMIN_PASSWORD_HASH`
|
|
||||||
- [ ] Set `CORS_ORIGINS` to your frontend domain
|
|
||||||
- [ ] Enable `ENABLE_HSTS=true` if using HTTPS (when not behind Nginx; behind Nginx, HSTS is set by the proxy)
|
|
||||||
- [ ] Configure rate limits appropriately
|
|
||||||
- [ ] Set up log rotation for `logs/` directory
|
|
||||||
- [ ] Use a reverse proxy (nginx/traefik) for HTTPS
|
|
||||||
|
|
||||||
### Docker Deployment
|
|
||||||
|
|
||||||
En production, utilisez le stack Docker Compose avec Nginx en reverse proxy (ports 80/443) :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Avec certificats SSL dans docker/nginx/ssl/ (voir DEPLOYMENT_GUIDE.md)
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Nginx** : terminaison TLS, HTTP→HTTPS, HSTS, routage `/api/*` → backend, `/*` → frontend (Story 6.5).
|
|
||||||
- Backend et frontend ne sont pas exposés sur l’hôte ; tout passe par le proxy.
|
|
||||||
- Détails : [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) (SSL/TLS, santé `/health`, variables d’environnement).
|
|
||||||
|
|
||||||
## 📝 License
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
Contributions welcome! Please submit a Pull Request.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Built with ❤️ using Python, FastAPI, Next.js, and Ollama**
|
## 🗺️ Carte de la Documentation
|
||||||
|
|
||||||
|
Pour faciliter la navigation, utilisez les liens ci-dessous pour accéder directement aux guides spécialisés :
|
||||||
|
|
||||||
|
### 🚀 Démarrage & Utilisation
|
||||||
|
* 📥 **[Guide de démarrage rapide (QUICKSTART.md)](./QUICKSTART.md)** : Installation locale et lancement du projet en développement.
|
||||||
|
* 📖 **[Guide d'utilisation de l'API (GUIDE_UTILISATION.md)](./GUIDE_UTILISATION.md)** : Exemples de requêtes de traduction, gestion du glossaire et des presets techniques (CVC, IT, Légal, etc.).
|
||||||
|
* 💡 **[Fiche de référence rapide (QUICK_REFERENCE.md)](./QUICK_REFERENCE.md)** : Commandes utiles, ports réseaux, identifiants par défaut et astuces rapides.
|
||||||
|
|
||||||
|
### 🏗️ Architecture & Conception
|
||||||
|
* 📐 **[Architecture logicielle (ARCHITECTURE.md)](./ARCHITECTURE.md)** : Choix technologiques, flux de données, gestion du cycle de vie des fichiers, sécurité (HSTS, CSP) et monitoring.
|
||||||
|
|
||||||
|
### 🌐 Déploiement en Production
|
||||||
|
* 🏢 **[Guide de Déploiement Général (DEPLOYMENT_GUIDE.md)](./DEPLOYMENT_GUIDE.md)** : Guide standard pour le déploiement de production sous Docker avec reverse-proxy Nginx et SSL.
|
||||||
|
* ☁️ **[Déploiement sur IONOS (DEPLOY_IONOS.md)](./DEPLOY_IONOS.md)** : Instructions spécifiques pour déployer l'infrastructure sur un serveur virtuel IONOS.
|
||||||
|
|
||||||
|
### 🛡️ Sauvegarde, Résilience & Secours (Disaster Recovery)
|
||||||
|
* 💾 **[Plan de Reprise d'Activité (DISASTER_RECOVERY.md)](./DISASTER_RECOVERY.md)** : Guide complet du système de sauvegarde automatique vers le NAS via SSH/rsync, monitoring et automatisation du failover.
|
||||||
|
* 🔄 **[Procédure de Restauration Simplifiée (PROCEDURE_RESTAURATION.md)](./PROCEDURE_RESTAURATION.md)** : Guide d'urgence pas-à-pas pour restaurer les données sur le serveur actif ou basculer en 20 minutes sur le serveur de secours (`.98`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Fonctionnalités Clés
|
||||||
|
|
||||||
|
### 🔄 Multi-fournisseurs de Traduction
|
||||||
|
L'application supporte 7 moteurs de traduction, activables à la volée :
|
||||||
|
* **Google Translate** (Gratuit, rapide, par défaut)
|
||||||
|
* **DeepL API** (Haute qualité pour l'entreprise)
|
||||||
|
* **OpenAI** (Modèles GPT-4o, support de la vision)
|
||||||
|
* **DeepSeek, OpenRouter, Minimax, x.ai (Zai)** (Modèles de pointe pour traductions complexes)
|
||||||
|
|
||||||
|
### 📁 Traduction Intelligente par Fichier
|
||||||
|
* **Excel (.xlsx)** : Conserve la fusion des cellules, les formules, les polices de caractères, les styles de bordures et traduit également le texte contenu dans les images (via modèles vision).
|
||||||
|
* **Word (.docx)** : Préserve les en-têtes, pieds de page, tableaux, listes à puces et la mise en forme des paragraphes.
|
||||||
|
* **PowerPoint (.pptx)** : Conserve la mise en page des diapositives, les animations et transitions.
|
||||||
|
|
||||||
|
### 🏢 Sécurité & Exploitation (SaaS-Ready)
|
||||||
|
* 🚦 **Limitation de débit (Rate Limiting)** : Par IP client avec algorithme Token Bucket stocké dans Redis.
|
||||||
|
* 🧹 **Nettoyage automatique (Auto Cleanup)** : Suppression automatique des fichiers temporaires après expiration de la durée de vie (TTL).
|
||||||
|
* 📊 **Monitoring complet** : Route `/health` détaillée et intégration Prometheus + Grafana pour suivre les performances physiques et logicielles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Stack Technique
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
* **FastAPI** (Python 3.11+) : API asynchrone rapide et documentée (Swagger disponible sur `http://localhost:8000/docs`).
|
||||||
|
* **openpyxl**, **python-docx**, **python-pptx** : Moteurs de manipulation de documents sans dépendance Microsoft Office.
|
||||||
|
* **Docker / Docker Compose** : Isolation complète de l'application, de la base PostgreSQL et du cache Redis.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
* **Next.js 15** (React) & **Tailwind CSS** : Interface utilisateur moderne, ergonomique et responsive.
|
||||||
|
* **Lucide Icons** : Bibliothèque d'icônes vectorielles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Lancement Rapide (Mode Dev)
|
||||||
|
|
||||||
|
Pour un déploiement complet en production ou homelab, veuillez vous référer aux fichiers de déploiement listés dans la [Carte de la Documentation](#-carte-de-la-documentation).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Cloner le projet
|
||||||
|
git clone https://gitea.parsanet.org/sepehr/office_translator.git /opt/wordly
|
||||||
|
cd /opt/wordly
|
||||||
|
|
||||||
|
# 2. Configurer l'environnement
|
||||||
|
cp .env.example .env
|
||||||
|
# Modifiez les variables dans le .env selon vos besoins
|
||||||
|
|
||||||
|
# 3. Lancer avec Docker Compose
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
* **API (Backend)** : `http://localhost:8000` (Documentation Swagger sur `/docs`)
|
||||||
|
* **Interface Web (Frontend)** : `http://localhost:3000`
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDapg1/jcFA2gLR
|
|
||||||
4/q99np9r3hFJbduPpd9QKOQ1D8mIipHy5MHTgpBY7QkUjyBLuK0sZoq9CwWXxQe
|
|
||||||
uFp56hHZW5aM1JDZwLGlCqY7j+AZzNB2b51hJyvzxoNTTN98txuRyiiVi9CWycXZ
|
|
||||||
SSGXeWzfPAuyIm6KpM2QA58IHaLqawIAdlOuPqKC9Mc3/M42xoCz2cvFgsq2FLXG
|
|
||||||
U2wIpCC2Kw5hfT+wvYWJXYwsi1G8DW97VQd1eGXzSn0gOlyHOFmUiQJWqee98LTW
|
|
||||||
ZbjUkZ4wTQkeo36ghbqdirDq15rZfbGDFfgRxTcDY7crOET4yZ3qiMWkQZMGYgsK
|
|
||||||
5zAuPMtrAgMBAAECggEAa9P4VC9UXuck/N2xin1Dazhd17wB+hxkGSEHiSUi/2tZ
|
|
||||||
7o42tCOIazcAX+kubYbxxqrPnN9N3rqE8aF4/SayJr9EP6+Uv86HzRyzXixWGGMp
|
|
||||||
az8gHglzY4VCvpHqpVaoAf3whc+tzq8zIzUtm8YGKxExs2fHnBgqSBuBEj+LWI8D
|
|
||||||
se3uQZk1sBj6/T+fXIDHYhg05Whsxm48NZaNk/Q2JCdnLdUeH1vz3/WG0pu5HHji
|
|
||||||
TwUW5/4e90O14Iw/ZRCUzmWdSXPu9BqwqiU620tm+2FT3aNYNSUpp5NO+eH4OGj/
|
|
||||||
pY+n8FscxTuRl4g7KWG8wRiyZM7+wrdaf3V76lisAQKBgQD0nId6CQUHfAYUDW1g
|
|
||||||
H1BGtnLrSmRvY+z0VDFK983RcGTvW+aMIbxFYwZpdvc4tNnMCFk20mwqwwsbnhmY
|
|
||||||
Imgz0RSSFRt48Fl0sB0vSg0k0s7BJVUvZuWZpNMyAfaQxd8p4dzkXm2B0wqcxdZB
|
|
||||||
P7YrbShwpLDGgw3dwyThAqdWowKBgQDk1BQbrUba053BsPCIvHd2i5ljSbbMgJQ3
|
|
||||||
yKt7eDyhM8wdVTTE4rIEXUSUMoRKQyFmDEWGxl3Ku+LDDwmzyAuBx43yKhnmHyFZ
|
|
||||||
9vcioUef1pV6rNQ4OCAjZ8I3U/XvABe/yKRweAHuuQbMrzCxttHAR/qJvSWOBS5u
|
|
||||||
v0SgiLIsmQKBgCWT9mwU/qTLtNNXzLm7lcWHOdhTsWI7Y6eVV41AzGmkbp0m7SAR
|
|
||||||
/q5aLB3/nLMldWAW2D3Hzb40AHzZqOzqPD/ZmuNmQrYtllCKzbD6G1LQ39pkNdX2
|
|
||||||
ZMdZvugrzQkonMP+YsTijMgo97Rl4Chd9UpulovXgSC9+V9cH0pB8hspAoGAH87z
|
|
||||||
NJ2zmbuM1F3aMcew+4OQQOMATBEbiV2VqMYf93CR0QoFGdFBWaX1YHxdM019mQff
|
|
||||||
qkpswz+219KBUMJgBxxMOPttOjXdlvnjhKbSo/9aLAFWRrJWlVVZBAPhzeU/Z+Nm
|
|
||||||
KFQmQJ7ruQwpQqrdIVNgGkF2tPU3bSGdUott6RECgYBm/JO1oO80ERFqrAyiJziw
|
|
||||||
IOjdu0FzhPQMwrkl4xkCqHbI8Pa2PWsNYRL88QuMr9EACHsVFU6x62Y68WCzz9TZ
|
|
||||||
4UNKR51kGzEVzEpl4ayB52tCkB0fdZZDA4FZtnUAqX5uBVabO04Qhq4EUkyyhlPW
|
|
||||||
1ms5bI9LgyXstOOQ4jJl4w==
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
-----BEGIN RSA PRIVATE KEY-----
|
|
||||||
MIIEogIBAAKCAQEA2qYNf43BQNoC0eP6vfZ6fa94RSW3bj6XfUCjkNQ/JiIqR8uT
|
|
||||||
B04KQWO0JFI8gS7itLGaKvQsFl8UHrhaeeoR2VuWjNSQ2cCxpQqmO4/gGczQdm+d
|
|
||||||
YScr88aDU0zffLcbkcoolYvQlsnF2Ukhl3ls3zwLsiJuiqTNkAOfCB2i6msCAHZT
|
|
||||||
rj6igvTHN/zONsaAs9nLxYLKthS1xlNsCKQgtisOYX0/sL2FiV2MLItRvA1ve1UH
|
|
||||||
dXhl80p9IDpchzhZlIkCVqnnvfC01mW41JGeME0JHqN+oIW6nYqw6tea2X2xgxX4
|
|
||||||
EcU3A2O3KzhE+Mmd6ojFpEGTBmILCucwLjzLawIDAQABAoIBAGvT+FQvVF7nJPzd
|
|
||||||
sYp9Q2s4Xde8AfocZBkhB4klIv9rWe6ONrQjiGs3AF/pLm2G8caqz5zfTd66hPGh
|
|
||||||
eP0msia/RD+vlL/Oh80cs14sVhhjKWs/IB4Jc2OFQr6R6qVWqAH98IXPrc6vMyM1
|
|
||||||
LZvGBisRMbNnx5wYKkgbgRI/i1iPA7Ht7kGZNbAY+v0/n1yAx2IYNOVobMZuPDWW
|
|
||||||
jZP0NiQnZy3VHh9b89/1htKbuRx44k8FFuf+HvdDteCMP2UQlM5lnUlz7vQasKol
|
|
||||||
OttLZvthU92jWDUlKaeTTvnh+Dho/6WPp/BbHMU7kZeIOylhvMEYsmTO/sK3Wn91
|
|
||||||
e+pYrAECgYEA9JyHegkFB3wGFA1tYB9QRrZy60pkb2Ps9FQxSvfN0XBk71vmjCG8
|
|
||||||
RWMGaXb3OLTZzAhZNtJsKsMLG54ZmCJoM9EUkhUbePBZdLAdL0oNJNLOwSVVL2bl
|
|
||||||
maTTMgH2kMXfKeHc5F5tgdMKnMXWQT+2K20ocKSwxoMN3cMk4QKnVqMCgYEA5NQU
|
|
||||||
G61G2tOdwbDwiLx3douZY0m2zICUN8ire3g8oTPMHVU0xOKyBF1ElDKESkMhZgxF
|
|
||||||
hsZdyrviww8Js8gLgceN8ioZ5h8hWfb3IqFHn9aVeqzUODggI2fCN1P17wAXv8ik
|
|
||||||
cHgB7rkGzK8wsbbRwEf6ib0ljgUubr9EoIiyLJkCgYAlk/ZsFP6ky7TTV8y5u5XF
|
|
||||||
hznYU7FiO2OnlVeNQMxppG6dJu0gEf6uWiwd/5yzJXVgFtg9x82+NAB82ajs6jw/
|
|
||||||
2ZrjZkK2LZZQis2w+htS0N/aZDXV9mTHWb7oK80JKJzD/mLE4ozIKPe0ZeAoXfVK
|
|
||||||
bpaL14EgvflfXB9KQfIbKQKBgB/O8zSds5m7jNRd2jHHsPuDkEDjAEwRG4ldlajG
|
|
||||||
H/dwkdEKBRnRQVml9WB8XTNNfZkH36pKbMM/ttfSgVDCYAccTDj7bTo13Zb544Sm
|
|
||||||
0qP/WiwBVkayVpVVWQQD4c3lP2fjZihUJkCe67kMKUKq3SFTYBpBdrT1N20hnVKL
|
|
||||||
bekRAoGAZvyTtaDvNBERaqwMoic4sCDo3btBc4T0DMK5JeMZAqh2yPD2tj1rDWES
|
|
||||||
/PELjK/RAAh7FRVOsetmOvFgs8/U2eFDSkedZBsxFcxKZeGsgedrQpAdH3WWQwOB
|
|
||||||
WbZ1AKl+bgVWmztOEIauBFJMsoZT1tZrOWyPS4Ml7LTjkOIyZeM=
|
|
||||||
-----END RSA PRIVATE KEY-----
|
|
||||||
Binary file not shown.
@@ -1,36 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIGTDCCBDSgAwIBAgIQOXpmzCdWNi4NqofKbqvjsTANBgkqhkiG9w0BAQwFADBf
|
|
||||||
MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD
|
|
||||||
Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw
|
|
||||||
HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY
|
|
||||||
MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp
|
|
||||||
YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgRFYgUjM2MIIBojANBgkqhkiG9w0B
|
|
||||||
AQEFAAOCAY8AMIIBigKCAYEAljZf2HIz7+SPUPQCQObZYcrxLTHYdf1ZtMRe7Yeq
|
|
||||||
RPSwygz16qJ9cAWtWNTcuICc++p8Dct7zNGxCpqmEtqifO7NvuB5dEVexXn9RFFH
|
|
||||||
12Hm+NtPRQgXIFjx6MSJcNWuVO3XGE57L1mHlcQYj+g4hny90aFh2SCZCDEVkAja
|
|
||||||
EMMfYPKuCjHuuF+bzHFb/9gV8P9+ekcHENF2nR1efGWSKwnfG5RawlkaQDpRtZTm
|
|
||||||
M64TIsv/r7cyFO4nSjs1jLdXYdz5q3a4L0NoabZfbdxVb+CUEHfB0bpulZQtH1Rv
|
|
||||||
38e/lIdP7OTTIlZh6OYL6NhxP8So0/sht/4J9mqIGxRFc0/pC8suja+wcIUna0HB
|
|
||||||
pXKfXTKpzgis+zmXDL06ASJf5E4A2/m+Hp6b84sfPAwQ766rI65mh50S0Di9E3Pn
|
|
||||||
2WcaJc+PILsBmYpgtmgWTR9eV9otfKRUBfzHUHcVgarub/XluEpRlTtZudU5xbFN
|
|
||||||
xx/DgMrXLUAPaI60fZ6wA+PTAgMBAAGjggGBMIIBfTAfBgNVHSMEGDAWgBRWc1hk
|
|
||||||
lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQUaMASFhgOr872h6YyV6NGUV3LBycw
|
|
||||||
DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI
|
|
||||||
KwYBBQUHAwEGCCsGAQUFBwMCMBsGA1UdIAQUMBIwBgYEVR0gADAIBgZngQwBAgEw
|
|
||||||
VAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5zZWN0aWdvLmNvbS9TZWN0aWdv
|
|
||||||
UHVibGljU2VydmVyQXV0aGVudGljYXRpb25Sb290UjQ2LmNybDCBhAYIKwYBBQUH
|
|
||||||
AQEEeDB2ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3Rp
|
|
||||||
Z29QdWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYucDdjMCMGCCsGAQUF
|
|
||||||
BzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEA
|
|
||||||
YtOC9Fy+TqECFw40IospI92kLGgoSZGPOSQXMBqmsGWZUQ7rux7cj1du6d9rD6C8
|
|
||||||
ze1B2eQjkrGkIL/OF1s7vSmgYVafsRoZd/IHUrkoQvX8FZwUsmPu7amgBfaY3g+d
|
|
||||||
q1x0jNGKb6I6Bzdl6LgMD9qxp+3i7GQOnd9J8LFSietY6Z4jUBzVoOoz8iAU84OF
|
|
||||||
h2HhAuiPw1ai0VnY38RTI+8kepGWVfGxfBWzwH9uIjeooIeaosVFvE8cmYUB4TSH
|
|
||||||
5dUyD0jHct2+8ceKEtIoFU/FfHq/mDaVnvcDCZXtIgitdMFQdMZaVehmObyhRdDD
|
|
||||||
4NQCs0gaI9AAgFj4L9QtkARzhQLNyRf87Kln+YU0lgCGr9HLg3rGO8q+Y4ppLsOd
|
|
||||||
unQZ6ZxPNGIfOApbPVf5hCe58EZwiWdHIMn9lPP6+F404y8NNugbQixBber+x536
|
|
||||||
WrZhFZLjEkhp7fFXf9r32rNPfb74X/U90Bdy4lzp3+X1ukh1BuMxA/EEhDoTOS3l
|
|
||||||
7ABvc7BYSQubQ2490OcdkIzUh3ZwDrakMVrbaTxUM2p24N6dB+ns2zptWCva6jzW
|
|
||||||
r8IWKIMxzxLPv5Kt3ePKcUdvkBU/smqujSczTzzSjIoR5QqQA6lN1ZRSnuHIWCvh
|
|
||||||
JEltkYnTAH41QJ6SAWO66GrrUESwN/cgZzL4JLEqz1Y=
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIGlTCCBH2gAwIBAgIRANJ/u8HeNZ5SFq1hSVhgmcQwDQYJKoZIhvcNAQEMBQAw
|
|
||||||
gYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtK
|
|
||||||
ZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYD
|
|
||||||
VQQDEyVVU0VSVHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTIx
|
|
||||||
MDMyMjAwMDAwMFoXDTM4MDExODIzNTk1OVowXzELMAkGA1UEBhMCR0IxGDAWBgNV
|
|
||||||
BAoTD1NlY3RpZ28gTGltaXRlZDE2MDQGA1UEAxMtU2VjdGlnbyBQdWJsaWMgU2Vy
|
|
||||||
dmVyIEF1dGhlbnRpY2F0aW9uIFJvb3QgUjQ2MIICIjANBgkqhkiG9w0BAQEFAAOC
|
|
||||||
Ag8AMIICCgKCAgEAk77VNlJ12AEjoBxHQknuY7a3If3EldVIKyZ8FFMQ2nn9K7ct
|
|
||||||
pNQs+uoy3UnCub0PSD17WphUr55dMXRPB/xQId2kz2hPGxJjbSWZTCqZ80gwYfqB
|
|
||||||
fB6nCErcPiscHxhMcao1jK34bug7StnllALWiYQTqm3ITzPMUJY3kjPcX4jnn1TZ
|
|
||||||
SPCYQ9Zm/Z8XOEPFAVEL1+MjDxRdWxTnS77d9MjaAzfR1jmhIVEwg7Bt1zBOlluR
|
|
||||||
8HAkq79FgWRDDb0hOi886Z4NyyC1QifM2m+b7mQwkDnNk2WBITG1I1AzNyLjOO34
|
|
||||||
MTDMRf5i+dFdMnlCh99qzFYZQE3Oqrv5tXZJlPEn+JGlg+UGs2MOgNzgElWApjtm
|
|
||||||
tDmHLcjw0NEU6eQNTQ72XVdyxTscR1ad4tX7gWGMzE2AkDRbt9cUddzYBEifwMEo
|
|
||||||
iLTpHMqnsfFWt3tJTFnlIBWohAIp+jiUaZpJBo/NH3kUFxIMg3reH7GX7vmXeCik
|
|
||||||
yESS6X0mBaZYcpt5E9gRX67FOGI0aLKGMI74kGGeMmz1BzbNokxu7Io27fLmmRVE
|
|
||||||
cMN8vJw5wLTha/eDJSNX2RKA5UnwdQ/vjescm1QotCE8/HwK/+97a3X/ix2gGQWr
|
|
||||||
+vgrgULoOLq7+6r9PeDzyt9Ol5cp7fMYVumllqy9w5CYsuD5otSmR0N8bc8CAwEA
|
|
||||||
AaOCASAwggEcMB8GA1UdIwQYMBaAFFN5v1qqK0rPVIDh2JvAnfKyA2bLMB0GA1Ud
|
|
||||||
DgQWBBRWc1hklfmSGrASKgRieaFAFYghSTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0T
|
|
||||||
AQH/BAUwAwEB/zAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwEQYDVR0g
|
|
||||||
BAowCDAGBgRVHSAAMFAGA1UdHwRJMEcwRaBDoEGGP2h0dHA6Ly9jcmwudXNlcnRy
|
|
||||||
dXN0LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDA1
|
|
||||||
BggrBgEFBQcBAQQpMCcwJQYIKwYBBQUHMAGGGWh0dHA6Ly9vY3NwLnVzZXJ0cnVz
|
|
||||||
dC5jb20wDQYJKoZIhvcNAQEMBQADggIBADpvBIlq7bMU0cFDT/9P9+BsgCkRgQs0
|
|
||||||
S6Bf7vJSlWMHwby0VGvxCS0hrbi0K2BINZbEbsVsgpQq04431yyoVn3Hldorgq24
|
|
||||||
RldRDOOipEZDTFB9wC9HYt1thHF00XeG2C8KC1plwoEzKAIhPvefI/C3cT0CfTXJ
|
|
||||||
uFjUbKIgSwjNjw6YHtLgoy/hd5+JLUlLco/gzFX/qWbT7tEquOMYpsNKWZj8TLqP
|
|
||||||
q6zMiG4Na6feEZte6YPXGrMWlTWN341vDedc+yxQqSug79HJUQcOZs7KyDWztmae
|
|
||||||
QxsPE49UV/8XwrfZtZaYyrs4FpD94Z4Q8dzXGL8+qEJjxgcza7W6PROaClubavd1
|
|
||||||
VKPm8+aCW77u7SxpR2TFGL6kPdxsKyFijpcunR5V79sUyROfNdzjrAcFWZXK8sbb
|
|
||||||
9FlnwuVG677JLv+ZVTX5AxLvW5OB4zt5uS+zB62wJ/Wv+jXGAttSAcJec4iFgCWH
|
|
||||||
Rvdi/jJoSzRLa3nEzx6pFIzclSCnh0u1xCeLcUBypSiPga8W+6PkuoyQq8U9qs9E
|
|
||||||
oxG5NvrvlyshwUS9yvcZRGw7Ljlx4jJH/BhIPR8kIBCQj1vna9TziZOrw1Of8hDU
|
|
||||||
bHKFG9Pm8Dp2vbjz/2JH39qvxshPKVllGfq+5klPm7yZRUYTiCMAbqwNdL/nsqF2
|
|
||||||
Rnnyp58XRStJ
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
title: 'Aligner le design du CreateGlossaryDialog avec le design system éditorial'
|
||||||
|
type: 'refactor'
|
||||||
|
created: '2026-06-20'
|
||||||
|
status: 'done'
|
||||||
|
route: 'one-shot'
|
||||||
|
context: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Aligner le design du CreateGlossaryDialog avec le design system éditorial
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
**Problem:** Le dialog `CreateGlossaryDialog` utilisait le style shadcn/ui générique (boutons gris, tabs Radix standard, couleurs template Tailwind) alors que le reste de l'application — et notamment la page Glossaires elle-même — utilise un design éditorial premium avec typography serif Playfair Display, boutons `premium-button` dorés, cartes `editorial-card`, palette brand (#C5A17A), et micro-typographie soignée. De plus, les labels de langue étaient hardcodés en français au lieu d'utiliser le système i18n.
|
||||||
|
|
||||||
|
**Approach:** Refactoriser le composant pour adopter le même vocabulaire visuel (editorial-card, premium-button, accent-pill, font-serif, brand colors), remplacer les labels hardcodés par des clés i18n existantes, et ajouter les attributs ARIA manquants sur les tabs custom.
|
||||||
|
|
||||||
|
## Suggested Review Order
|
||||||
|
|
||||||
|
1. [CreateGlossaryDialog.tsx](file:///d:/dev1405/office_translator/frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx) — Composant refactorisé : vérifier la cohérence visuelle avec la page parent
|
||||||
|
2. [page.tsx](file:///d:/dev1405/office_translator/frontend/src/app/dashboard/glossaries/page.tsx) — Page Glossaires (référence design — non modifiée)
|
||||||
|
3. [globals.css](file:///d:/dev1405/office_translator/frontend/src/app/globals.css) — Design tokens (référence — non modifié)
|
||||||
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_
|
||||||
@@ -21,11 +21,14 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column("users", sa.Column("reset_token", sa.String(length=255), nullable=True))
|
op.add_column("users", sa.Column("reset_token", sa.String(length=255), nullable=True))
|
||||||
op.add_column("users", sa.Column("reset_token_expires", sa.DateTime(), nullable=True))
|
op.add_column("users", sa.Column("reset_token_expires", sa.DateTime(), nullable=True))
|
||||||
# Make legacy password_hash column nullable (ORM uses hashed_password now)
|
# Make legacy password_hash column nullable (ORM uses hashed_password now).
|
||||||
op.alter_column("users", "password_hash", nullable=True)
|
# Use batch_alter_table for SQLite compatibility.
|
||||||
|
with op.batch_alter_table("users") as batch_op:
|
||||||
|
batch_op.alter_column("password_hash", nullable=True)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_column("users", "reset_token_expires")
|
with op.batch_alter_table("users") as batch_op:
|
||||||
op.drop_column("users", "reset_token")
|
batch_op.drop_column("reset_token_expires")
|
||||||
op.alter_column("users", "password_hash", nullable=False)
|
batch_op.drop_column("reset_token")
|
||||||
|
batch_op.alter_column("password_hash", nullable=False)
|
||||||
|
|||||||
@@ -12,20 +12,38 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.execute(
|
conn = op.get_bind()
|
||||||
"ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_tier"
|
dialect = conn.dialect.name
|
||||||
)
|
|
||||||
op.execute(
|
if dialect == "sqlite":
|
||||||
"ALTER TABLE users ADD CONSTRAINT ck_users_tier "
|
# Migration 002 skipped the CHECK constraint on SQLite; just create it now.
|
||||||
"CHECK (tier IN ('free', 'starter', 'pro', 'business', 'enterprise'))"
|
with op.batch_alter_table("users") as batch_op:
|
||||||
)
|
batch_op.create_check_constraint(
|
||||||
|
"ck_users_tier",
|
||||||
|
"tier IN ('free', 'starter', 'pro', 'business', 'enterprise')",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_tier")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE users ADD CONSTRAINT ck_users_tier "
|
||||||
|
"CHECK (tier IN ('free', 'starter', 'pro', 'business', 'enterprise'))"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.execute(
|
conn = op.get_bind()
|
||||||
"ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_tier"
|
dialect = conn.dialect.name
|
||||||
)
|
|
||||||
op.execute(
|
if dialect == "sqlite":
|
||||||
"ALTER TABLE users ADD CONSTRAINT ck_users_tier "
|
with op.batch_alter_table("users") as batch_op:
|
||||||
"CHECK (tier IN ('free', 'pro'))"
|
batch_op.drop_constraint("ck_users_tier", type_="check")
|
||||||
)
|
batch_op.create_check_constraint(
|
||||||
|
"ck_users_tier",
|
||||||
|
"tier IN ('free', 'pro')",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_tier")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE users ADD CONSTRAINT ck_users_tier "
|
||||||
|
"CHECK (tier IN ('free', 'pro'))"
|
||||||
|
)
|
||||||
|
|||||||
@@ -26,23 +26,44 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
dialect = conn.dialect.name
|
||||||
|
|
||||||
# Glossaries with terms containing 5+ translation keys are multilingual templates
|
# Glossaries with terms containing 5+ translation keys are multilingual templates
|
||||||
# (enriched glossaries have 11 translations: de, es, it, pt, nl, ru, ja, ko, zh, ar, fa)
|
# (enriched glossaries have 11 translations: de, es, it, pt, nl, ru, ja, ko, zh, ar, fa)
|
||||||
op.execute("""
|
if dialect == "sqlite":
|
||||||
UPDATE glossaries
|
# SQLite does not have jsonb_object_keys. Use json_each for compatibility.
|
||||||
SET target_language = 'multi'
|
op.execute("""
|
||||||
WHERE id IN (
|
UPDATE glossaries
|
||||||
SELECT DISTINCT g.id
|
SET target_language = 'multi'
|
||||||
FROM glossaries g
|
WHERE id IN (
|
||||||
JOIN glossary_terms gt ON gt.glossary_id = g.id
|
SELECT DISTINCT g.id
|
||||||
WHERE gt.translations IS NOT NULL
|
FROM glossaries g
|
||||||
AND jsonb_typeof(gt.translations) = 'object'
|
JOIN glossary_terms gt ON gt.glossary_id = g.id
|
||||||
AND (
|
WHERE gt.translations IS NOT NULL
|
||||||
SELECT count(*)
|
AND json_type(gt.translations, '$') = 'object'
|
||||||
FROM jsonb_object_keys(gt.translations)
|
AND (
|
||||||
) >= 5
|
SELECT count(*)
|
||||||
)
|
FROM json_each(gt.translations)
|
||||||
""")
|
) >= 5
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
op.execute("""
|
||||||
|
UPDATE glossaries
|
||||||
|
SET target_language = 'multi'
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT DISTINCT g.id
|
||||||
|
FROM glossaries g
|
||||||
|
JOIN glossary_terms gt ON gt.glossary_id = g.id
|
||||||
|
WHERE gt.translations IS NOT NULL
|
||||||
|
AND jsonb_typeof(gt.translations) = 'object'
|
||||||
|
AND (
|
||||||
|
SELECT count(*)
|
||||||
|
FROM jsonb_object_keys(gt.translations)
|
||||||
|
) >= 5
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# Also rename glossaries: replace "Anglais" with "Multilingue" in the name
|
# Also rename glossaries: replace "Anglais" with "Multilingue" in the name
|
||||||
op.execute("""
|
op.execute("""
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
dialect = conn.dialect.name
|
||||||
|
|
||||||
|
if dialect == "sqlite":
|
||||||
|
# SQLite test databases are created fresh and have no legacy glossaries to clean up.
|
||||||
|
# The PostgreSQL-specific jsonb_object_keys logic is not needed here.
|
||||||
|
return
|
||||||
|
|
||||||
# 1. Delete old FR→EN glossaries that don't have multilingual translations
|
# 1. Delete old FR→EN glossaries that don't have multilingual translations
|
||||||
# (imported before enrichment, they are stale duplicates)
|
# (imported before enrichment, they are stale duplicates)
|
||||||
op.execute("""
|
op.execute("""
|
||||||
|
|||||||
@@ -21,10 +21,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
dialect = conn.dialect.name
|
||||||
|
|
||||||
# Step 1: Delete ALL glossaries with target_language='en' (old stale imports)
|
# Step 1: Delete ALL glossaries with target_language='en' (old stale imports)
|
||||||
op.execute("DELETE FROM glossary_terms WHERE glossary_id IN (SELECT id FROM glossaries WHERE target_language = 'en')")
|
op.execute("DELETE FROM glossary_terms WHERE glossary_id IN (SELECT id FROM glossaries WHERE target_language = 'en')")
|
||||||
op.execute("DELETE FROM glossaries WHERE target_language = 'en'")
|
op.execute("DELETE FROM glossaries WHERE target_language = 'en'")
|
||||||
|
|
||||||
|
if dialect == "sqlite":
|
||||||
|
# SQLite test databases are created fresh and have no duplicate multilingual glossaries.
|
||||||
|
# The PostgreSQL-specific DISTINCT ON logic is not needed here.
|
||||||
|
return
|
||||||
|
|
||||||
# Step 2: Deduplicate multilingual glossaries — keep only the newest per name
|
# Step 2: Deduplicate multilingual glossaries — keep only the newest per name
|
||||||
# Delete terms for duplicates first, then the duplicates themselves
|
# Delete terms for duplicates first, then the duplicates themselves
|
||||||
op.execute("""
|
op.execute("""
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ class Config:
|
|||||||
TRANSLATION_SERVICE = os.getenv("TRANSLATION_SERVICE", "google")
|
TRANSLATION_SERVICE = os.getenv("TRANSLATION_SERVICE", "google")
|
||||||
DEEPL_API_KEY = os.getenv("DEEPL_API_KEY", "")
|
DEEPL_API_KEY = os.getenv("DEEPL_API_KEY", "")
|
||||||
|
|
||||||
# Ollama Configuration
|
|
||||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
|
||||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
|
|
||||||
OLLAMA_VISION_MODEL = os.getenv("OLLAMA_VISION_MODEL", "llava")
|
|
||||||
|
|
||||||
# ============== File Upload Configuration ==============
|
# ============== File Upload Configuration ==============
|
||||||
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
|
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
|
||||||
@@ -153,5 +149,5 @@ config = Config()
|
|||||||
|
|
||||||
# So that database/connection.py and alembic see DATABASE_URL when only POSTGRES_* is set (AC #1)
|
# So that database/connection.py and alembic see DATABASE_URL when only POSTGRES_* is set (AC #1)
|
||||||
_effective_db_url = Config._get_database_url()
|
_effective_db_url = Config._get_database_url()
|
||||||
if _effective_db_url and not os.environ.get("DATABASE_URL", "").strip():
|
if Config.ENV == "production" and _effective_db_url and not os.environ.get("DATABASE_URL", "").strip():
|
||||||
os.environ["DATABASE_URL"] = _effective_db_url
|
os.environ["DATABASE_URL"] = _effective_db_url
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "E-commerce & Vente - Français → Anglais",
|
"name": "E-commerce & Vente - Français → Multilingue",
|
||||||
"description": "Terminologie e-commerce et vente française vers anglaise pour boutiques en ligne, catalogues et documents commerciaux",
|
"description": "Terminologie e-commerce et vente française traduite en 12 langues pour boutiques en ligne, catalogues et documents commerciaux",
|
||||||
"source_lang": "fr",
|
"source_lang": "fr",
|
||||||
"target_lang": "multi",
|
"target_lang": "multi",
|
||||||
"category": "ecommerce",
|
"category": "ecommerce",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Finance & Comptabilité - Français → Anglais",
|
"name": "Finance & Comptabilité - Français → Multilingue",
|
||||||
"description": "Terminologie financière et comptable française vers anglaise pour rapports annuels, bilans et documents financiers",
|
"description": "Terminologie financière et comptable française traduite en 12 langues pour rapports annuels, bilans et documents financiers",
|
||||||
"source_lang": "fr",
|
"source_lang": "fr",
|
||||||
"target_lang": "multi",
|
"target_lang": "multi",
|
||||||
"category": "finance",
|
"category": "finance",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "RH & Ressources Humaines - Français → Anglais",
|
"name": "RH & Ressources Humaines - Français → Multilingue",
|
||||||
"description": "Terminologie des ressources humaines française vers anglaise pour contrats, politiques et documents RH",
|
"description": "Terminologie des ressources humaines française traduite en 12 langues pour contrats, politiques et documents RH",
|
||||||
"source_lang": "fr",
|
"source_lang": "fr",
|
||||||
"target_lang": "multi",
|
"target_lang": "multi",
|
||||||
"category": "hr",
|
"category": "hr",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Juridique - Français → Anglais",
|
"name": "Juridique - Français → Multilingue",
|
||||||
"description": "Terminologie juridique française vers anglaise pour contrats, statuts et documents légaux",
|
"description": "Terminologie juridique française traduite en 12 langues pour contrats, statuts et documents légaux",
|
||||||
"source_lang": "fr",
|
"source_lang": "fr",
|
||||||
"target_lang": "multi",
|
"target_lang": "multi",
|
||||||
"category": "legal",
|
"category": "legal",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Marketing & Communication - Français → Anglais",
|
"name": "Marketing & Communication - Français → Multilingue",
|
||||||
"description": "Terminologie marketing et communication française vers anglaise pour campagnes, stratégies et contenus marketing",
|
"description": "Terminologie marketing et communication française traduite en 12 langues pour campagnes, stratégies et contenus marketing",
|
||||||
"source_lang": "fr",
|
"source_lang": "fr",
|
||||||
"target_lang": "multi",
|
"target_lang": "multi",
|
||||||
"category": "marketing",
|
"category": "marketing",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Médical & Santé - Français → Anglais",
|
"name": "Médical & Santé - Français → Multilingue",
|
||||||
"description": "Terminologie médicale française vers anglaise pour rapports médicaux, publications scientifiques et documents de santé",
|
"description": "Terminologie médicale française traduite en 12 langues pour rapports médicaux, publications scientifiques et documents de santé",
|
||||||
"source_lang": "fr",
|
"source_lang": "fr",
|
||||||
"target_lang": "multi",
|
"target_lang": "multi",
|
||||||
"category": "medical",
|
"category": "medical",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Scientifique & Recherche - Français → Anglais",
|
"name": "Scientifique & Recherche - Français → Multilingue",
|
||||||
"description": "Terminologie scientifique française vers anglaise pour publications, articles de recherche et thèses",
|
"description": "Terminologie scientifique française traduite en 12 langues pour publications, articles de recherche et thèses",
|
||||||
"source_lang": "fr",
|
"source_lang": "fr",
|
||||||
"target_lang": "multi",
|
"target_lang": "multi",
|
||||||
"category": "scientific",
|
"category": "scientific",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Technologie / IT - Français → Anglais",
|
"name": "Technologie / IT - Français → Multilingue",
|
||||||
"description": "Terminologie technique et informatique française vers anglaise pour documentation, spécifications et rapports IT",
|
"description": "Terminologie technique et informatique française traduite en 12 langues pour documentation, spécifications et rapports IT",
|
||||||
"source_lang": "fr",
|
"source_lang": "fr",
|
||||||
"target_lang": "multi",
|
"target_lang": "multi",
|
||||||
"category": "technology",
|
"category": "technology",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"api_key": null,
|
"api_key": null,
|
||||||
"base_url": null,
|
"base_url": null,
|
||||||
"model": "deepseek/deepseek-v3.2",
|
"model": "google/gemini-3.5-flash",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"max_retries": 3
|
"max_retries": 3
|
||||||
},
|
},
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": null,
|
"api_key": null,
|
||||||
"base_url": null,
|
"base_url": null,
|
||||||
"model": "anthropic/claude-3.5-haiku",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"max_retries": 3
|
"max_retries": 3
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ class Glossary(Base):
|
|||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
source_language = Column(String(10), nullable=False, default="fr")
|
source_language = Column(String(10), nullable=False, default="fr")
|
||||||
target_language = Column(String(10), nullable=True, default="en")
|
target_language = Column(String(10), nullable=True, default="en")
|
||||||
template_id = Column(String(50), nullable=True, index=True)
|
template_id = Column(String(50), nullable=True)
|
||||||
created_at = Column(DateTime, default=_utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -93,34 +93,45 @@ class UserRepository:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def increment_usage(
|
def reset_usage_if_needed(self, user_id: str) -> Optional[User]:
|
||||||
self, user_id: str, docs: int = 0, pages: int = 0, api_calls: int = 0
|
"""Reset monthly counters if usage_reset_date is in a previous month."""
|
||||||
) -> Optional[User]:
|
|
||||||
"""Increment usage counters"""
|
|
||||||
user = self.get_by_id(user_id)
|
user = self.get_by_id(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if usage needs to be reset (monthly)
|
now = datetime.now(timezone.utc)
|
||||||
if user.usage_reset_date:
|
reset_date = user.usage_reset_date
|
||||||
now = datetime.now(timezone.utc)
|
if reset_date is None or reset_date.month != now.month or reset_date.year != now.year:
|
||||||
if (
|
user.docs_translated_this_month = 0
|
||||||
now.month != user.usage_reset_date.month
|
user.pages_translated_this_month = 0
|
||||||
or now.year != user.usage_reset_date.year
|
user.api_calls_this_month = 0
|
||||||
):
|
user.usage_reset_date = now
|
||||||
user.docs_translated_this_month = 0
|
user.updated_at = now
|
||||||
user.pages_translated_this_month = 0
|
self.db.commit()
|
||||||
user.api_calls_this_month = 0
|
self.db.refresh(user)
|
||||||
user.usage_reset_date = now
|
|
||||||
|
|
||||||
user.docs_translated_this_month += docs
|
|
||||||
user.pages_translated_this_month += pages
|
|
||||||
user.api_calls_this_month += api_calls
|
|
||||||
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(user)
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
def increment_usage(
|
||||||
|
self, user_id: str, docs: int = 0, pages: int = 0, api_calls: int = 0
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Increment usage counters atomically via SQL UPDATE."""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
self.db.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.id == user_id)
|
||||||
|
.values(
|
||||||
|
docs_translated_this_month=User.docs_translated_this_month + docs,
|
||||||
|
pages_translated_this_month=User.pages_translated_this_month + pages,
|
||||||
|
api_calls_this_month=User.api_calls_this_month + api_calls,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
return self.get_by_id(user_id)
|
||||||
|
|
||||||
def add_credits(self, user_id: str, credits: int) -> Optional[User]:
|
def add_credits(self, user_id: str, credits: int) -> Optional[User]:
|
||||||
"""Add extra credits to user"""
|
"""Add extra credits to user"""
|
||||||
user = self.get_by_id(user_id)
|
user = self.get_by_id(user_id)
|
||||||
@@ -133,14 +144,21 @@ class UserRepository:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
def use_credits(self, user_id: str, credits: int) -> bool:
|
def use_credits(self, user_id: str, credits: int) -> bool:
|
||||||
"""Use credits from user balance"""
|
"""Use credits from user balance atomically (prevents overdraft)."""
|
||||||
user = self.get_by_id(user_id)
|
from sqlalchemy import update
|
||||||
if not user or user.extra_credits < credits:
|
|
||||||
return False
|
|
||||||
|
|
||||||
user.extra_credits -= credits
|
now = datetime.now(timezone.utc)
|
||||||
|
result = self.db.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.id == user_id, User.extra_credits >= credits)
|
||||||
|
.values(
|
||||||
|
extra_credits=User.extra_credits - credits,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
return result.rowcount > 0
|
||||||
|
|
||||||
def get_all_users(
|
def get_all_users(
|
||||||
self, skip: int = 0, limit: int = 100, plan: Optional[PlanType] = None
|
self, skip: int = 0, limit: int = 100, plan: Optional[PlanType] = None
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -47,6 +52,11 @@ services:
|
|||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -84,6 +94,11 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -119,6 +134,11 @@ services:
|
|||||||
- "${FRONTEND_PORT:-3010}:3000"
|
- "${FRONTEND_PORT:-3010}:3000"
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -144,6 +164,11 @@ services:
|
|||||||
- nginx_cache:/var/cache/nginx
|
- nginx_cache:/var/cache/nginx
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
# docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||||
#
|
#
|
||||||
# Access:
|
# Access:
|
||||||
# Grafana: http://<IP_SERVEUR>:3001 (admin / WordlyGrafana2026!)
|
# Grafana: http://<IP_SERVEUR>:3001 (admin / $GRAFANA_PASSWORD)
|
||||||
# Ou via NPM: monitoring.wordly.art -> wordly-grafana:3000
|
# Ou via NPM: monitoring.wordly.art -> wordly-grafana:3000
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ services:
|
|||||||
container_name: wordly-grafana
|
container_name: wordly-grafana
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- GF_SECURITY_ADMIN_USER=admin
|
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=WordlyGrafana2026!
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
- GF_SERVER_ROOT_URL=https://monitoring.wordly.art
|
- GF_SERVER_ROOT_URL=https://monitoring.wordly.art
|
||||||
- GF_INSTALL_PLUGINS=grafana-clock-panel
|
- GF_INSTALL_PLUGINS=grafana-clock-panel
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ services:
|
|||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -53,6 +58,11 @@ services:
|
|||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -77,7 +87,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-translate}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-translate_db}
|
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-translate}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-translate_db}
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- TRANSLATION_SERVICE=${TRANSLATION_SERVICE:-ollama}
|
- TRANSLATION_SERVICE=${TRANSLATION_SERVICE:-google}
|
||||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
|
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3}
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3}
|
||||||
- DEEPL_API_KEY=${DEEPL_API_KEY:-}
|
- DEEPL_API_KEY=${DEEPL_API_KEY:-}
|
||||||
@@ -116,6 +126,11 @@ services:
|
|||||||
- logs_data:/app/logs
|
- logs_data:/app/logs
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -144,6 +159,7 @@ services:
|
|||||||
target: production
|
target: production
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_API_URL: ""
|
NEXT_PUBLIC_API_URL: ""
|
||||||
|
NEXT_PUBLIC_GOOGLE_CLIENT_ID: "${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
|
||||||
container_name: wordly-frontend
|
container_name: wordly-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -155,6 +171,11 @@ services:
|
|||||||
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -176,6 +197,11 @@ services:
|
|||||||
- ollama_data:/root/.ollama
|
- ollama_data:/root/.ollama
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
reservations:
|
reservations:
|
||||||
@@ -205,6 +231,11 @@ services:
|
|||||||
- '--web.enable-lifecycle'
|
- '--web.enable-lifecycle'
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -220,7 +251,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
|
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-WordlyGrafana2026!}
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
- GF_SERVER_ROOT_URL=https://monitoring.wordly.art
|
- GF_SERVER_ROOT_URL=https://monitoring.wordly.art
|
||||||
- GF_INSTALL_PLUGINS=grafana-clock-panel
|
- GF_INSTALL_PLUGINS=grafana-clock-panel
|
||||||
@@ -232,6 +263,11 @@ services:
|
|||||||
- "3001:3000"
|
- "3001:3000"
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
prometheus:
|
prometheus:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -260,6 +296,11 @@ services:
|
|||||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|etc|var/lib/docker)($$|/)'
|
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|etc|var/lib/docker)($$|/)'
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# cAdvisor - Container Metrics
|
# cAdvisor - Container Metrics
|
||||||
@@ -279,6 +320,11 @@ services:
|
|||||||
- /dev/kmsg
|
- /dev/kmsg
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Networks
|
# Networks
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ COPY frontend/ .
|
|||||||
# NEXT_PUBLIC_API_URL: client-side API base (empty = relative URLs via proxy)
|
# NEXT_PUBLIC_API_URL: client-side API base (empty = relative URLs via proxy)
|
||||||
# BACKEND_URL: server-side proxy target (must be resolvable from container)
|
# BACKEND_URL: server-side proxy target (must be resolvable from container)
|
||||||
ARG NEXT_PUBLIC_API_URL=
|
ARG NEXT_PUBLIC_API_URL=
|
||||||
|
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||||
ARG BACKEND_URL=http://backend:8000
|
ARG BACKEND_URL=http://backend:8000
|
||||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
|
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID}
|
||||||
ENV BACKEND_URL=${BACKEND_URL}
|
ENV BACKEND_URL=${BACKEND_URL}
|
||||||
|
|
||||||
# Build the application (with standalone output)
|
# Build the application (with standalone output)
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import type { NextConfig } from "next";
|
|||||||
const backendUrl = (process.env.BACKEND_URL || "http://127.0.0.1:8000").replace(/\/$/, "");
|
const backendUrl = (process.env.BACKEND_URL || "http://127.0.0.1:8000").replace(/\/$/, "");
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Docker: standalone output for optimized production images
|
// Docker: standalone output for optimized production images.
|
||||||
output: "standalone",
|
// On Windows without Developer Mode / admin rights the standalone symlink
|
||||||
|
// copy can fail with EPERM. Set NEXT_OUTPUT=default to disable standalone.
|
||||||
|
output: process.env.NEXT_OUTPUT === "default" ? undefined : "standalone",
|
||||||
// Turbopack ne résout pas le require() dynamique de lightningcss → "Module not found".
|
// Turbopack ne résout pas le require() dynamique de lightningcss → "Module not found".
|
||||||
// Toujours lancer avec Webpack : npm run dev ou next dev --webpack (pas "next dev" seul).
|
// Toujours lancer avec Webpack : npm run dev ou next dev --webpack (pas "next dev" seul).
|
||||||
serverExternalPackages: ["lightningcss", "@tailwindcss/postcss", "@tailwindcss/node"],
|
serverExternalPackages: ["lightningcss", "@tailwindcss/postcss", "@tailwindcss/node"],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"dev": "next dev --webpack",
|
"dev": "next dev --webpack",
|
||||||
"dev:turbo": "next dev",
|
"dev:turbo": "next dev",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
|
"build:local": "cross-env NEXT_OUTPUT=default next build --webpack",
|
||||||
"build:turbo": "next build",
|
"build:turbo": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
"agentation-mcp": "^1.2.0",
|
"agentation-mcp": "^1.2.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "11.18.2",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.6",
|
"next": "16.0.6",
|
||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"agentation": "^3.0.2",
|
"agentation": "^3.0.2",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.6",
|
"eslint-config-next": "16.0.6",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
|
|||||||
7833
frontend/pnpm-lock.yaml
generated
Normal file
7833
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -535,7 +535,7 @@ export default function AdminSettingsPage() {
|
|||||||
|
|
||||||
<ProviderCard
|
<ProviderCard
|
||||||
title="Traduction IA Premium"
|
title="Traduction IA Premium"
|
||||||
description="Affichée aux utilisateurs comme 'Traduction IA Premium'. Modèles haute qualité : openai/gpt-4o, anthropic/claude-3.5-sonnet, google/gemini-1.5-pro. Partage la même clé OpenRouter."
|
description="Affichée aux utilisateurs comme 'Traduction IA Premium'. Modèles haute qualité : openai/gpt-4o, anthropic/claude-sonnet-4.6, google/gemini-3.5-pro. Partage la même clé OpenRouter."
|
||||||
enabled={config.openrouter_premium.enabled}
|
enabled={config.openrouter_premium.enabled}
|
||||||
onToggle={(enabled) => updateProvider("openrouter_premium", { enabled })}
|
onToggle={(enabled) => updateProvider("openrouter_premium", { enabled })}
|
||||||
onTest={() => testProvider("openrouter_premium")}
|
onTest={() => testProvider("openrouter_premium")}
|
||||||
@@ -552,10 +552,10 @@ export default function AdminSettingsPage() {
|
|||||||
isLoading={loadingModelsProvider === "openrouter"}
|
isLoading={loadingModelsProvider === "openrouter"}
|
||||||
onFetchModels={() => fetchModels("openrouter")}
|
onFetchModels={() => fetchModels("openrouter")}
|
||||||
providerLabel="OpenRouter"
|
providerLabel="OpenRouter"
|
||||||
placeholder="openai/gpt-4o-mini"
|
placeholder="anthropic/claude-sonnet-4.6"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Recommandé : <code>openai/gpt-4o-mini</code> (~€0.15/doc) ou <code>anthropic/claude-3.5-haiku</code> (~€0.20/doc)
|
Recommandé : <code>anthropic/claude-sonnet-4.6</code> (~€0.20/doc) ou <code>openai/gpt-4o</code> (~€0.30/doc)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</ProviderCard>
|
</ProviderCard>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react';
|
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react';
|
||||||
@@ -14,6 +15,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { apiClient } from '@/lib/apiClient';
|
import { apiClient } from '@/lib/apiClient';
|
||||||
import { useLogin } from './useLogin';
|
import { useLogin } from './useLogin';
|
||||||
import type { GoogleAuthResponse } from './types';
|
import type { GoogleAuthResponse } from './types';
|
||||||
|
import { useGoogleConfig } from '@/providers/ClientGoogleProvider';
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -28,7 +30,9 @@ export function LoginForm() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const redirect = searchParams.get('redirect') || '/dashboard';
|
const redirect = searchParams.get('redirect') || '/dashboard';
|
||||||
|
|
||||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
|
const { clientId: googleClientId, enabled: googleEnabled } = useGoogleConfig();
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loginMutation.isError && loginMutation.error) {
|
if (loginMutation.isError && loginMutation.error) {
|
||||||
@@ -38,7 +42,7 @@ export function LoginForm() {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [loginMutation.isError, loginMutation.error, notify]);
|
}, [loginMutation.isError, loginMutation.error, notify, t]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -54,6 +58,7 @@ export function LoginForm() {
|
|||||||
{ credential: credentialResponse.credential },
|
{ credential: credentialResponse.credential },
|
||||||
);
|
);
|
||||||
const { access_token, refresh_token } = response.data;
|
const { access_token, refresh_token } = response.data;
|
||||||
|
queryClient.clear();
|
||||||
localStorage.setItem('token', access_token);
|
localStorage.setItem('token', access_token);
|
||||||
localStorage.setItem('refresh_token', refresh_token);
|
localStorage.setItem('refresh_token', refresh_token);
|
||||||
router.push(redirect);
|
router.push(redirect);
|
||||||
@@ -66,7 +71,7 @@ export function LoginForm() {
|
|||||||
} finally {
|
} finally {
|
||||||
setGoogleLoading(false);
|
setGoogleLoading(false);
|
||||||
}
|
}
|
||||||
}, [redirect, router, notify, t]);
|
}, [redirect, router, notify, t, queryClient]);
|
||||||
|
|
||||||
const handleGoogleError = useCallback(() => {
|
const handleGoogleError = useCallback(() => {
|
||||||
notify({
|
notify({
|
||||||
@@ -97,7 +102,7 @@ export function LoginForm() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{googleClientId && (
|
{googleEnabled && googleClientId && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{googleLoading ? (
|
{googleLoading ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { apiClient, ApiClientError } from '@/lib/apiClient';
|
import { apiClient, ApiClientError } from '@/lib/apiClient';
|
||||||
import type { LoginRequest, LoginResponse } from './types';
|
import type { LoginRequest, LoginResponse } from './types';
|
||||||
@@ -9,6 +9,7 @@ export function useLogin() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const redirect = searchParams.get('redirect') || '/dashboard';
|
const redirect = searchParams.get('redirect') || '/dashboard';
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<LoginResponse, ApiClientError, LoginRequest>({
|
return useMutation<LoginResponse, ApiClientError, LoginRequest>({
|
||||||
mutationFn: async (credentials: LoginRequest) => {
|
mutationFn: async (credentials: LoginRequest) => {
|
||||||
@@ -19,6 +20,7 @@ export function useLogin() {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
queryClient.clear();
|
||||||
localStorage.setItem('token', data.access_token);
|
localStorage.setItem('token', data.access_token);
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
router.push(redirect);
|
router.push(redirect);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { apiClient } from '@/lib/apiClient';
|
|||||||
import { useRegister } from './useRegister';
|
import { useRegister } from './useRegister';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { GoogleAuthResponse } from '../login/types';
|
import type { GoogleAuthResponse } from '../login/types';
|
||||||
|
import { useGoogleConfig } from '@/providers/ClientGoogleProvider';
|
||||||
|
|
||||||
function validateEmail(email: string) {
|
function validateEmail(email: string) {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
@@ -85,7 +86,7 @@ export function RegisterForm() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const redirect = searchParams.get('redirect') || '/dashboard';
|
const redirect = searchParams.get('redirect') || '/dashboard';
|
||||||
|
|
||||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
|
const { clientId: googleClientId, enabled: googleEnabled } = useGoogleConfig();
|
||||||
|
|
||||||
const nameError = touched.name && name.length > 0 && name.length < 2
|
const nameError = touched.name && name.length > 0 && name.length < 2
|
||||||
? t('register.name.error')
|
? t('register.name.error')
|
||||||
@@ -179,7 +180,7 @@ export function RegisterForm() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
{googleClientId && (
|
{googleEnabled && googleClientId && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{googleLoading ? (
|
{googleLoading ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { apiClient } from '@/lib/apiClient';
|
import { apiClient } from '@/lib/apiClient';
|
||||||
import type { RegisterRequest, RegisterResponse } from './types';
|
import type { RegisterRequest, RegisterResponse } from './types';
|
||||||
@@ -15,6 +15,7 @@ export function useRegister() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const redirect = searchParams.get('redirect') || '/dashboard';
|
const redirect = searchParams.get('redirect') || '/dashboard';
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data: RegisterRequest) => {
|
mutationFn: async (data: RegisterRequest) => {
|
||||||
@@ -27,6 +28,7 @@ export function useRegister() {
|
|||||||
return loginResponse.data;
|
return loginResponse.data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
queryClient.clear();
|
||||||
localStorage.setItem('token', data.access_token);
|
localStorage.setItem('token', data.access_token);
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
router.push(redirect);
|
router.push(redirect);
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
import { CheckCircle2, XCircle, RefreshCw } from 'lucide-react';
|
import { CheckCircle2, XCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /checkout/success
|
* /checkout/success
|
||||||
@@ -15,6 +17,8 @@ export default function CheckoutSuccessPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sessionId = searchParams.get('session_id');
|
const sessionId = searchParams.get('session_id');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [status, setStatus] = useState<'syncing' | 'ok' | 'error'>('syncing');
|
const [status, setStatus] = useState<'syncing' | 'ok' | 'error'>('syncing');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
@@ -41,18 +45,23 @@ export default function CheckoutSuccessPage() {
|
|||||||
try { data = await res.json(); } catch { /* ignore */ }
|
try { data = await res.json(); } catch { /* ignore */ }
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setStatus('ok');
|
setStatus('ok');
|
||||||
setMessage(data.data?.plan ? `Forfait ${data.data.plan} activé !` : 'Abonnement activé !');
|
setMessage(
|
||||||
|
data.data?.plan
|
||||||
|
? t('checkout.planActivated', { plan: data.data.plan })
|
||||||
|
: t('checkout.subscriptionActivated')
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user', 'me'] });
|
||||||
// Redirect after 2s
|
// Redirect after 2s
|
||||||
setTimeout(() => router.replace('/dashboard/profile?tab=subscription'), 2000);
|
setTimeout(() => router.replace('/dashboard/profile?tab=subscription'), 2000);
|
||||||
} else {
|
} else {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setMessage(data.message ?? data.error ?? 'Erreur de synchronisation');
|
setMessage(data.message ?? data.error ?? t('checkout.syncError'));
|
||||||
setTimeout(() => router.replace('/dashboard/profile?tab=subscription'), 3000);
|
setTimeout(() => router.replace('/dashboard/profile?tab=subscription'), 3000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setMessage('Erreur réseau. Votre paiement est confirmé — rechargez votre profil.');
|
setMessage(t('checkout.networkError'));
|
||||||
setTimeout(() => router.replace('/dashboard/profile?tab=subscription'), 3000);
|
setTimeout(() => router.replace('/dashboard/profile?tab=subscription'), 3000);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -64,24 +73,24 @@ export default function CheckoutSuccessPage() {
|
|||||||
{status === 'syncing' && (
|
{status === 'syncing' && (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="w-12 h-12 text-brand-accent animate-spin" />
|
<RefreshCw className="w-12 h-12 text-brand-accent animate-spin" />
|
||||||
<h1 className="text-2xl font-black uppercase tracking-tight">Activation en cours…</h1>
|
<h1 className="text-2xl font-black uppercase tracking-tight">{t('checkout.activating')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">Nous mettons à jour votre abonnement, veuillez patienter.</p>
|
<p className="text-sm text-muted-foreground">{t('checkout.activatingDesc')}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === 'ok' && (
|
{status === 'ok' && (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 className="w-12 h-12 text-emerald-500" />
|
<CheckCircle2 className="w-12 h-12 text-emerald-500" />
|
||||||
<h1 className="text-2xl font-black uppercase tracking-tight text-emerald-600">Paiement confirmé !</h1>
|
<h1 className="text-2xl font-black uppercase tracking-tight text-emerald-600">{t('checkout.paymentConfirmed')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{message}</p>
|
<p className="text-sm text-muted-foreground">{message}</p>
|
||||||
<p className="text-xs text-muted-foreground">Redirection vers votre profil…</p>
|
<p className="text-xs text-muted-foreground">{t('checkout.redirectingToProfile')}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<>
|
<>
|
||||||
<XCircle className="w-12 h-12 text-amber-500" />
|
<XCircle className="w-12 h-12 text-amber-500" />
|
||||||
<h1 className="text-2xl font-black uppercase tracking-tight">Paiement reçu</h1>
|
<h1 className="text-2xl font-black uppercase tracking-tight">{t('checkout.paymentReceived')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{message}</p>
|
<p className="text-sm text-muted-foreground">{message}</p>
|
||||||
<p className="text-xs text-muted-foreground">Redirection…</p>
|
<p className="text-xs text-muted-foreground">{t('checkout.redirecting')}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{copiedId === key.id ? 'Copied!' : t('apiKeys.table.copyPrefix')}
|
{copiedId === key.id ? t('apiKeys.copied') : t('apiKeys.table.copyPrefix')}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export default function ApiKeysPage() {
|
|||||||
{t('apiKeys.sectionTitle')}
|
{t('apiKeys.sectionTitle')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-mono text-brand-dark dark:text-white">
|
<p className="text-sm font-mono text-brand-dark dark:text-white">
|
||||||
{keys.length > 0 ? `${keys[0].key_prefix}************************************` : 'No keys generated'}
|
{keys.length > 0 ? `${keys[0].key_prefix}************************************` : t('apiKeys.noKeysGenerated')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -1,315 +1,45 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { TermEditor } from './TermEditor';
|
import { TermEditor } from './TermEditor';
|
||||||
import { parseFileToTerms } from './csvUtils';
|
|
||||||
import { useGlossaryTemplates } from './useGlossaries';
|
|
||||||
import type { GlossaryTermInput } from './types';
|
import type { GlossaryTermInput } from './types';
|
||||||
import type { GlossaryTemplate } from './useGlossaries';
|
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import {
|
import { Loader2, PenLine } from 'lucide-react';
|
||||||
Upload,
|
|
||||||
FileText,
|
|
||||||
BookOpen,
|
|
||||||
PenLine,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
Loader2,
|
|
||||||
X,
|
|
||||||
Scale,
|
|
||||||
Cpu,
|
|
||||||
TrendingUp,
|
|
||||||
HeartPulse,
|
|
||||||
Megaphone,
|
|
||||||
Users,
|
|
||||||
FlaskConical,
|
|
||||||
ShoppingCart,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
import { SUPPORTED_LANGUAGES } from './types';
|
import { SUPPORTED_LANGUAGES } from './types';
|
||||||
|
|
||||||
interface CreateGlossaryDialogProps {
|
interface CreateGlossaryDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onCreate: (data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => Promise<void>;
|
onCreate: (data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => Promise<void>;
|
||||||
onImportTemplate: (templateId: string, name?: string) => Promise<void>;
|
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
isImportingTemplate: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEMPLATE_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
legal: <Scale className="size-5" />,
|
|
||||||
technology: <Cpu className="size-5" />,
|
|
||||||
finance: <TrendingUp className="size-5" />,
|
|
||||||
medical: <HeartPulse className="size-5" />,
|
|
||||||
marketing: <Megaphone className="size-5" />,
|
|
||||||
hr: <Users className="size-5" />,
|
|
||||||
scientific: <FlaskConical className="size-5" />,
|
|
||||||
ecommerce: <ShoppingCart className="size-5" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEMPLATE_COLORS: Record<string, string> = {
|
|
||||||
legal: 'bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100',
|
|
||||||
technology: 'bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100',
|
|
||||||
finance: 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100',
|
|
||||||
medical: 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100',
|
|
||||||
marketing: 'bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100',
|
|
||||||
hr: 'bg-teal-50 text-teal-700 border-teal-200 hover:bg-teal-100',
|
|
||||||
scientific: 'bg-indigo-50 text-indigo-700 border-indigo-200 hover:bg-indigo-100',
|
|
||||||
ecommerce: 'bg-pink-50 text-pink-700 border-pink-200 hover:bg-pink-100',
|
|
||||||
};
|
|
||||||
|
|
||||||
type FileStatus = 'idle' | 'parsing' | 'success' | 'error';
|
|
||||||
|
|
||||||
const MAX_FILE_SIZE_MB = 5;
|
|
||||||
|
|
||||||
function TemplateCard({
|
|
||||||
template,
|
|
||||||
onSelect,
|
|
||||||
isLoading,
|
|
||||||
termsLabel,
|
|
||||||
}: {
|
|
||||||
template: GlossaryTemplate;
|
|
||||||
onSelect: (t: GlossaryTemplate) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
termsLabel: string;
|
|
||||||
}) {
|
|
||||||
const icon = TEMPLATE_ICONS[template.id] ?? <BookOpen className="size-5" />;
|
|
||||||
const colorClass = TEMPLATE_COLORS[template.id] ?? 'bg-muted text-foreground border-border hover:bg-muted/80';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(template)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col gap-2 rounded-lg border p-3 text-start transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
colorClass
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{icon}
|
|
||||||
<span className="text-sm font-medium leading-tight">
|
|
||||||
{template.name.split(' - ')[0]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary" className="shrink-0 text-xs font-normal">
|
|
||||||
{template.terms_count} {termsLabel}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs opacity-75 line-clamp-2">{template.description}</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileUploadZone({
|
|
||||||
onTermsParsed,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
onTermsParsed: (terms: GlossaryTermInput[], filename: string) => void;
|
|
||||||
disabled: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [status, setStatus] = useState<FileStatus>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
const [parsedFile, setParsedFile] = useState<{ name: string; count: number } | null>(null);
|
|
||||||
|
|
||||||
const processFile = useCallback(async (file: File) => {
|
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
|
||||||
const allowed = ['csv', 'xlsx', 'xls', 'ods', 'txt', 'tsv'];
|
|
||||||
if (!ext || !allowed.includes(ext)) {
|
|
||||||
setStatus('error');
|
|
||||||
setErrorMsg(t('glossaries.dialog.errorFormat'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
|
|
||||||
setStatus('error');
|
|
||||||
setErrorMsg(t('glossaries.dialog.errorSize', { max: String(MAX_FILE_SIZE_MB) }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus('parsing');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
const terms = await parseFileToTerms(file);
|
|
||||||
if (terms.length === 0) {
|
|
||||||
setStatus('error');
|
|
||||||
setErrorMsg(t('glossaries.dialog.errorEmpty'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus('success');
|
|
||||||
setParsedFile({ name: file.name, count: terms.length });
|
|
||||||
onTermsParsed(terms, file.name);
|
|
||||||
} catch {
|
|
||||||
setStatus('error');
|
|
||||||
setErrorMsg(t('glossaries.dialog.errorRead'));
|
|
||||||
}
|
|
||||||
}, [onTermsParsed, t]);
|
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
const file = e.dataTransfer.files[0];
|
|
||||||
if (file) processFile(file);
|
|
||||||
}, [processFile]);
|
|
||||||
|
|
||||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) processFile(file);
|
|
||||||
e.target.value = '';
|
|
||||||
}, [processFile]);
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setStatus('idle');
|
|
||||||
setParsedFile(null);
|
|
||||||
setErrorMsg('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div
|
|
||||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
|
||||||
onDragLeave={() => setIsDragging(false)}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
|
||||||
className={cn(
|
|
||||||
'relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors cursor-pointer',
|
|
||||||
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/30',
|
|
||||||
disabled && 'opacity-50 cursor-not-allowed',
|
|
||||||
status === 'success' && 'border-green-400 bg-green-50',
|
|
||||||
status === 'error' && 'border-destructive/50 bg-destructive/5'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{status === 'parsing' && (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-8 animate-spin text-primary" />
|
|
||||||
<p className="text-sm text-muted-foreground">{t('glossaries.dialog.parsing')}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'success' && parsedFile && (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="size-8 text-green-600" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-green-700">
|
|
||||||
{t('glossaries.dialog.termsImported', { count: String(parsedFile.count) })}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{parsedFile.name}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => { e.stopPropagation(); reset(); }}
|
|
||||||
className="gap-1.5 text-xs"
|
|
||||||
>
|
|
||||||
<X className="size-3.5" /> {t('glossaries.dialog.changeFile')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'error' && (
|
|
||||||
<>
|
|
||||||
<AlertCircle className="size-8 text-destructive" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-destructive">{errorMsg}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => { e.stopPropagation(); reset(); }}
|
|
||||||
className="gap-1.5 text-xs"
|
|
||||||
>
|
|
||||||
<X className="size-3.5" /> {t('glossaries.dialog.retry')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'idle' && (
|
|
||||||
<>
|
|
||||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
|
||||||
<Upload className="size-6 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">{t('glossaries.dialog.dropTitle')}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{t('glossaries.dialog.dropOr')}</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('glossaries.dialog.dropFormats')}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1">
|
|
||||||
<p className="font-medium">{t('glossaries.dialog.formatTitle')}</p>
|
|
||||||
<p>{t('glossaries.dialog.formatDesc')}</p>
|
|
||||||
<div className="font-mono bg-background rounded border px-2 py-1 mt-1">
|
|
||||||
<div className="text-muted-foreground">source,target</div>
|
|
||||||
<div>server,server</div>
|
|
||||||
<div>database,database</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1">{t('glossaries.dialog.formatNote')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateGlossaryDialog({
|
export function CreateGlossaryDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCreate,
|
onCreate,
|
||||||
onImportTemplate,
|
|
||||||
isCreating,
|
isCreating,
|
||||||
isImportingTemplate,
|
|
||||||
}: CreateGlossaryDialogProps) {
|
}: CreateGlossaryDialogProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [activeTab, setActiveTab] = useState<'templates' | 'file' | 'manual'>('templates');
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [nameAutoFilled, setNameAutoFilled] = useState(false);
|
|
||||||
const [sourceLanguage, setSourceLanguage] = useState('fr');
|
const [sourceLanguage, setSourceLanguage] = useState('fr');
|
||||||
const [targetLanguage, setTargetLanguage] = useState('multi');
|
const [targetLanguage, setTargetLanguage] = useState('multi');
|
||||||
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
|
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
|
||||||
const [fileTerms, setFileTerms] = useState<GlossaryTermInput[]>([]);
|
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<GlossaryTemplate | null>(null);
|
|
||||||
|
|
||||||
const { templates, isLoading: isLoadingTemplates } = useGlossaryTemplates();
|
|
||||||
|
|
||||||
const isProcessing = isCreating || isImportingTemplate;
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setName('');
|
setName('');
|
||||||
setNameAutoFilled(false);
|
|
||||||
setSourceLanguage('fr');
|
setSourceLanguage('fr');
|
||||||
setTargetLanguage('multi');
|
setTargetLanguage('multi');
|
||||||
setTerms([{ source: '', target: '' }]);
|
setTerms([{ source: '', target: '' }]);
|
||||||
setFileTerms([]);
|
|
||||||
setSelectedTemplate(null);
|
|
||||||
setActiveTab('templates');
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||||
@@ -317,111 +47,77 @@ export function CreateGlossaryDialog({
|
|||||||
onOpenChange(newOpen);
|
onOpenChange(newOpen);
|
||||||
}, [onOpenChange, reset]);
|
}, [onOpenChange, reset]);
|
||||||
|
|
||||||
const handleTemplateSelect = useCallback((template: GlossaryTemplate) => {
|
|
||||||
setSelectedTemplate(template);
|
|
||||||
if (!name || nameAutoFilled) {
|
|
||||||
setName(template.name.split(' - ')[0]);
|
|
||||||
setNameAutoFilled(true);
|
|
||||||
}
|
|
||||||
}, [name, nameAutoFilled]);
|
|
||||||
|
|
||||||
const handleFileTermsParsed = useCallback((parsed: GlossaryTermInput[], filename: string) => {
|
|
||||||
setFileTerms(parsed);
|
|
||||||
if (!name || nameAutoFilled) {
|
|
||||||
const baseName = filename.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
|
|
||||||
setName(baseName);
|
|
||||||
setNameAutoFilled(true);
|
|
||||||
}
|
|
||||||
}, [name, nameAutoFilled]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
|
const termsToSave = terms.filter(t => t.source.trim() && t.target.trim());
|
||||||
if (activeTab === 'templates' && selectedTemplate) {
|
|
||||||
await onImportTemplate(selectedTemplate.id, name.trim());
|
|
||||||
reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const termsToSave = activeTab === 'file'
|
|
||||||
? fileTerms
|
|
||||||
: terms.filter(t => t.source.trim() && t.target.trim());
|
|
||||||
await onCreate({ name: name.trim(), source_language: sourceLanguage, target_language: targetLanguage, terms: termsToSave });
|
await onCreate({ name: name.trim(), source_language: sourceLanguage, target_language: targetLanguage, terms: termsToSave });
|
||||||
reset();
|
reset();
|
||||||
}, [activeTab, selectedTemplate, name, fileTerms, terms, sourceLanguage, targetLanguage, onCreate, onImportTemplate, reset]);
|
}, [name, terms, sourceLanguage, targetLanguage, onCreate, reset]);
|
||||||
|
|
||||||
const canSubmit = (() => {
|
const canSubmit = !!(name.trim() && !isCreating && terms.some(t => t.source.trim() && t.target.trim()));
|
||||||
if (!name.trim() || isProcessing) return false;
|
|
||||||
if (activeTab === 'templates') return !!selectedTemplate;
|
|
||||||
if (activeTab === 'file') return fileTerms.length > 0;
|
|
||||||
return terms.some(t => t.source.trim() && t.target.trim());
|
|
||||||
})();
|
|
||||||
|
|
||||||
const submitLabel = (() => {
|
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
|
||||||
if (isProcessing) {
|
const submitLabel = isCreating
|
||||||
return activeTab === 'templates'
|
? t('glossaries.dialog.creating') || 'Création…'
|
||||||
? t('glossaries.dialog.importing')
|
: validTermsCount > 0
|
||||||
: t('glossaries.dialog.creating');
|
? t('glossaries.dialog.createBtn', { count: String(validTermsCount) }) || `Créer (${validTermsCount})`
|
||||||
}
|
: t('glossaries.dialog.createEmpty') || 'Créer vide';
|
||||||
if (activeTab === 'templates') {
|
|
||||||
return selectedTemplate
|
|
||||||
? t('glossaries.dialog.importBtn', { count: String(selectedTemplate.terms_count) })
|
|
||||||
: t('glossaries.dialog.selectPrompt');
|
|
||||||
}
|
|
||||||
if (activeTab === 'file') {
|
|
||||||
return fileTerms.length > 0
|
|
||||||
? t('glossaries.dialog.importBtn', { count: String(fileTerms.length) })
|
|
||||||
: t('glossaries.dialog.dropTitle');
|
|
||||||
}
|
|
||||||
const count = terms.filter(t => t.source.trim() && t.target.trim()).length;
|
|
||||||
return count > 0
|
|
||||||
? t('glossaries.dialog.createBtn', { count: String(count) })
|
|
||||||
: t('glossaries.dialog.createEmpty');
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden !rounded-2xl border-black/5 dark:border-white/5 bg-white dark:bg-[#141414] shadow-editorial p-0">
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0 px-8 pt-8 pb-0">
|
||||||
<DialogTitle>{t('glossaries.dialog.title')}</DialogTitle>
|
<DialogTitle className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||||||
<DialogDescription>{t('glossaries.dialog.description')}</DialogDescription>
|
{t('glossaries.dialog.title') || 'Nouveau glossaire'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light">
|
||||||
|
{t('glossaries.dialog.description') || 'Créez un glossaire pour vos traductions'}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="shrink-0 space-y-3 pt-2">
|
<div className="shrink-0 space-y-4 px-8 pt-5">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="glossary-name">{t('glossaries.dialog.nameLabel')}</Label>
|
<Label htmlFor="glossary-name" className="text-xs font-bold text-brand-dark/70 dark:text-white/60 uppercase tracking-wider">
|
||||||
|
{t('glossaries.dialog.nameLabel') || 'Nom'}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="glossary-name"
|
id="glossary-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => { setName(e.target.value); setNameAutoFilled(false); }}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder={t('glossaries.dialog.namePlaceholder')}
|
placeholder={t('glossaries.dialog.namePlaceholder') || 'Mon glossaire'}
|
||||||
disabled={isProcessing}
|
disabled={isCreating}
|
||||||
className="mt-1"
|
className="mt-1.5 bg-brand-muted/30 dark:bg-white/[0.03] border-black/5 dark:border-white/10 rounded-xl focus:ring-brand-accent/20 focus:border-brand-accent/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Language pair selector */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-xs text-muted-foreground mb-1 block">Langue source</Label>
|
<Label htmlFor="glossary-source-lang" className="text-[10px] font-bold text-brand-dark/50 dark:text-white/40 uppercase tracking-wider mb-1.5 block">
|
||||||
|
{t('glossaries.edit.sourceLang') || 'Langue source'}
|
||||||
|
</Label>
|
||||||
<select
|
<select
|
||||||
|
id="glossary-source-lang"
|
||||||
value={sourceLanguage}
|
value={sourceLanguage}
|
||||||
onChange={e => setSourceLanguage(e.target.value)}
|
onChange={e => setSourceLanguage(e.target.value)}
|
||||||
disabled={isProcessing}
|
disabled={isCreating}
|
||||||
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full h-10 rounded-xl border border-black/5 dark:border-white/10 bg-brand-muted/30 dark:bg-white/[0.03] px-3 text-sm text-brand-dark dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30 transition-all"
|
||||||
>
|
>
|
||||||
{SUPPORTED_LANGUAGES.map(l => (
|
{SUPPORTED_LANGUAGES.map(l => (
|
||||||
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-5 text-muted-foreground font-bold">→</div>
|
<div className="pt-5 text-brand-accent font-bold text-lg">→</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-xs text-muted-foreground mb-1 block">Langue cible</Label>
|
<Label htmlFor="glossary-target-lang" className="text-[10px] font-bold text-brand-dark/50 dark:text-white/40 uppercase tracking-wider mb-1.5 block">
|
||||||
|
{t('glossaries.edit.targetLang') || 'Langue cible'}
|
||||||
|
</Label>
|
||||||
<select
|
<select
|
||||||
|
id="glossary-target-lang"
|
||||||
value={targetLanguage}
|
value={targetLanguage}
|
||||||
onChange={e => setTargetLanguage(e.target.value)}
|
onChange={e => setTargetLanguage(e.target.value)}
|
||||||
disabled={isProcessing}
|
disabled={isCreating}
|
||||||
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full h-10 rounded-xl border border-black/5 dark:border-white/10 bg-brand-muted/30 dark:bg-white/[0.03] px-3 text-sm text-brand-dark dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30 transition-all"
|
||||||
>
|
>
|
||||||
{SUPPORTED_LANGUAGES.map(l => (
|
{SUPPORTED_LANGUAGES.map(l => (
|
||||||
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
||||||
@@ -431,95 +127,39 @@ export function CreateGlossaryDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<div className="flex-1 overflow-y-auto px-8 pt-5 min-h-0">
|
||||||
value={activeTab}
|
<div className="flex items-center gap-1.5 mb-3 text-brand-accent">
|
||||||
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
|
<PenLine className="size-3.5" />
|
||||||
className="flex flex-col flex-1 min-h-0 mt-4"
|
<span className="text-xs font-bold uppercase tracking-wider">
|
||||||
>
|
{t('glossaries.dialog.tabManual') || 'Saisie manuelle'}
|
||||||
<TabsList className="shrink-0 w-full grid grid-cols-3">
|
</span>
|
||||||
<TabsTrigger value="templates" className="gap-1.5 text-xs">
|
</div>
|
||||||
<BookOpen className="size-3.5" />
|
<TermEditor
|
||||||
{t('glossaries.dialog.tabTemplates')}
|
terms={terms}
|
||||||
</TabsTrigger>
|
onChange={setTerms}
|
||||||
<TabsTrigger value="file" className="gap-1.5 text-xs">
|
disabled={isCreating}
|
||||||
<FileText className="size-3.5" />
|
/>
|
||||||
{t('glossaries.dialog.tabFile')}
|
</div>
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="manual" className="gap-1.5 text-xs">
|
|
||||||
<PenLine className="size-3.5" />
|
|
||||||
{t('glossaries.dialog.tabManual')}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="templates" className="flex-1 overflow-y-auto mt-4 space-y-3">
|
<div className="shrink-0 px-8 py-5 border-t border-black/5 dark:border-white/5 flex justify-end gap-3">
|
||||||
{isLoadingTemplates ? (
|
<button
|
||||||
<div className="flex items-center justify-center py-10">
|
type="button"
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('glossaries.dialog.templatesDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
{templates.map((template) => (
|
|
||||||
<div
|
|
||||||
key={template.id}
|
|
||||||
className={cn(
|
|
||||||
'relative',
|
|
||||||
selectedTemplate?.id === template.id && 'ring-2 ring-primary ring-offset-1 rounded-lg'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectedTemplate?.id === template.id && (
|
|
||||||
<CheckCircle2 className="absolute -top-1.5 -right-1.5 size-4 text-primary bg-background rounded-full z-10" />
|
|
||||||
)}
|
|
||||||
<TemplateCard
|
|
||||||
template={template}
|
|
||||||
onSelect={handleTemplateSelect}
|
|
||||||
isLoading={isProcessing}
|
|
||||||
termsLabel={t('glossaries.dialog.terms')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{templates.length === 0 && !isLoadingTemplates && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
|
||||||
{t('glossaries.dialog.templatesEmpty')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="file" className="flex-1 overflow-y-auto mt-4">
|
|
||||||
<FileUploadZone
|
|
||||||
onTermsParsed={handleFileTermsParsed}
|
|
||||||
disabled={isProcessing}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="manual" className="flex-1 overflow-y-auto mt-4">
|
|
||||||
<TermEditor
|
|
||||||
terms={terms}
|
|
||||||
onChange={setTerms}
|
|
||||||
disabled={isProcessing}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<DialogFooter className="shrink-0 pt-4 border-t mt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleOpenChange(false)}
|
onClick={() => handleOpenChange(false)}
|
||||||
disabled={isProcessing}
|
disabled={isCreating}
|
||||||
|
className="px-5 py-2.5 bg-brand-muted dark:bg-white/5 text-brand-dark/50 dark:text-white/40 rounded-xl text-[11px] font-bold uppercase tracking-wider hover:text-brand-dark dark:hover:text-white transition-all cursor-pointer disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t('glossaries.dialog.cancel')}
|
{t('glossaries.dialog.cancel') || 'Annuler'}
|
||||||
</Button>
|
</button>
|
||||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
<button
|
||||||
{isProcessing && <Loader2 className="size-3.5 animate-spin me-1.5" />}
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="premium-button px-8 py-2.5 text-[11px] uppercase tracking-widest !rounded-xl flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer font-bold"
|
||||||
|
>
|
||||||
|
{isCreating && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
717
frontend/src/app/dashboard/glossaries/[id]/page.tsx
Normal file
717
frontend/src/app/dashboard/glossaries/[id]/page.tsx
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
ArrowLeft, Library, Calendar, Hash, Save, Trash2, Loader2,
|
||||||
|
CheckCircle2, AlertCircle, Download, Upload, Plus, X, Search,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { useUser } from '@/app/dashboard/useUser';
|
||||||
|
import {
|
||||||
|
useGlossary,
|
||||||
|
useGlossaries,
|
||||||
|
} from '../useGlossaries';
|
||||||
|
import {
|
||||||
|
exportGlossaryToCsv,
|
||||||
|
parseFileToTerms,
|
||||||
|
generateCsvContent,
|
||||||
|
} from '../csvUtils';
|
||||||
|
import { SUPPORTED_LANGUAGES } from '../types';
|
||||||
|
import type { GlossaryTermInput } from '../types';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { ProUpgradePrompt } from '../ProUpgradePrompt';
|
||||||
|
|
||||||
|
const MAX_TERMS = 500;
|
||||||
|
|
||||||
|
/** Source term in the chosen language.
|
||||||
|
* Structure du terme : source=FR (fondation), target=EN (fondation),
|
||||||
|
* translations = 11 autres langues (de, es, it, pt, nl, ru, ja, ko, zh, ar, fa).
|
||||||
|
* - lang vide / 'multi' → '' (pas de choix)
|
||||||
|
* - lang === 'fr' → term.source
|
||||||
|
* - lang === 'en' → term.target (fallback EN depuis le champ legacy)
|
||||||
|
* - autre lang → term.translations[lang]
|
||||||
|
* - si manquant → '' (placeholder, JAMAIS une autre langue en fallback)
|
||||||
|
*/
|
||||||
|
function getDisplaySource(
|
||||||
|
term: { source: string; target: string; translations?: Record<string, string> | null },
|
||||||
|
lang: string,
|
||||||
|
glossarySourceLang: string
|
||||||
|
): string {
|
||||||
|
if (!lang || lang === 'multi') return '';
|
||||||
|
const normalizedLang = lang.toLowerCase();
|
||||||
|
const normalizedSourceLang = glossarySourceLang.toLowerCase();
|
||||||
|
if (normalizedLang === normalizedSourceLang) return term.source;
|
||||||
|
const translations = term.translations || {};
|
||||||
|
return translations[normalizedLang] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Target term in the chosen language.
|
||||||
|
*/
|
||||||
|
function getDisplayTarget(
|
||||||
|
term: { source: string; target: string; translations?: Record<string, string> | null },
|
||||||
|
lang: string,
|
||||||
|
glossaryTargetLang: string
|
||||||
|
): string {
|
||||||
|
if (!lang) return '';
|
||||||
|
const normalizedLang = lang.toLowerCase();
|
||||||
|
const normalizedTargetLang = glossaryTargetLang.toLowerCase();
|
||||||
|
if (normalizedLang === 'multi' || normalizedLang === normalizedTargetLang) return term.target;
|
||||||
|
const translations = term.translations || {};
|
||||||
|
return translations[normalizedLang] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GlossaryDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const id = (params?.id as string) || '';
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||||
|
const { glossary, isLoading, error } = useGlossary(id);
|
||||||
|
const { updateGlossary, deleteGlossary, isUpdating, isDeleting } = useGlossaries();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const isPro = user?.tier === 'pro';
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Editable state
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [sourceLanguage, setSourceLanguage] = useState('fr');
|
||||||
|
const [targetLanguage, setTargetLanguage] = useState('multi');
|
||||||
|
const [terms, setTerms] = useState<GlossaryTermInput[]>([]);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Initialize from glossary data
|
||||||
|
useEffect(() => {
|
||||||
|
if (glossary && !initialized) {
|
||||||
|
setName(glossary.name);
|
||||||
|
setSourceLanguage(glossary.source_language || 'fr');
|
||||||
|
setTargetLanguage(glossary.target_language || 'multi');
|
||||||
|
setTerms(
|
||||||
|
glossary.terms.map((t) => ({
|
||||||
|
source: t.source,
|
||||||
|
target: t.target,
|
||||||
|
translations: t.translations || {},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [glossary, initialized]);
|
||||||
|
|
||||||
|
// Reset on id change
|
||||||
|
useEffect(() => {
|
||||||
|
setInitialized(false);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const hasUnsavedChanges = useCallback(() => {
|
||||||
|
if (!glossary) return false;
|
||||||
|
if (name.trim() !== glossary.name) return true;
|
||||||
|
if (sourceLanguage !== (glossary.source_language || 'fr')) return true;
|
||||||
|
if (targetLanguage !== (glossary.target_language || 'multi')) return true;
|
||||||
|
// Compare normalized term payloads (source + target + translations dict).
|
||||||
|
const normalize = (termList: Array<{ source: string; target: string; translations?: Record<string, string> | null }>) =>
|
||||||
|
termList
|
||||||
|
.filter((t) => t.source.trim() && t.target.trim())
|
||||||
|
.map((t) => {
|
||||||
|
const tr = t.translations || {};
|
||||||
|
const trKeys = Object.keys(tr).sort();
|
||||||
|
return `${t.source}|${t.target}|${trKeys.map((k) => `${k}=${tr[k]}`).join(',')}`;
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join(';;');
|
||||||
|
return normalize(terms) !== normalize(glossary.terms);
|
||||||
|
}, [glossary, name, sourceLanguage, targetLanguage, terms]);
|
||||||
|
|
||||||
|
const validTerms = terms.filter((t) => t.source.trim() && t.target.trim());
|
||||||
|
const validTermsCount = validTerms.length;
|
||||||
|
const isDirty = hasUnsavedChanges();
|
||||||
|
|
||||||
|
const handleAddTerm = () => {
|
||||||
|
if (validTermsCount >= MAX_TERMS) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.detail.maxTermsTitle') || 'Limite atteinte',
|
||||||
|
description: t('glossaries.detail.maxTermsDesc', { max: String(MAX_TERMS) }) ||
|
||||||
|
`Maximum ${MAX_TERMS} termes par glossaire.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTerms([...terms, { source: '', target: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTerm = (index: number) => {
|
||||||
|
setTerms(terms.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTermChange = (index: number, field: 'source' | 'target', value: string) => {
|
||||||
|
if (!glossary) return;
|
||||||
|
setTerms(terms.map((t, i) => {
|
||||||
|
if (i !== index) return t;
|
||||||
|
const translations = { ...(t.translations || {}) } as Record<string, string>;
|
||||||
|
|
||||||
|
const editLang = (field === 'source' ? sourceLanguage : targetLanguage).toLowerCase();
|
||||||
|
const srcLang = glossary.source_language.toLowerCase();
|
||||||
|
const tgtLang = glossary.target_language.toLowerCase();
|
||||||
|
|
||||||
|
if (field === 'source') {
|
||||||
|
if (editLang === srcLang) {
|
||||||
|
return { ...t, source: value };
|
||||||
|
} else {
|
||||||
|
if (editLang && editLang !== 'multi') {
|
||||||
|
translations[editLang] = value;
|
||||||
|
}
|
||||||
|
return { ...t, translations };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (editLang === 'multi' || editLang === tgtLang) {
|
||||||
|
return { ...t, target: value };
|
||||||
|
} else {
|
||||||
|
if (editLang && editLang !== 'multi') {
|
||||||
|
translations[editLang] = value;
|
||||||
|
}
|
||||||
|
return { ...t, translations };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrateTerms = (
|
||||||
|
currentTerms: GlossaryTermInput[],
|
||||||
|
oldSrc: string,
|
||||||
|
newSrc: string,
|
||||||
|
oldTgt: string,
|
||||||
|
newTgt: string
|
||||||
|
): GlossaryTermInput[] => {
|
||||||
|
if (!glossary) return currentTerms;
|
||||||
|
return currentTerms.map(t => {
|
||||||
|
const languagesData: Record<string, string> = { ...(t.translations || {}) };
|
||||||
|
|
||||||
|
const oldSrcVal = oldSrc === glossary.source_language ? t.source : (t.translations?.[oldSrc] || '');
|
||||||
|
const oldTgtVal = oldTgt === 'multi' || oldTgt === glossary.target_language ? t.target : (t.translations?.[oldTgt] || '');
|
||||||
|
|
||||||
|
if (oldSrc && oldSrc !== 'multi') {
|
||||||
|
languagesData[oldSrc] = oldSrcVal;
|
||||||
|
}
|
||||||
|
if (oldTgt) {
|
||||||
|
const langKey = oldTgt === 'multi' ? 'en' : oldTgt;
|
||||||
|
languagesData[langKey] = oldTgtVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSourceVal = languagesData[newSrc] || '';
|
||||||
|
const targetLangKey = newTgt === 'multi' ? 'en' : newTgt;
|
||||||
|
const newTargetVal = languagesData[targetLangKey] || '';
|
||||||
|
|
||||||
|
delete languagesData[newSrc];
|
||||||
|
delete languagesData[targetLangKey];
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: newSourceVal,
|
||||||
|
target: newTargetVal,
|
||||||
|
translations: languagesData
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!glossary || !name.trim()) return;
|
||||||
|
try {
|
||||||
|
await updateGlossary(glossary.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
source_language: sourceLanguage,
|
||||||
|
target_language: targetLanguage,
|
||||||
|
terms: validTerms,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: t('glossaries.detail.savedTitle') || 'Enregistré',
|
||||||
|
description: t('glossaries.detail.savedDesc') || 'Le glossaire a été mis à jour.',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.toast.error') || 'Erreur',
|
||||||
|
description: t('glossaries.toast.errorUpdate') || 'Impossible de mettre à jour le glossaire.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!glossary) return;
|
||||||
|
try {
|
||||||
|
await deleteGlossary(glossary.id);
|
||||||
|
toast({
|
||||||
|
title: t('glossaries.toast.deleted') || 'Supprimé',
|
||||||
|
description: t('glossaries.toast.deletedDesc') || 'Le glossaire a été supprimé.',
|
||||||
|
});
|
||||||
|
router.push('/dashboard/glossaries');
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.toast.error') || 'Erreur',
|
||||||
|
description: t('glossaries.toast.errorDelete') || 'Impossible de supprimer le glossaire.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!glossary) return;
|
||||||
|
const csv = generateCsvContent(validTerms);
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${name.replace(/[^a-z0-9]/gi, '_') || 'glossary'}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportClick = () => fileInputRef.current?.click();
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!glossary) return;
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = '';
|
||||||
|
try {
|
||||||
|
const parsed = await parseFileToTerms(file);
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.detail.importEmptyTitle') || 'Fichier vide',
|
||||||
|
description: t('glossaries.detail.importEmptyDesc') ||
|
||||||
|
'Aucun terme détecté dans ce fichier.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.length > MAX_TERMS) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.detail.maxTermsTitle') || 'Trop de termes',
|
||||||
|
description: t('glossaries.detail.maxTermsDesc', { max: String(MAX_TERMS) }) ||
|
||||||
|
`Maximum ${MAX_TERMS} termes par glossaire.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Auto-save immediately to DB
|
||||||
|
await updateGlossary(glossary.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
source_language: sourceLanguage,
|
||||||
|
target_language: targetLanguage,
|
||||||
|
terms: parsed,
|
||||||
|
});
|
||||||
|
setTerms(parsed);
|
||||||
|
toast({
|
||||||
|
title: t('glossaries.detail.importedTitle') || 'Importé',
|
||||||
|
description: t('glossaries.detail.importedDesc', { count: String(parsed.length) }) ||
|
||||||
|
`${parsed.length} termes importés et enregistrés.`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.toast.error') || 'Erreur',
|
||||||
|
description: t('glossaries.toast.errorUpdate') || 'Impossible de mettre à jour le glossaire.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter terms by search
|
||||||
|
const filteredTerms = useCallback(() => {
|
||||||
|
if (!searchQuery.trim()) return terms.map((t, i) => ({ ...t, _index: i }));
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return terms
|
||||||
|
.map((t, i) => ({ ...t, _index: i }))
|
||||||
|
.filter((t) => t.source.toLowerCase().includes(q) || t.target.toLowerCase().includes(q));
|
||||||
|
}, [terms, searchQuery]);
|
||||||
|
|
||||||
|
if (isLoadingUser) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPro) {
|
||||||
|
return <ProUpgradePrompt />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto" />
|
||||||
|
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light">
|
||||||
|
{t('glossaries.loading') || 'Chargement…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !glossary) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 lg:p-8">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/glossaries"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-dark/60 dark:text-white/60 hover:text-brand-accent mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={12} />
|
||||||
|
{t('glossaries.detail.backToList') || 'Retour aux glossaires'}
|
||||||
|
</Link>
|
||||||
|
<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">
|
||||||
|
<AlertCircle size={32} className="mx-auto text-destructive mb-3" />
|
||||||
|
<p className="text-base font-serif font-medium text-brand-dark dark:text-white mb-1">
|
||||||
|
{t('glossaries.detail.notFoundTitle') || 'Glossaire introuvable'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-brand-dark/50 dark:text-white/50 font-light">
|
||||||
|
{t('glossaries.detail.notFoundDesc') ||
|
||||||
|
'Ce glossaire n\'existe pas ou vous n\'y avez pas accès.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language);
|
||||||
|
const tgtInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto w-full p-6 lg:p-8">
|
||||||
|
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/glossaries"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-dark/60 dark:text-white/60 hover:text-brand-accent mb-6 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={12} />
|
||||||
|
{t('glossaries.detail.backToList') || 'Retour aux glossaires'}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-6 mb-8">
|
||||||
|
<div className="flex items-start gap-4 min-w-0 flex-1">
|
||||||
|
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shrink-0">
|
||||||
|
<Library size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="w-full text-2xl md:text-3xl font-serif font-semibold text-brand-dark dark:text-white tracking-tight leading-tight bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-brand-accent/20 rounded px-2 py-1 -mx-2"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-[11px] text-brand-dark/50 dark:text-white/50 font-light">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{srcInfo?.flag ?? '🌐'} {srcInfo?.label ?? glossary.source_language}
|
||||||
|
<span className="text-brand-accent font-bold mx-1">→</span>
|
||||||
|
{tgtInfo?.flag ?? '🌐'} {tgtInfo?.label ?? glossary.target_language}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="flex items-center gap-1 font-mono">
|
||||||
|
<Hash size={10} /> {validTermsCount} {t('glossaries.defineTerms') || 'termes'}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="flex items-center gap-1 font-mono">
|
||||||
|
<Calendar size={10} /> {new Date(glossary.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{isDirty && (
|
||||||
|
<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">
|
||||||
|
<AlertCircle size={11} />
|
||||||
|
{t('glossaries.status.unsaved') || 'Non enregistré'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isUpdating || !isDirty || !name.trim()}
|
||||||
|
className="premium-button px-6 py-2.5 text-[11px] uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold"
|
||||||
|
>
|
||||||
|
{isUpdating ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||||
|
{t('glossaries.detail.save') || 'Enregistrer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings card */}
|
||||||
|
<section className="editorial-card p-6 lg:p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm mb-6">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60 mb-4">
|
||||||
|
{t('glossaries.detail.settingsTitle') || 'Paramètres'}
|
||||||
|
</h3>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-brand-dark/50 dark:text-white/50 mb-1.5 block">
|
||||||
|
{t('glossaries.detail.sourceLang') || 'Langue source'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={sourceLanguage}
|
||||||
|
onChange={(e) => handleSourceLanguageChange(e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="w-full h-10 rounded-lg border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/20"
|
||||||
|
>
|
||||||
|
{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-brand-dark/40 dark:text-white/40 font-light mt-1.5">
|
||||||
|
{t('glossaries.detail.sourceLangNote') ||
|
||||||
|
'La source originale est en français. Pour les autres langues, on lit le champ translations du terme (s\'il existe).'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-brand-dark/50 dark:text-white/50 mb-1.5 block">
|
||||||
|
{t('glossaries.detail.targetLang') || 'Langue cible'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={targetLanguage}
|
||||||
|
onChange={(e) => handleTargetLanguageChange(e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="w-full h-10 rounded-lg border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/20"
|
||||||
|
>
|
||||||
|
{SUPPORTED_LANGUAGES.map((l) => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.flag} {l.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-[10px] text-brand-dark/40 dark:text-white/40 font-light mt-1.5">
|
||||||
|
{t('glossaries.detail.targetLangNote') ||
|
||||||
|
'Choisissez une langue pour voir les traductions correspondantes, ou « Multilingue » pour la valeur par défaut.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Terms table card */}
|
||||||
|
<section className="editorial-card bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm mb-6 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60">
|
||||||
|
{t('glossaries.detail.termsTitle') || 'Termes'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light mt-0.5">
|
||||||
|
{validTermsCount} / {MAX_TERMS} {t('glossaries.detail.terms') || 'termes'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{terms.length > 5 && (
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={12} className="absolute left-2.5 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.detail.searchTerms') || 'Filtrer…'}
|
||||||
|
className="pl-7 pr-2 py-1.5 text-[11px] w-32 sm:w-40 rounded-md border border-black/5 dark:border-white/10 bg-white dark:bg-[#141414] focus:outline-none focus:ring-2 focus:ring-brand-accent/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{terms.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-xs text-brand-dark/50 dark:text-white/50 font-light mb-4">
|
||||||
|
{t('glossaries.detail.noTerms') || 'Aucun terme pour l\'instant.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleAddTerm}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
{t('glossaries.detail.addFirstTerm') || 'Ajouter le premier terme'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-white dark:bg-[#141414] border-b border-black/5 dark:border-white/5 z-10">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50 px-6 py-2.5 w-[45%]">
|
||||||
|
{t('glossaries.detail.source') || 'Source'}
|
||||||
|
</th>
|
||||||
|
<th className="text-left font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50 px-3 py-2.5 w-[45%]">
|
||||||
|
{t('glossaries.detail.target') || 'Cible'}
|
||||||
|
</th>
|
||||||
|
<th className="w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredTerms().map((term) => (
|
||||||
|
<tr
|
||||||
|
key={term._index}
|
||||||
|
className="border-b border-black/5 dark:border-white/5 last:border-0 group hover:bg-brand-muted/30 dark:hover:bg-white/[0.02]"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-1.5">
|
||||||
|
<input
|
||||||
|
value={getDisplaySource(term, sourceLanguage, glossary.source_language)}
|
||||||
|
onChange={(e) => handleTermChange(term._index, 'source', e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
placeholder={t('glossaries.detail.sourcePlaceholder') || 'terme source'}
|
||||||
|
className="w-full bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-brand-accent/20 rounded px-2 py-1.5 -mx-2 text-brand-dark dark:text-white"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<input
|
||||||
|
value={getDisplayTarget(term, targetLanguage, glossary.target_language)}
|
||||||
|
onChange={(e) => handleTermChange(term._index, 'target', e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
placeholder={t('glossaries.detail.targetPlaceholder') || 'terme cible'}
|
||||||
|
className="w-full bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-brand-accent/20 rounded px-2 py-1.5 -mx-2 text-brand-dark dark:text-white"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveTerm(term._index)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded text-brand-dark/40 dark:text-white/40 hover:text-red-500 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add term footer */}
|
||||||
|
{terms.length > 0 && (
|
||||||
|
<div className="p-4 border-t border-black/5 dark:border-white/5 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={handleAddTerm}
|
||||||
|
disabled={isUpdating || validTermsCount >= MAX_TERMS}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
{t('glossaries.detail.addTerm') || 'Ajouter un terme'}
|
||||||
|
</button>
|
||||||
|
{validTermsCount >= MAX_TERMS && (
|
||||||
|
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
{t('glossaries.detail.maxReached') || 'Limite maximale atteinte'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CSV + Danger zone */}
|
||||||
|
<section className="grid sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60 mb-3">
|
||||||
|
{t('glossaries.detail.csvTitle') || 'CSV'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-light mb-4">
|
||||||
|
{t('glossaries.detail.csvDesc') ||
|
||||||
|
'Exportez vos termes en CSV ou importez-en de nouveaux (remplace la liste actuelle).'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={validTermsCount === 0}
|
||||||
|
className="flex-1 py-2.5 px-3 rounded-lg bg-brand-muted/60 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 disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Download size={11} />
|
||||||
|
{t('glossaries.detail.export') || 'Exporter'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImportClick}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="flex-1 py-2.5 px-3 rounded-lg bg-brand-muted/60 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 disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Upload size={11} />
|
||||||
|
{t('glossaries.detail.import') || 'Importer'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editorial-card p-6 bg-red-500/5 border border-red-500/20 rounded-2xl">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400 mb-3">
|
||||||
|
{t('glossaries.detail.dangerTitle') || 'Zone danger'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-light mb-4">
|
||||||
|
{t('glossaries.detail.dangerDesc') ||
|
||||||
|
'La suppression est définitive. Tous les termes associés seront perdus.'}
|
||||||
|
</p>
|
||||||
|
{!confirmDelete ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="w-full py-2.5 px-3 rounded-lg bg-red-500/10 hover:bg-red-500 hover:text-white text-red-500 text-[10px] font-bold uppercase tracking-wider transition-all disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
{t('glossaries.detail.deleteGlossary') || 'Supprimer ce glossaire'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] text-red-600 dark:text-red-400 font-bold">
|
||||||
|
{t('glossaries.detail.confirmDelete') || 'Confirmer la suppression ?'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 py-2 px-3 rounded-lg bg-brand-muted dark:bg-white/5 text-brand-dark/70 dark:text-white/60 text-[10px] font-bold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{t('glossaries.detail.cancel') || 'Annuler'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 py-2 px-3 rounded-lg bg-red-500 text-white text-[10px] font-bold uppercase tracking-wider hover:bg-red-600 transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />}
|
||||||
|
{t('glossaries.detail.confirm') || 'Confirmer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,81 +1,64 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
BookText, Plus, Library, Calendar, Hash,
|
Library, Calendar, Hash, MessageSquare, Save, Trash2, Loader2,
|
||||||
Zap, Save, Trash2, MessageSquare, Loader2,
|
CheckCircle2, AlertCircle, ArrowRight, Info, Search, Plus,
|
||||||
Monitor, Scale, Stethoscope, BarChart3,
|
Sparkles, Zap
|
||||||
Megaphone, ShoppingCart, FlaskConical, Users,
|
|
||||||
CheckCircle2, AlertCircle, ArrowRight, MousePointerClick,
|
|
||||||
Info, ExternalLink,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useUser } from '@/app/dashboard/useUser';
|
import { useUser } from '@/app/dashboard/useUser';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useGlossaries, useGlossary } from './useGlossaries';
|
import { useGlossaries } from './useGlossaries';
|
||||||
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
|
import type { GlossaryListItem } from './types';
|
||||||
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
||||||
import { CreateGlossaryDialog } from './CreateGlossaryDialog';
|
|
||||||
import { EditGlossaryDialog } from './EditGlossaryDialog';
|
|
||||||
import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
|
|
||||||
import { useToast } from '@/components/ui/toast';
|
import { useToast } from '@/components/ui/toast';
|
||||||
import { SUPPORTED_LANGUAGES } from './types';
|
import { SUPPORTED_LANGUAGES } from './types';
|
||||||
import { useTranslationStore } from '@/lib/store';
|
import { useTranslationStore } from '@/lib/store';
|
||||||
import { API_BASE } from '@/lib/config';
|
|
||||||
|
|
||||||
const PRESETS = [
|
// ── Chips de suggestions pour les consignes de contexte ─────────────────────
|
||||||
{ key: 'it', titleKey: 'glossaries.presets.it.title', descKey: 'glossaries.presets.it.desc', icon: Monitor, templateId: 'technology' },
|
const CONTEXT_SUGGESTIONS = [
|
||||||
{ key: 'legal', titleKey: 'glossaries.presets.legal.title', descKey: 'glossaries.presets.legal.desc', icon: Scale, templateId: 'legal' },
|
{ label: 'Ton formel', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' },
|
||||||
{ key: 'medical', titleKey: 'glossaries.presets.medical.title', descKey: 'glossaries.presets.medical.desc', icon: Stethoscope, templateId: 'medical' },
|
{ label: 'Noms propres', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' },
|
||||||
{ key: 'finance', titleKey: 'glossaries.presets.finance.title', descKey: 'glossaries.presets.finance.desc', icon: BarChart3, templateId: 'finance' },
|
{ label: 'Chiffres', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' },
|
||||||
{ key: 'marketing', titleKey: 'glossaries.presets.marketing.title', descKey: 'glossaries.presets.marketing.desc', icon: Megaphone, templateId: 'marketing' },
|
{ label: 'Placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' },
|
||||||
{ key: 'hr', titleKey: 'glossaries.presets.hr.title', descKey: 'glossaries.presets.hr.desc', icon: Users, templateId: 'hr' },
|
{ label: 'Termes techniques', value: 'Conserve les termes techniques en langue originale et ne les traduis pas.' },
|
||||||
{ key: 'scientific', titleKey: 'glossaries.presets.scientific.title', descKey: 'glossaries.presets.scientific.desc', icon: FlaskConical, templateId: 'scientific' },
|
{ label: 'Style concis', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
|
||||||
{ key: 'ecommerce', titleKey: 'glossaries.presets.ecommerce.title', descKey: 'glossaries.presets.ecommerce.desc', icon: ShoppingCart, templateId: 'ecommerce' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function renderTitle(title: string) {
|
||||||
|
const lastSpaceIndex = title.lastIndexOf(' ');
|
||||||
|
if (lastSpaceIndex === -1) return title;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{title.substring(0, lastSpaceIndex)}{' '}
|
||||||
|
<span className="italic">{title.substring(lastSpaceIndex + 1)}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function GlossariesPage() {
|
export default function GlossariesPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||||
const {
|
const { glossaries, isLoading: isLoadingGlossaries } = useGlossaries();
|
||||||
glossaries,
|
|
||||||
total,
|
|
||||||
isLoading: isLoadingGlossaries,
|
|
||||||
isCreating,
|
|
||||||
isUpdating,
|
|
||||||
isDeleting,
|
|
||||||
isImportingTemplate,
|
|
||||||
createGlossary,
|
|
||||||
updateGlossary,
|
|
||||||
deleteGlossary,
|
|
||||||
importTemplate,
|
|
||||||
} = useGlossaries();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { settings, updateSettings } = useTranslationStore();
|
const { settings, updateSettings } = useTranslationStore();
|
||||||
|
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [selectedGlossary, setSelectedGlossary] = useState<GlossaryListItem | null>(null);
|
|
||||||
const [glossaryToDelete, setGlossaryToDelete] = useState<{ id: string; name: string } | null>(null);
|
|
||||||
const [systemPrompt, setSystemPrompt] = useState(settings.systemPrompt);
|
const [systemPrompt, setSystemPrompt] = useState(settings.systemPrompt);
|
||||||
const [isSavingPrompt, setIsSavingPrompt] = useState(false);
|
const [isSavingPrompt, setIsSavingPrompt] = useState(false);
|
||||||
const [promptSaved, setPromptSaved] = useState(false);
|
const [promptSaved, setPromptSaved] = useState(false);
|
||||||
const [creatingPreset, setCreatingPreset] = useState<string | null>(null);
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'glossaries' | 'context'>('glossaries');
|
||||||
const { glossary: fullGlossary, isLoading: isLoadingGlossaryDetail } = useGlossary(
|
|
||||||
selectedGlossary?.id || null
|
|
||||||
);
|
|
||||||
|
|
||||||
const isPro = user?.tier === 'pro';
|
const isPro = user?.tier === 'pro';
|
||||||
const isLoading = isLoadingUser || isLoadingGlossaries;
|
const isLoading = isLoadingUser || isLoadingGlossaries;
|
||||||
|
|
||||||
// Current translation target from store
|
|
||||||
const currentTargetLang = settings.defaultTargetLanguage;
|
const currentTargetLang = settings.defaultTargetLanguage;
|
||||||
const currentTargetInfo = SUPPORTED_LANGUAGES.find(l => l.code === currentTargetLang);
|
const currentTargetInfo = SUPPORTED_LANGUAGES.find(l => l.code === currentTargetLang);
|
||||||
|
|
||||||
// Track whether prompt has unsaved changes
|
|
||||||
const promptHasUnsavedChanges = systemPrompt !== settings.systemPrompt;
|
const promptHasUnsavedChanges = systemPrompt !== settings.systemPrompt;
|
||||||
const promptIsActive = !!settings.systemPrompt?.trim();
|
const promptIsActive = !!settings.systemPrompt?.trim();
|
||||||
|
|
||||||
@@ -92,7 +75,9 @@ export default function GlossariesPage() {
|
|||||||
setPromptSaved(true);
|
setPromptSaved(true);
|
||||||
toast({ title: t('context.saved'), description: t('context.savedDesc') });
|
toast({ title: t('context.saved'), description: t('context.savedDesc') });
|
||||||
setTimeout(() => setPromptSaved(false), 3000);
|
setTimeout(() => setPromptSaved(false), 3000);
|
||||||
} finally { setIsSavingPrompt(false); }
|
} finally {
|
||||||
|
setIsSavingPrompt(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearPrompt = () => {
|
const handleClearPrompt = () => {
|
||||||
@@ -100,137 +85,24 @@ export default function GlossariesPage() {
|
|||||||
setSystemPrompt('');
|
setSystemPrompt('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreatePresetGlossary = async (preset: typeof PRESETS[0]) => {
|
const handleAddSuggestion = (value: string) => {
|
||||||
setCreatingPreset(preset.key);
|
const current = systemPrompt.trim();
|
||||||
try {
|
const newPrompt = current ? `${current}\n${value}` : value;
|
||||||
const token = localStorage.getItem('token');
|
setSystemPrompt(newPrompt);
|
||||||
if (!token) return;
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
};
|
|
||||||
const params = new URLSearchParams({ template_id: preset.templateId });
|
|
||||||
const res = await fetch(`${API_BASE}/api/v1/glossaries/import?${params.toString()}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const result = await res.json();
|
|
||||||
const glossary = result.data;
|
|
||||||
toast({
|
|
||||||
title: t('context.presets.created'),
|
|
||||||
description: t('context.presets.createdDesc', {
|
|
||||||
name: glossary?.name ?? t(preset.titleKey),
|
|
||||||
count: String(glossary?.terms?.length ?? 0),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else if (res.status === 409) {
|
|
||||||
toast({ title: t('glossaries.presets.alreadyImported') });
|
|
||||||
} else {
|
|
||||||
toast({ variant: 'destructive', title: t('glossaries.toast.error'), description: t('glossaries.toast.errorCreate') });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast({ variant: 'destructive', title: t('glossaries.toast.error'), description: t('glossaries.toast.errorImport') });
|
|
||||||
} finally {
|
|
||||||
setCreatingPreset(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditClick = (id: string) => {
|
const filteredGlossaries = useMemo(() => {
|
||||||
const glossary = glossaries.find((g: GlossaryListItem) => g.id === id);
|
if (!searchQuery.trim()) return glossaries;
|
||||||
if (glossary) {
|
const q = searchQuery.toLowerCase();
|
||||||
setSelectedGlossary(glossary);
|
return glossaries.filter((g) => g.name.toLowerCase().includes(q));
|
||||||
setEditDialogOpen(true);
|
}, [glossaries, searchQuery]);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteClick = (id: string, name: string) => {
|
|
||||||
setGlossaryToDelete({ id, name });
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 handleImportTemplate = async (templateId: string, name?: string) => {
|
|
||||||
try {
|
|
||||||
await importTemplate(templateId, name);
|
|
||||||
setCreateDialogOpen(false);
|
|
||||||
toast({
|
|
||||||
title: t('glossaries.toast.imported'),
|
|
||||||
description: name
|
|
||||||
? t('glossaries.toast.importedDesc', { name })
|
|
||||||
: t('glossaries.toast.importedDesc', { name: templateId }),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: t('glossaries.toast.error'),
|
|
||||||
description: t('glossaries.toast.errorImport'),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveGlossary = async (id: string, data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => {
|
|
||||||
try {
|
|
||||||
await updateGlossary(id, data);
|
|
||||||
setEditDialogOpen(false);
|
|
||||||
setSelectedGlossary(null);
|
|
||||||
toast({
|
|
||||||
title: t('glossaries.toast.updated'),
|
|
||||||
description: t('glossaries.toast.updatedDesc', { name: data.name }),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: t('glossaries.toast.error'),
|
|
||||||
description: t('glossaries.toast.errorUpdate'),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (!glossaryToDelete) return;
|
|
||||||
try {
|
|
||||||
await deleteGlossary(glossaryToDelete.id);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
setGlossaryToDelete(null);
|
|
||||||
toast({
|
|
||||||
title: t('glossaries.toast.deleted'),
|
|
||||||
description: t('glossaries.toast.deletedDesc'),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: t('glossaries.toast.error'),
|
|
||||||
description: t('glossaries.toast.errorDelete'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="text-center space-y-4">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -240,401 +112,331 @@ export default function GlossariesPage() {
|
|||||||
return <ProUpgradePrompt />;
|
return <ProUpgradePrompt />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full p-6 lg:p-8">
|
<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 className="flex flex-col sm:flex-row justify-between items-start sm:items-end mb-10 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<span className="accent-pill mb-3 block w-fit font-medium text-[10px] uppercase tracking-widest">
|
<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>
|
</span>
|
||||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
<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>
|
</h1>
|
||||||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
<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."}
|
{t('glossaries.description') || 'Gérez vos glossaires et instructions de contexte pour des traductions plus précises.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{/* Bouton principal — unique point d'entrée pour créer */}
|
||||||
onClick={() => setCreateDialogOpen(true)}
|
<Link
|
||||||
disabled={isCreating}
|
href="/dashboard/glossaries/new"
|
||||||
className="premium-button px-8 py-3 text-xs uppercase tracking-widest !rounded-xl flex items-center gap-2 disabled:opacity-50 shrink-0 cursor-pointer font-bold"
|
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} />
|
<Plus size={14} />
|
||||||
{t('glossaries.createNew')}
|
Nouveau glossaire
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 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>
|
||||||
|
|
||||||
|
{/* ── 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',
|
||||||
|
activeTab === 'glossaries'
|
||||||
|
? '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'}
|
||||||
|
</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',
|
||||||
|
activeTab === 'context'
|
||||||
|
? '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)'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Comment ça marche ─────────────────────────────────── */}
|
|
||||||
<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 flex items-center justify-between">
|
|
||||||
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light">
|
|
||||||
{t('glossaries.howItWorks.warning')}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/translate"
|
|
||||||
className="ml-4 shrink-0 flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline"
|
|
||||||
>
|
|
||||||
{t('glossaries.howItWorks.goToTranslate')} <ExternalLink size={11} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
|
{activeTab === 'context' ? (
|
||||||
|
|
||||||
{/* ── System Prompt ────────────────────────────────────── */}
|
/* ── 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">
|
<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">
|
||||||
{/* Header with status badge */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
{/* En-tête section */}
|
||||||
<div className="flex items-center gap-3 text-brand-accent">
|
<div className="flex items-start justify-between mb-6 gap-4">
|
||||||
<MessageSquare size={18} />
|
<div className="flex items-start gap-3">
|
||||||
<h3 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
<div className="w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center text-[#8B6F47] shrink-0">
|
||||||
{t('context.instructions.title')}
|
<MessageSquare size={18} />
|
||||||
</h3>
|
</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="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} />
|
||||||
|
Non sauvegardé
|
||||||
|
</span>
|
||||||
|
) : promptIsActive ? (
|
||||||
|
<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} />
|
||||||
|
Actif
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
{/* Status badge */}
|
|
||||||
{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">
|
|
||||||
<AlertCircle size={11} />
|
|
||||||
{t('glossaries.status.unsaved')}
|
|
||||||
</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">
|
|
||||||
<CheckCircle2 size={11} />
|
|
||||||
{t('glossaries.status.active')}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Explanation box */}
|
{/* Chips de suggestions rapides */}
|
||||||
<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">
|
<div className="mb-5">
|
||||||
<p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<strong className="font-bold text-brand-dark/80 dark:text-white/80">{t('glossaries.instructions.whatForBold')}</strong> {t('glossaries.instructions.whatForDesc')}
|
<Sparkles size={13} className="text-[#8B6F47]" />
|
||||||
</p>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-[#555555] dark:text-white/40">
|
||||||
<p className="text-[11px] text-brand-accent/80 dark:text-brand-accent/70 font-medium mt-2 italic">
|
Suggestions rapides — cliquez pour ajouter
|
||||||
{t('glossaries.instructions.example')}
|
</span>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CONTEXT_SUGGESTIONS.map((s) => (
|
||||||
<textarea
|
<button
|
||||||
value={systemPrompt}
|
key={s.label}
|
||||||
onChange={e => { setSystemPrompt(e.target.value); setPromptSaved(false); }}
|
onClick={() => handleAddSuggestion(s.value)}
|
||||||
placeholder={t('context.instructions.placeholder')}
|
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"
|
||||||
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"
|
>
|
||||||
/>
|
+ {s.label}
|
||||||
<div className="flex justify-between items-center mt-4">
|
</button>
|
||||||
<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')}
|
</div>
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<Trash2 size={12} className="inline mr-1.5" />{t('glossaries.instructions.clearAll')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSavePrompt}
|
|
||||||
disabled={isSavingPrompt || !promptHasUnsavedChanges}
|
|
||||||
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')}</>
|
|
||||||
: promptSaved
|
|
||||||
? <><CheckCircle2 size={14} /> {t('glossaries.instructions.saved')}</>
|
|
||||||
: <><Save size={14} /> {t('context.save')}</>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Professional Presets ─────────────────────────────── */}
|
{/* Zone de texte */}
|
||||||
<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">
|
<textarea
|
||||||
<div className="flex items-center gap-3 mb-3 text-brand-accent">
|
value={systemPrompt}
|
||||||
<Zap size={18} />
|
onChange={e => { setSystemPrompt(e.target.value); setPromptSaved(false); }}
|
||||||
<h3 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
placeholder="Ex : Vous traduisez des documents techniques HVAC. Utilisez une terminologie d'ingénierie précise et un ton professionnel…"
|
||||||
{t('context.presets.title')}
|
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"
|
||||||
</h3>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Explanation box */}
|
{/* Pied de section */}
|
||||||
<div className="mb-6 p-3.5 rounded-xl bg-brand-muted/40 dark:bg-white/[0.03] border border-black/5 dark:border-white/5">
|
<div className="flex justify-between items-center mt-4">
|
||||||
<p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed">
|
<p className="text-[10px] text-[#777777] dark:text-white/25 font-light">
|
||||||
<strong className="font-bold text-brand-dark/80 dark:text-white/80">{t('glossaries.presets.whatForBold')}</strong> {t('glossaries.presets.whatForDesc')}
|
{systemPrompt.length > 0
|
||||||
</p>
|
? `${systemPrompt.length} caractères`
|
||||||
<div className="flex items-center gap-2 mt-2.5">
|
: 'Aucune consigne enregistrée'}
|
||||||
<MousePointerClick size={11} className="text-brand-accent shrink-0" />
|
|
||||||
<p className="text-[11px] text-brand-accent/80 font-medium">
|
|
||||||
{t('glossaries.presets.clickHint')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<div className="flex gap-3">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{(() => {
|
|
||||||
const importedTemplateIds = new Set(
|
|
||||||
glossaries
|
|
||||||
.map((g: GlossaryListItem) => g.template_id)
|
|
||||||
.filter(Boolean) as string[]
|
|
||||||
);
|
|
||||||
return PRESETS.map((p) => {
|
|
||||||
const Icon = p.icon;
|
|
||||||
const isCreatingThis = creatingPreset === p.key;
|
|
||||||
const alreadyImported = importedTemplateIds.has(p.templateId);
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={p.key}
|
onClick={handleClearPrompt}
|
||||||
onClick={() => !alreadyImported && handleCreatePresetGlossary(p)}
|
disabled={!systemPrompt}
|
||||||
disabled={!!creatingPreset || alreadyImported}
|
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"
|
||||||
className="relative p-4 bg-brand-muted/40 dark:bg-white/5 hover:bg-brand-accent/5 dark:hover:bg-brand-accent/10 border border-black/5 dark:border-white/5 rounded-xl text-left transition-all hover:border-brand-accent/30 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed group min-h-[105px] flex flex-col justify-between"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<Trash2 size={12} className="inline mr-1.5" />
|
||||||
<div className="p-1.5 bg-brand-accent/10 rounded-lg text-brand-accent group-hover:scale-110 transition-transform">
|
Tout effacer
|
||||||
{isCreatingThis ? <Loader2 size={16} className="animate-spin" /> : <Icon size={16} />}
|
|
||||||
</div>
|
|
||||||
{isCreatingThis && <span className="text-[10px] text-brand-accent font-bold uppercase">{t('glossaries.presets.creating')}</span>}
|
|
||||||
{alreadyImported && (
|
|
||||||
<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')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-bold text-brand-dark dark:text-white mb-1">
|
|
||||||
{t(p.titleKey)}
|
|
||||||
</h4>
|
|
||||||
<p className="text-[10px] text-brand-dark/45 dark:text-white/45 leading-normal font-light">
|
|
||||||
{t(p.descKey)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
<button
|
||||||
});
|
onClick={handleSavePrompt}
|
||||||
})()}
|
disabled={isSavingPrompt || !promptHasUnsavedChanges}
|
||||||
</div>
|
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"
|
||||||
</section>
|
>
|
||||||
|
{isSavingPrompt
|
||||||
{/* ── Glossary Grid ──────────────────────────────────────── */}
|
? <><Loader2 size={14} className="animate-spin" /> Enregistrement…</>
|
||||||
<div>
|
: promptSaved
|
||||||
<div className="flex items-center justify-between mb-5">
|
? <><CheckCircle2 size={14} /> Enregistré</>
|
||||||
<div>
|
: <><Save size={14} /> Enregistrer</>
|
||||||
<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>
|
</button>
|
||||||
</h2>
|
</div>
|
||||||
<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>
|
||||||
<div className="flex items-center gap-3">
|
</section>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
|
/* ── Onglet Glossaires ──────────────────────────────────── */
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
|
||||||
|
{/* 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 && (
|
{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 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>{t('glossaries.grid.activeTranslation')}</span>
|
<span>Traduction active :</span>
|
||||||
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
|
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{glossaries.length > 0 && (
|
|
||||||
<Link
|
|
||||||
href="/dashboard/translate"
|
|
||||||
className="flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline shrink-0"
|
|
||||||
>
|
|
||||||
<ExternalLink size={12} />
|
|
||||||
{t('glossaries.grid.goToTranslate')}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{glossaries.length === 0 ? (
|
{/* Barre de recherche (si > 3 glossaires) */}
|
||||||
<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">
|
{glossaries.length > 3 && (
|
||||||
<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">
|
<div className="relative">
|
||||||
<Library size={24} />
|
<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>
|
</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">{t('glossaries.emptyDesc')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{glossaries.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';
|
|
||||||
// Does this glossary match the current translation target?
|
|
||||||
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',
|
|
||||||
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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Match / mismatch badge */}
|
|
||||||
{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="flex items-start gap-3 mb-5">
|
{/* État vide */}
|
||||||
<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">
|
{glossaries.length === 0 ? (
|
||||||
<Library size={18} />
|
<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;
|
||||||
|
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
{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>
|
</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">
|
{/* Actions */}
|
||||||
{glossary.name}
|
<div className="flex gap-2 pt-4 border-t border-[#F0EDE8] dark:border-white/10 mt-auto shrink-0">
|
||||||
</h3>
|
<Link
|
||||||
<p className="text-[11px] text-brand-dark/40 dark:text-white/40 font-medium flex items-center gap-1 mt-0.5">
|
href={`/dashboard/glossaries/${glossary.id}`}
|
||||||
<span>{srcInfo?.flag ?? '🌐'}</span>
|
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"
|
||||||
<span>{srcInfo?.label ?? glossary.source_language}</span>
|
>
|
||||||
<span className="text-brand-accent font-bold">→</span>
|
Éditer
|
||||||
<span>{tgtInfo?.flag ?? '🌐'}</span>
|
</Link>
|
||||||
<span>{tgtInfo?.label ?? glossary.target_language}</span>
|
<Link
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
);
|
||||||
<div className="flex justify-between items-center pt-4 border-t border-black/5 dark:border-white/10 text-xs text-brand-dark/40 dark:text-white/40">
|
})}
|
||||||
<span className="flex items-center gap-1">
|
</div>
|
||||||
<Hash size={12} className="text-brand-accent" />
|
)}
|
||||||
{termCount} {t('glossaries.defineTerms')}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1 font-mono">
|
|
||||||
<Calendar size={12} />
|
|
||||||
{new Date(glossary.created_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons — always visible, unambiguous */}
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditClick(glossary.id)}
|
|
||||||
className="flex-1 py-2 px-3 rounded-lg bg-brand-muted/60 dark:bg-white/5 hover:bg-brand-accent/10 dark:hover:bg-brand-accent/15 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Save size={10} /> {t('glossaries.card.editTerms')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteClick(glossary.id, glossary.name);
|
|
||||||
}}
|
|
||||||
className="py-2 px-3 rounded-lg bg-red-500/5 hover:bg-red-500 hover:text-white text-red-500 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Trash2 size={10} /> {t('glossaries.card.delete')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── About section ──────────────────────────────────────── */}
|
|
||||||
<div className="editorial-card p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
|
|
||||||
<div className="flex items-center gap-3 text-brand-accent mb-4">
|
|
||||||
<BookText size={18} />
|
|
||||||
<span className="text-xs font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
|
||||||
{t('glossaries.aboutTitle')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-brand-dark/50 dark:text-white/40 font-light leading-relaxed mb-3">{t('glossaries.aboutDesc')}</p>
|
)}
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60">
|
|
||||||
{t('glossaries.aboutFormat')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dialogs */}
|
|
||||||
<CreateGlossaryDialog
|
|
||||||
open={createDialogOpen}
|
|
||||||
onOpenChange={setCreateDialogOpen}
|
|
||||||
onCreate={handleCreateGlossary}
|
|
||||||
onImportTemplate={handleImportTemplate}
|
|
||||||
isCreating={isCreating}
|
|
||||||
isImportingTemplate={isImportingTemplate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{editDialogOpen && (fullGlossary || !isLoadingGlossaryDetail) && (
|
|
||||||
<EditGlossaryDialog
|
|
||||||
open={editDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setEditDialogOpen(open);
|
|
||||||
if (!open) setSelectedGlossary(null);
|
|
||||||
}}
|
|
||||||
glossary={fullGlossary}
|
|
||||||
onSave={handleSaveGlossary}
|
|
||||||
isSaving={isUpdating}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DeleteGlossaryDialog
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
glossaryName={glossaryToDelete?.name}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useUser } from './useUser';
|
import { useUser } from './useUser';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +19,7 @@ export default function DashboardPage() {
|
|||||||
const checkoutSessionId = searchParams.get('session_id');
|
const checkoutSessionId = searchParams.get('session_id');
|
||||||
const [syncError, setSyncError] = useState<string | null>(null);
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
const { refetch } = useUser();
|
const { refetch } = useUser();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!checkoutSessionId) {
|
if (!checkoutSessionId) {
|
||||||
@@ -42,20 +44,20 @@ export default function DashboardPage() {
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errData = await res.json().catch(() => ({}));
|
const errData = await res.json().catch(() => ({}));
|
||||||
setSyncError(errData.message || 'Erreur lors de la synchronisation du paiement.');
|
setSyncError(errData.message || t('dashboard.checkoutSyncError'));
|
||||||
} else {
|
} else {
|
||||||
await refetch();
|
await refetch();
|
||||||
router.replace('/dashboard/translate');
|
router.replace('/dashboard/translate');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setSyncError('Erreur réseau. Veuillez rafraîchir la page.');
|
if (!cancelled) setSyncError(t('dashboard.networkRefresh'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
runSync();
|
runSync();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [checkoutSessionId, refetch, router]);
|
}, [checkoutSessionId, refetch, router, t]);
|
||||||
|
|
||||||
if (syncError) {
|
if (syncError) {
|
||||||
return (
|
return (
|
||||||
@@ -65,7 +67,7 @@ export default function DashboardPage() {
|
|||||||
onClick={() => router.replace('/dashboard/translate')}
|
onClick={() => router.replace('/dashboard/translate')}
|
||||||
className="text-xs text-muted-foreground underline"
|
className="text-xs text-muted-foreground underline"
|
||||||
>
|
>
|
||||||
Continuer vers la traduction
|
{t('dashboard.continueToTranslate')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ThemeToggle } from '@/components/ui/theme-toggle';
|
|||||||
import { languages } from '@/lib/api';
|
import { languages } from '@/lib/api';
|
||||||
import { useTranslationStore } from '@/lib/store';
|
import { useTranslationStore } from '@/lib/store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
/* ── helpers ──────────────────────────────────────────────────── */
|
/* ── helpers ──────────────────────────────────────────────────── */
|
||||||
const PLAN_ICONS: Record<string, React.ElementType> = {
|
const PLAN_ICONS: Record<string, React.ElementType> = {
|
||||||
@@ -90,6 +91,8 @@ export default function ProfilePage() {
|
|||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
const authHeaders = { Authorization: `Bearer ${token}` };
|
const authHeaders = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
if (!token) { router.push('/auth/login?redirect=/dashboard/profile'); return; }
|
if (!token) { router.push('/auth/login?redirect=/dashboard/profile'); return; }
|
||||||
try {
|
try {
|
||||||
@@ -97,10 +100,15 @@ export default function ProfilePage() {
|
|||||||
fetch(`${API_BASE}/api/v1/auth/me`, { headers: authHeaders }),
|
fetch(`${API_BASE}/api/v1/auth/me`, { headers: authHeaders }),
|
||||||
fetch(`${API_BASE}/api/v1/auth/usage`, { headers: authHeaders }),
|
fetch(`${API_BASE}/api/v1/auth/usage`, { headers: authHeaders }),
|
||||||
]);
|
]);
|
||||||
if (meRes.ok) { const j = await meRes.json(); setUser(j.data ?? j); }
|
if (meRes.ok) {
|
||||||
|
const j = await meRes.json();
|
||||||
|
const userData = j.data ?? j;
|
||||||
|
setUser(userData);
|
||||||
|
queryClient.setQueryData(['user', 'me'], userData);
|
||||||
|
}
|
||||||
if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); }
|
if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); }
|
||||||
} catch { /* ignore */ } finally { setLoading(false); }
|
} catch { /* ignore */ } finally { setLoading(false); }
|
||||||
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [token, queryClient]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
useEffect(() => { setDefaultLanguage(settings.defaultTargetLanguage); }, [settings.defaultTargetLanguage]);
|
useEffect(() => { setDefaultLanguage(settings.defaultTargetLanguage); }, [settings.defaultTargetLanguage]);
|
||||||
@@ -199,7 +207,7 @@ export default function ProfilePage() {
|
|||||||
)}>
|
)}>
|
||||||
{statusMsg.type === 'ok' ? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 shrink-0 mt-0.5" />}
|
{statusMsg.type === 'ok' ? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 shrink-0 mt-0.5" />}
|
||||||
<span className="flex-1">{statusMsg.text}</span>
|
<span className="flex-1">{statusMsg.text}</span>
|
||||||
<button onClick={() => setStatusMsg(null)} className="text-muted-foreground hover:text-foreground">✕</button>
|
<button onClick={() => setStatusMsg(null)} className="text-muted-foreground hover:text-foreground" aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from 'lucide-react';
|
|||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
const FALLBACK_PROVIDERS = [
|
const FALLBACK_PROVIDERS_FACTORY = (t: (k: string) => string) => [
|
||||||
{ id: "google", label: "Google Traduction", description: "Traduction rapide, 130+ langues", mode: "classic" as const },
|
{ id: "google", label: t('services.fallback.google.label'), description: t('services.fallback.google.desc'), mode: "classic" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface AvailableProvider {
|
interface AvailableProvider {
|
||||||
@@ -19,6 +19,7 @@ interface AvailableProvider {
|
|||||||
|
|
||||||
export default function TranslationServicesPage() {
|
export default function TranslationServicesPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const FALLBACK_PROVIDERS = FALLBACK_PROVIDERS_FACTORY(t);
|
||||||
const [providers, setProviders] = useState<AvailableProvider[]>([]);
|
const [providers, setProviders] = useState<AvailableProvider[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export default function TranslationServicesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchProviders();
|
fetchProviders();
|
||||||
}, []);
|
}, [FALLBACK_PROVIDERS]);
|
||||||
|
|
||||||
const classicProviders = providers.filter((p) => p.mode === "classic");
|
const classicProviders = providers.filter((p) => p.mode === "classic");
|
||||||
const llmProviders = providers.filter((p) => p.mode === "llm");
|
const llmProviders = providers.filter((p) => p.mode === "llm");
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export default function GeneralSettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formats = [
|
const formats = [
|
||||||
{ icon: FileSpreadsheet, color: 'text-green-500', name: 'Excel', ext: '.xlsx, .xls', features: [t('settings.formats.formulas'), t('settings.formats.styles'), t('settings.formats.images')] },
|
{ icon: FileSpreadsheet, color: 'text-green-500', name: t('settings.formats.excel.name'), ext: '.xlsx, .xls', features: [t('settings.formats.formulas'), t('settings.formats.styles'), t('settings.formats.images')] },
|
||||||
{ icon: FileText, color: 'text-blue-500', name: 'Word', ext: '.docx, .doc', features: [t('settings.formats.headers'), t('settings.formats.tables'), t('settings.formats.images')] },
|
{ icon: FileText, color: 'text-blue-500', name: t('settings.formats.word.name'), ext: '.docx, .doc', features: [t('settings.formats.headers'), t('settings.formats.tables'), t('settings.formats.images')] },
|
||||||
{ icon: Presentation, color: 'text-orange-500', name: 'PowerPoint', ext: '.pptx, .ppt', features: [t('settings.formats.slides'), t('settings.formats.notes'), t('settings.formats.images')] },
|
{ icon: Presentation, color: 'text-orange-500', name: t('settings.formats.powerpoint.name'), ext: '.pptx, .ppt', features: [t('settings.formats.slides'), t('settings.formats.notes'), t('settings.formats.images')] },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function Combobox({
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
onChange: (code: string) => void;
|
onChange: (code: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -81,13 +82,13 @@ function Combobox({
|
|||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
placeholder="Search..."
|
placeholder={t('langSelector.search')}
|
||||||
className="w-full bg-transparent px-1 py-1 text-xs outline-none placeholder:text-brand-dark/30 dark:placeholder:text-white/30 text-brand-dark dark:text-white"
|
className="w-full bg-transparent px-1 py-1 text-xs outline-none placeholder:text-brand-dark/30 dark:placeholder:text-white/30 text-brand-dark dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[160px] overflow-y-auto p-1 mt-1 space-y-0.5">
|
<div className="max-h-[160px] overflow-y-auto p-1 mt-1 space-y-0.5">
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<div className="px-3 py-3 text-center text-xs text-brand-dark/40 dark:text-white/40">No results</div>
|
<div className="px-3 py-3 text-center text-xs text-brand-dark/40 dark:text-white/40">{t('langSelector.noResults')}</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map(lang => (
|
{filtered.map(lang => (
|
||||||
<button
|
<button
|
||||||
@@ -143,7 +144,7 @@ export default function LanguageSelector({
|
|||||||
<div className="grid grid-cols-11 gap-2 items-center">
|
<div className="grid grid-cols-11 gap-2 items-center">
|
||||||
{/* Source */}
|
{/* Source */}
|
||||||
<div className="col-span-5 relative text-left">
|
<div className="col-span-5 relative text-left">
|
||||||
<span className="text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest block mb-1">Source</span>
|
<span className="text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest block mb-1">{t('langSelector.source')}</span>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={sourceLang}
|
value={sourceLang}
|
||||||
options={languages}
|
options={languages}
|
||||||
@@ -166,7 +167,7 @@ export default function LanguageSelector({
|
|||||||
? 'text-brand-dark/50 dark:text-white/50 hover:text-brand-accent hover:bg-brand-muted/50 dark:hover:bg-white/5'
|
? 'text-brand-dark/50 dark:text-white/50 hover:text-brand-accent hover:bg-brand-muted/50 dark:hover:bg-white/5'
|
||||||
: 'cursor-not-allowed opacity-30'
|
: 'cursor-not-allowed opacity-30'
|
||||||
)}
|
)}
|
||||||
title="Inverser"
|
title={t('langSelector.swap')}
|
||||||
>
|
>
|
||||||
<ArrowRightLeft size={12} />
|
<ArrowRightLeft size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -174,7 +175,7 @@ export default function LanguageSelector({
|
|||||||
|
|
||||||
{/* Target */}
|
{/* Target */}
|
||||||
<div className="col-span-5 relative text-left">
|
<div className="col-span-5 relative text-left">
|
||||||
<span className="text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest block mb-1">Cible</span>
|
<span className="text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest block mb-1">{t('langSelector.target')}</span>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={targetLang}
|
value={targetLang}
|
||||||
options={languages}
|
options={languages}
|
||||||
|
|||||||
@@ -22,73 +22,73 @@ interface CardTheme {
|
|||||||
descriptionOverride: string;
|
descriptionOverride: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LLM_THEMES: Record<string, CardTheme> = {
|
const LLM_THEMES_FACTORY = (t: (k: string) => string): Record<string, CardTheme> => ({
|
||||||
deepseek: {
|
deepseek: {
|
||||||
badge: 'Essentielle',
|
badge: t('providerTheme.deepseek.badge'),
|
||||||
subBadge: 'Technique & Éco',
|
subBadge: t('providerTheme.deepseek.subBadge'),
|
||||||
accentClass: 'border-cyan-500/30 text-cyan-600 dark:text-cyan-400 bg-cyan-500/5',
|
accentClass: 'border-cyan-500/30 text-cyan-600 dark:text-cyan-400 bg-cyan-500/5',
|
||||||
glowClass: 'from-cyan-500/10 dark:from-cyan-500/5 to-transparent',
|
glowClass: 'from-cyan-500/10 dark:from-cyan-500/5 to-transparent',
|
||||||
descriptionOverride: 'Traduction ultra-précise et économique, idéale pour les documents techniques et le code.'
|
descriptionOverride: t('providerTheme.deepseek.desc')
|
||||||
},
|
},
|
||||||
openai: {
|
openai: {
|
||||||
badge: 'Premium',
|
badge: t('providerTheme.openai.badge'),
|
||||||
subBadge: 'Haute Fidélité',
|
subBadge: t('providerTheme.openai.subBadge'),
|
||||||
accentClass: 'border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/5',
|
accentClass: 'border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/5',
|
||||||
glowClass: 'from-emerald-500/10 dark:from-emerald-500/5 to-transparent',
|
glowClass: 'from-emerald-500/10 dark:from-emerald-500/5 to-transparent',
|
||||||
descriptionOverride: 'Le standard mondial de l\'IA. Cohérence textuelle maximale et respect strict du style.'
|
descriptionOverride: t('providerTheme.openai.desc')
|
||||||
},
|
},
|
||||||
minimax: {
|
minimax: {
|
||||||
badge: 'Avancée',
|
badge: t('providerTheme.minimax.badge'),
|
||||||
subBadge: 'Performance',
|
subBadge: t('providerTheme.minimax.subBadge'),
|
||||||
accentClass: 'border-indigo-500/30 text-indigo-600 dark:text-indigo-400 bg-indigo-500/5',
|
accentClass: 'border-indigo-500/30 text-indigo-600 dark:text-indigo-400 bg-indigo-500/5',
|
||||||
glowClass: 'from-indigo-500/10 dark:from-indigo-500/5 to-transparent',
|
glowClass: 'from-indigo-500/10 dark:from-indigo-500/5 to-transparent',
|
||||||
descriptionOverride: 'Vitesse d\'exécution incroyable et excellente compréhension des structures complexes.'
|
descriptionOverride: t('providerTheme.minimax.desc')
|
||||||
},
|
},
|
||||||
openrouter: {
|
openrouter: {
|
||||||
badge: 'Express',
|
badge: t('providerTheme.openrouter.badge'),
|
||||||
subBadge: 'Multi-Modèles',
|
subBadge: t('providerTheme.openrouter.subBadge'),
|
||||||
accentClass: 'border-purple-500/30 text-purple-600 dark:text-purple-400 bg-purple-500/5',
|
accentClass: 'border-purple-500/30 text-purple-600 dark:text-purple-400 bg-purple-500/5',
|
||||||
glowClass: 'from-purple-500/10 dark:from-purple-500/5 to-transparent',
|
glowClass: 'from-purple-500/10 dark:from-purple-500/5 to-transparent',
|
||||||
descriptionOverride: 'Accès unifié aux meilleurs modèles open-source optimisés pour la traduction.'
|
descriptionOverride: t('providerTheme.openrouter.desc')
|
||||||
},
|
},
|
||||||
openrouter_premium: {
|
openrouter_premium: {
|
||||||
badge: 'Ultra',
|
badge: t('providerTheme.openrouter_premium.badge'),
|
||||||
subBadge: 'Maximum Context',
|
subBadge: t('providerTheme.openrouter_premium.subBadge'),
|
||||||
accentClass: 'border-rose-500/30 text-rose-600 dark:text-rose-400 bg-rose-500/5',
|
accentClass: 'border-rose-500/30 text-rose-600 dark:text-rose-400 bg-rose-500/5',
|
||||||
glowClass: 'from-rose-500/10 dark:from-rose-500/5 to-transparent',
|
glowClass: 'from-rose-500/10 dark:from-rose-500/5 to-transparent',
|
||||||
descriptionOverride: 'Traduction assistée par les modèles de pointe (GPT-4o, Claude 3.5 Sonnet) pour documents longs.'
|
descriptionOverride: t('providerTheme.openrouter_premium.desc')
|
||||||
},
|
},
|
||||||
zai: {
|
zai: {
|
||||||
badge: 'Spécialisée',
|
badge: t('providerTheme.zai.badge'),
|
||||||
subBadge: 'Finance & Droit',
|
subBadge: t('providerTheme.zai.subBadge'),
|
||||||
accentClass: 'border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/5',
|
accentClass: 'border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/5',
|
||||||
glowClass: 'from-amber-500/10 dark:from-amber-500/5 to-transparent',
|
glowClass: 'from-amber-500/10 dark:from-amber-500/5 to-transparent',
|
||||||
descriptionOverride: 'Modèle affiné pour les terminologies métiers exigeantes (juridique, finance).'
|
descriptionOverride: t('providerTheme.zai.desc')
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
const DEFAULT_LLM_THEME: CardTheme = {
|
const makeDefaultLlmTheme = (t: (k: string) => string): CardTheme => ({
|
||||||
badge: 'Moderne',
|
badge: t('providerTheme.default.badge'),
|
||||||
subBadge: 'Raisonnement IA',
|
subBadge: t('providerTheme.default.subBadge'),
|
||||||
accentClass: 'border-brand-accent/30 text-brand-accent bg-brand-accent/5',
|
accentClass: 'border-brand-accent/30 text-brand-accent bg-brand-accent/5',
|
||||||
glowClass: 'from-brand-accent/10 to-transparent',
|
glowClass: 'from-brand-accent/10 to-transparent',
|
||||||
descriptionOverride: 'Traduction par grand modèle linguistique (LLM) avec analyse sémantique avancée.'
|
descriptionOverride: t('providerTheme.default.desc')
|
||||||
};
|
});
|
||||||
|
|
||||||
const CLASSIC_THEMES: Record<string, { labelOverride?: string; descriptionOverride?: string }> = {
|
const CLASSIC_THEMES_FACTORY = (t: (k: string) => string): Record<string, { labelOverride?: string; descriptionOverride?: string }> => ({
|
||||||
google: {
|
google: {
|
||||||
labelOverride: 'Google Traduction',
|
labelOverride: t('providerTheme.classic.google.label'),
|
||||||
descriptionOverride: 'Traduction ultra-rapide couvrant plus de 130 langues. Recommandé pour les flux généraux.'
|
descriptionOverride: t('providerTheme.classic.google.desc')
|
||||||
},
|
},
|
||||||
deepl: {
|
deepl: {
|
||||||
labelOverride: 'DeepL Pro',
|
labelOverride: t('providerTheme.classic.deepl.label'),
|
||||||
descriptionOverride: 'Traduction haute précision réputée pour sa fluidité et ses formulations naturelles.'
|
descriptionOverride: t('providerTheme.classic.deepl.desc')
|
||||||
},
|
},
|
||||||
google_cloud: {
|
google_cloud: {
|
||||||
labelOverride: 'Google Cloud API',
|
labelOverride: t('providerTheme.classic.google_cloud.label'),
|
||||||
descriptionOverride: 'Moteur cloud professionnel optimisé pour le traitement de gros volumes de documents.'
|
descriptionOverride: t('providerTheme.classic.google_cloud.desc')
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
export function ProviderSelector({
|
export function ProviderSelector({
|
||||||
provider,
|
provider,
|
||||||
@@ -100,6 +100,11 @@ export function ProviderSelector({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [activeTab, setActiveTab] = useState<'classic' | 'llm'>('classic');
|
const [activeTab, setActiveTab] = useState<'classic' | 'llm'>('classic');
|
||||||
|
|
||||||
|
// Theme maps built from i18n (so they follow the active locale)
|
||||||
|
const LLM_THEMES = LLM_THEMES_FACTORY(t);
|
||||||
|
const DEFAULT_LLM_THEME = makeDefaultLlmTheme(t);
|
||||||
|
const CLASSIC_THEMES = CLASSIC_THEMES_FACTORY(t);
|
||||||
|
|
||||||
// Filter providers
|
// Filter providers
|
||||||
const classicProviders = availableProviders.filter((p) => p.mode === 'classic');
|
const classicProviders = availableProviders.filter((p) => p.mode === 'classic');
|
||||||
const llmProviders = availableProviders.filter((p) => p.mode === 'llm');
|
const llmProviders = availableProviders.filter((p) => p.mode === 'llm');
|
||||||
@@ -118,7 +123,7 @@ export function ProviderSelector({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-brand-dark/50 dark:text-white/40 py-4">
|
<div className="flex items-center gap-2 text-sm text-brand-dark/50 dark:text-white/40 py-4">
|
||||||
<Loader2 className="size-4 animate-spin text-brand-accent" />
|
<Loader2 className="size-4 animate-spin text-brand-accent" />
|
||||||
<span>{t('dashboard.translate.provider.loading') || 'Chargement des moteurs...'}</span>
|
<span>{t('dashboard.translate.provider.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,7 +131,7 @@ export function ProviderSelector({
|
|||||||
if (availableProviders.length === 0) {
|
if (availableProviders.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="rounded-xl border border-amber-200 bg-amber-50/50 px-4 py-3 text-xs text-amber-700 dark:border-amber-900/30 dark:bg-amber-950/20 dark:text-amber-400 font-light">
|
<p className="rounded-xl border border-amber-200 bg-amber-50/50 px-4 py-3 text-xs text-amber-700 dark:border-amber-900/30 dark:bg-amber-950/20 dark:text-amber-400 font-light">
|
||||||
{t('dashboard.translate.provider.noneConfigured') || 'Aucun fournisseur configuré'}
|
{t('dashboard.translate.provider.noneConfigured')}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,7 +214,7 @@ export function ProviderSelector({
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/45">
|
<label className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/45">
|
||||||
{t('dashboard.translate.provider.sectionTitle') || 'Moteur de Traduction'}
|
{t('dashboard.translate.provider.sectionTitle')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,7 +230,7 @@ export function ProviderSelector({
|
|||||||
: 'text-brand-dark/50 dark:text-white/40 hover:text-brand-dark dark:hover:text-white'
|
: 'text-brand-dark/50 dark:text-white/40 hover:text-brand-dark dark:hover:text-white'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t('dashboard.translate.provider.tabStandard') || 'Standard'}
|
{t('dashboard.translate.provider.tabStandard')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -238,7 +243,7 @@ export function ProviderSelector({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Sparkles className={cn("size-3", activeTab === 'llm' ? 'text-brand-accent' : 'text-brand-dark/35 dark:text-white/30')} />
|
<Sparkles className={cn("size-3", activeTab === 'llm' ? 'text-brand-accent' : 'text-brand-dark/35 dark:text-white/30')} />
|
||||||
{t('dashboard.translate.provider.tabLLM') || 'Multi-Modèles IA'}
|
{t('dashboard.translate.provider.tabLLM')}
|
||||||
{!isPro && <Lock className="size-2.5 opacity-60 ml-0.5" />}
|
{!isPro && <Lock className="size-2.5 opacity-60 ml-0.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +257,7 @@ export function ProviderSelector({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-brand-dark/40 dark:text-white/30 text-center py-4 border border-dashed border-brand-dark/10 dark:border-white/10 rounded-xl font-light">
|
<p className="text-xs text-brand-dark/40 dark:text-white/30 text-center py-4 border border-dashed border-brand-dark/10 dark:border-white/10 rounded-xl font-light">
|
||||||
Aucun traducteur standard disponible.
|
{t('providerSelector.noClassic')}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -262,7 +267,7 @@ export function ProviderSelector({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-brand-dark/40 dark:text-white/30 text-center py-4 border border-dashed border-brand-dark/10 dark:border-white/10 rounded-xl font-light">
|
<p className="text-xs text-brand-dark/40 dark:text-white/30 text-center py-4 border border-dashed border-brand-dark/10 dark:border-white/10 rounded-xl font-light">
|
||||||
Aucun modèle IA configuré.
|
{t('providerSelector.noLlm')}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -278,6 +283,7 @@ export function ProviderSelector({
|
|||||||
<p className="text-[10px] text-brand-dark/60 dark:text-white/60 leading-normal font-light">
|
<p className="text-[10px] text-brand-dark/60 dark:text-white/60 leading-normal font-light">
|
||||||
<span className="font-bold text-brand-dark dark:text-white">{meta?.labelOverride || activeP.label} : </span>
|
<span className="font-bold text-brand-dark dark:text-white">{meta?.labelOverride || activeP.label} : </span>
|
||||||
{meta?.descriptionOverride || activeP.description}
|
{meta?.descriptionOverride || activeP.description}
|
||||||
|
<span className="block mt-1 font-bold text-brand-accent">{t('providerSelector.costOne')}</span>
|
||||||
</p>
|
</p>
|
||||||
) : null;
|
) : null;
|
||||||
})()
|
})()
|
||||||
@@ -289,6 +295,9 @@ export function ProviderSelector({
|
|||||||
<p className="text-[10px] text-brand-dark/60 dark:text-white/60 leading-normal font-light">
|
<p className="text-[10px] text-brand-dark/60 dark:text-white/60 leading-normal font-light">
|
||||||
<span className="font-bold text-brand-dark dark:text-white">{activeP.label} : </span>
|
<span className="font-bold text-brand-dark dark:text-white">{activeP.label} : </span>
|
||||||
{theme.descriptionOverride} <span className="opacity-50 text-[9px] font-mono">({activeP.model || 'default'})</span>
|
{theme.descriptionOverride} <span className="opacity-50 text-[9px] font-mono">({activeP.model || 'default'})</span>
|
||||||
|
<span className="block mt-1 font-bold text-brand-accent">
|
||||||
|
{activeP.id === 'openrouter_premium' ? t('providerSelector.costFive') : t('providerSelector.costOne')}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
) : null;
|
) : null;
|
||||||
})()
|
})()
|
||||||
@@ -302,16 +311,16 @@ export function ProviderSelector({
|
|||||||
<div className="p-3.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.02] dark:bg-brand-accent/[0.01] text-center flex flex-col items-center gap-1.5">
|
<div className="p-3.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.02] dark:bg-brand-accent/[0.01] text-center flex flex-col items-center gap-1.5">
|
||||||
<Sparkles className="size-3.5 text-brand-accent animate-pulse" />
|
<Sparkles className="size-3.5 text-brand-accent animate-pulse" />
|
||||||
<span className="text-[11px] font-bold text-brand-dark dark:text-white leading-none">
|
<span className="text-[11px] font-bold text-brand-dark dark:text-white leading-none">
|
||||||
{t('dashboard.translate.provider.llmDivider') || 'Intelligence Artificielle Active'}
|
{t('dashboard.translate.provider.llmDivider')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-[9.5px] text-brand-dark/50 dark:text-white/50 leading-relaxed font-light">
|
<p className="text-[9.5px] text-brand-dark/50 dark:text-white/50 leading-relaxed font-light">
|
||||||
Débloquez la traduction contextuelle haut de gamme pour vos documents entiers.
|
{t('providerSelector.unlockContextual')}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className="w-full inline-flex items-center justify-center py-1.5 rounded-lg bg-brand-dark dark:bg-white text-white dark:text-brand-dark text-[10px] font-semibold hover:opacity-95 active:scale-[0.98] transition-all shadow-sm"
|
className="w-full inline-flex items-center justify-center py-1.5 rounded-lg bg-brand-dark dark:bg-white text-white dark:text-brand-dark text-[10px] font-semibold hover:opacity-95 active:scale-[0.98] transition-all shadow-sm"
|
||||||
>
|
>
|
||||||
{t('dashboard.translate.provider.upgrade') || 'Passer Pro'}
|
{t('dashboard.translate.provider.upgrade')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function TranslationComplete({
|
|||||||
: t('dashboard.translate.complete.descGeneric')}
|
: t('dashboard.translate.complete.descGeneric')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
|
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||||
<TrendingUp className="size-3" /> Haute qualité
|
<TrendingUp className="size-3" /> {t('translateComplete.highQuality')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,17 +122,17 @@ export function TranslationComplete({
|
|||||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||||
<FileText className="size-4 text-emerald-600" />
|
<FileText className="size-4 text-emerald-600" />
|
||||||
<p className="text-sm font-bold text-foreground">142</p>
|
<p className="text-sm font-bold text-foreground">142</p>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Segments</p>
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.segments')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||||
<Activity className="size-4 text-emerald-600" />
|
<Activity className="size-4 text-emerald-600" />
|
||||||
<p className="text-sm font-bold text-foreground">12.8k</p>
|
<p className="text-sm font-bold text-foreground">12.8k</p>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Caractères</p>
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.characters')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||||
<Timer className="size-4 text-emerald-600" />
|
<Timer className="size-4 text-emerald-600" />
|
||||||
<p className="text-sm font-bold text-emerald-600">96%</p>
|
<p className="text-sm font-bold text-emerald-600">96%</p>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Confiance</p>
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.confidence')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,12 @@ export default function TranslatePage() {
|
|||||||
const tgtLangName = config.languages.find(l => l.code === config.targetLang)?.name || config.targetLang;
|
const tgtLangName = config.languages.find(l => l.code === config.targetLang)?.name || config.targetLang;
|
||||||
const activeStepIdx = getActiveStepIdx(submit.progress);
|
const activeStepIdx = getActiveStepIdx(submit.progress);
|
||||||
const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
|
const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
|
||||||
|
const fileTypeButtons = [
|
||||||
|
{ label: t('translate.fileType.word'), type: 'word' as const, icon: <FileText size={11} className="text-blue-500" /> },
|
||||||
|
{ label: t('translate.fileType.excel'), type: 'excel' as const, icon: <FileSpreadsheet size={11} className="text-green-500" /> },
|
||||||
|
{ label: t('translate.fileType.slides'), type: 'slides' as const, icon: <Presentation size={11} className="text-orange-500" /> },
|
||||||
|
{ label: t('translate.fileType.pdf'), type: 'pdf' as const, icon: <FileType size={11} className="text-red-500" /> },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full p-6 pb-24 lg:pb-8 lg:p-8 dark:bg-[#0a0a0a] selection:bg-brand-accent/10">
|
<div className="min-h-full p-6 pb-24 lg:pb-8 lg:p-8 dark:bg-[#0a0a0a] selection:bg-brand-accent/10">
|
||||||
@@ -191,19 +197,27 @@ export default function TranslatePage() {
|
|||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
{showProcessing ? (
|
{showProcessing ? (
|
||||||
<>
|
<>
|
||||||
<span className="accent-pill mb-4 block w-fit italic">Traitement en cours</span>
|
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.processing')}</span>
|
||||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||||||
Analyse IA <span className="italic">Active</span>
|
{(() => {
|
||||||
|
const full = t('translate.header.aiActive');
|
||||||
|
const i = full.lastIndexOf(' ');
|
||||||
|
return <>{i === -1 ? full : <>{full.slice(0, i)} <span className="italic">{full.slice(i + 1)}</span></>}</>;
|
||||||
|
})()}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
||||||
Votre mise en page est en cours de préservation par notre moteur contextuel.
|
{t('translate.header.aiActiveDesc')}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : showComplete ? (
|
) : showComplete ? (
|
||||||
<>
|
<>
|
||||||
<span className="accent-pill mb-4 block w-fit italic">Complété</span>
|
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.completed')}</span>
|
||||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||||||
Traduction <span className="italic">terminée</span>
|
{(() => {
|
||||||
|
const full = t('translate.header.completedTitle');
|
||||||
|
const i = full.lastIndexOf(' ');
|
||||||
|
return <>{i === -1 ? full : <>{full.slice(0, i)} <span className="italic">{full.slice(i + 1)}</span></>}</>;
|
||||||
|
})()}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed truncate max-w-xl">
|
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed truncate max-w-xl">
|
||||||
{submit.fileName}
|
{submit.fileName}
|
||||||
@@ -211,12 +225,16 @@ export default function TranslatePage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="accent-pill mb-4 block w-fit">Espace Pro</span>
|
<span className="accent-pill mb-4 block w-fit">{t('translate.header.proSpace')}</span>
|
||||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||||||
Traduire un <span className="italic">document</span>
|
{(() => {
|
||||||
|
const full = t('translate.header.translateDoc');
|
||||||
|
const i = full.lastIndexOf(' ');
|
||||||
|
return <>{i === -1 ? full : <>{full.slice(0, i)} <span className="italic">{full.slice(i + 1)}</span></>}</>;
|
||||||
|
})()}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
|
||||||
Conservez la mise en page d'origine grâce au moteur de traduction ultra-haute fidélité.
|
{t('translate.header.translateDocDesc')}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -240,7 +258,7 @@ export default function TranslatePage() {
|
|||||||
onClick={() => dropzoneInputRef.current?.click()}
|
onClick={() => dropzoneInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<div className="absolute top-4 right-4 text-[8px] font-bold uppercase tracking-widest text-brand-dark/30 dark:text-white/30 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/[0.03] dark:border-white/[0.03]">
|
<div className="absolute top-4 right-4 text-[8px] font-bold uppercase tracking-widest text-brand-dark/30 dark:text-white/30 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/[0.03] dark:border-white/[0.03]">
|
||||||
Format natif
|
{t('translate.upload.nativeFormat')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-16 h-16 bg-brand-muted dark:bg-white/5 rounded-2xl flex items-center justify-center text-brand-accent group-hover:scale-105 group-hover:bg-brand-dark dark:group-hover:bg-brand-accent group-hover:text-white dark:group-hover:text-brand-dark transition-all duration-300 mb-6 shadow-sm">
|
<div className="w-16 h-16 bg-brand-muted dark:bg-white/5 rounded-2xl flex items-center justify-center text-brand-accent group-hover:scale-105 group-hover:bg-brand-dark dark:group-hover:bg-brand-accent group-hover:text-white dark:group-hover:text-brand-dark transition-all duration-300 mb-6 shadow-sm">
|
||||||
@@ -256,12 +274,7 @@ export default function TranslatePage() {
|
|||||||
|
|
||||||
{/* Simulated file triggers */}
|
{/* Simulated file triggers */}
|
||||||
<div className="flex flex-wrap justify-center gap-2.5" onClick={(e) => e.stopPropagation()}>
|
<div className="flex flex-wrap justify-center gap-2.5" onClick={(e) => e.stopPropagation()}>
|
||||||
{[
|
{fileTypeButtons.map(f => (
|
||||||
{ label: 'Word (.docx)', type: 'word' as const, icon: <FileText size={11} className="text-blue-500" /> },
|
|
||||||
{ label: 'Excel (.xlsx)', type: 'excel' as const, icon: <FileSpreadsheet size={11} className="text-green-500" /> },
|
|
||||||
{ label: 'Slides (.pptx)', type: 'slides' as const, icon: <Presentation size={11} className="text-orange-500" /> },
|
|
||||||
{ label: 'PDF (.pdf)', type: 'pdf' as const, icon: <FileType size={11} className="text-red-500" /> },
|
|
||||||
].map(f => (
|
|
||||||
<button
|
<button
|
||||||
key={f.type}
|
key={f.type}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -310,17 +323,17 @@ export default function TranslatePage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{submit.isSubmitting ? (
|
{submit.isSubmitting ? (
|
||||||
<><Loader2 className="size-4 animate-spin" /> {t('dashboard.translate.actions.uploading') || 'Soumission...'}</>
|
<><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</>
|
||||||
) : (
|
) : (
|
||||||
<>Lancer la traduction <ArrowRight size={13} className={upload.file ? 'text-brand-accent' : 'opacity-20'} /></>
|
<>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.file ? 'text-brand-accent' : 'opacity-20'} /></>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!upload.file && (
|
{!upload.file && (
|
||||||
<p className="text-center text-[8px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest">{t('dashboard.translate.noFile') || 'Veuillez charger un fichier'}</p>
|
<p className="text-center text-[8px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest">{t('translate.pleaseLoadFile')}</p>
|
||||||
)}
|
)}
|
||||||
{upload.file && !config.targetLang && (
|
{upload.file && !config.targetLang && (
|
||||||
<p className="text-center text-[8px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest">{t('dashboard.translate.noTargetLang') || 'Veuillez choisir une langue cible'}</p>
|
<p className="text-center text-[8px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest">{t('translate.chooseTargetLang')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between text-[7.5px] font-bold uppercase tracking-[0.15em] text-brand-dark/30 dark:text-white/30 border-t border-black/5 dark:border-white/5 pt-4">
|
<div className="flex justify-between text-[7.5px] font-bold uppercase tracking-[0.15em] text-brand-dark/30 dark:text-white/30 border-t border-black/5 dark:border-white/5 pt-4">
|
||||||
@@ -343,7 +356,7 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
<h3 className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||||||
Moteur contextuel actif
|
{t('translate.contextEngineActive')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">
|
<p className="text-[10px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">
|
||||||
{submit.fileName || upload.file?.name}
|
{submit.fileName || upload.file?.name}
|
||||||
@@ -376,7 +389,7 @@ export default function TranslatePage() {
|
|||||||
|
|
||||||
<div className="flex justify-between items-end mt-12 pt-6">
|
<div className="flex justify-between items-end mt-12 pt-6">
|
||||||
<span className="text-[10px] font-bold text-brand-dark/30 dark:text-white/30 uppercase tracking-[0.3em]">
|
<span className="text-[10px] font-bold text-brand-dark/30 dark:text-white/30 uppercase tracking-[0.3em]">
|
||||||
{activeStepIdx < 2 ? 'Phase 1: Initialisation' : 'Phase 2: Reconstruction Contextuelle'}
|
{activeStepIdx < 2 ? t('translate.phase1') : t('translate.phase2')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-7xl font-serif font-medium text-brand-dark dark:text-white leading-none">
|
<span className="text-7xl font-serif font-medium text-brand-dark dark:text-white leading-none">
|
||||||
{Math.round(submit.progress)}%
|
{Math.round(submit.progress)}%
|
||||||
@@ -384,10 +397,10 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4 pt-12 border-t border-black/5 dark:border-white/5">
|
<div className="grid grid-cols-4 gap-4 pt-12 border-t border-black/5 dark:border-white/5">
|
||||||
<StatBox icon={<FileText size={18} />} value={`${Math.round(submit.progress)}%`} label="segments" />
|
<StatBox icon={<FileText size={18} />} value={`${Math.round(submit.progress)}%`} label={t('translate.stat.segments')} />
|
||||||
<StatBox icon={<Zap size={18} />} value="99.9%" label="précision" />
|
<StatBox icon={<Zap size={18} />} value="99.9%" label={t('translate.stat.precision')} />
|
||||||
<StatBox icon={<Clock size={18} />} value="Turbo" label="vitesse" />
|
<StatBox icon={<Clock size={18} />} value="Turbo" label={t('translate.stat.speedLabel')} />
|
||||||
<StatBox icon={<Activity size={18} />} value={formatElapsed(elapsed)} label="temps" />
|
<StatBox icon={<Activity size={18} />} value={formatElapsed(elapsed)} label={t('translate.stat.time')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -402,7 +415,7 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="text-[13px] font-bold uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
<p className="text-[13px] font-bold uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
||||||
Traduction terminée
|
{t('translate.header.completedTitle')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-brand-dark/40 dark:text-white/40 font-bold uppercase mt-1 tracking-widest max-w-[300px] truncate">
|
<p className="text-[10px] text-brand-dark/40 dark:text-white/40 font-bold uppercase mt-1 tracking-widest max-w-[300px] truncate">
|
||||||
{submit.fileName}
|
{submit.fileName}
|
||||||
@@ -410,7 +423,7 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[9px] font-bold uppercase tracking-widest text-brand-accent border border-brand-accent/20 shadow-sm shrink-0">
|
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[9px] font-bold uppercase tracking-widest text-brand-accent border border-brand-accent/20 shadow-sm shrink-0">
|
||||||
✓ Qualité Maître
|
{t('translate.complete.masterQuality')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -420,13 +433,13 @@ export default function TranslatePage() {
|
|||||||
className="premium-button px-24 py-6 text-xl !rounded-full flex items-center gap-6 mb-8 group cursor-pointer hover:scale-[1.02] active:scale-95"
|
className="premium-button px-24 py-6 text-xl !rounded-full flex items-center gap-6 mb-8 group cursor-pointer hover:scale-[1.02] active:scale-95"
|
||||||
>
|
>
|
||||||
<Download size={28} className="group-hover:translate-y-1 transition-transform" />
|
<Download size={28} className="group-hover:translate-y-1 transition-transform" />
|
||||||
Télécharger
|
{t('translate.download')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleNewTranslation}
|
onClick={handleNewTranslation}
|
||||||
className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark/30 hover:text-brand-dark dark:text-white/30 dark:hover:text-white transition-colors"
|
className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark/30 hover:text-brand-dark dark:text-white/30 dark:hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
+ Nouvelle traduction
|
{t('translate.newTranslation')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -441,7 +454,7 @@ export default function TranslatePage() {
|
|||||||
<AlertTriangle className="size-5 text-red-500" />
|
<AlertTriangle className="size-5 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 text-left">
|
<div className="flex-1 min-w-0 text-left">
|
||||||
<p className="text-sm font-bold uppercase tracking-tight text-red-600 dark:text-red-400 mb-2">Erreur lors de la traduction</p>
|
<p className="text-sm font-bold uppercase tracking-tight text-red-600 dark:text-red-400 mb-2">{t('translate.failedTitle')}</p>
|
||||||
<p className="text-xs text-red-600/80 dark:text-red-300/80 leading-relaxed font-medium">{humanFriendlyError(submit.error)}</p>
|
<p className="text-xs text-red-600/80 dark:text-red-300/80 leading-relaxed font-medium">{humanFriendlyError(submit.error)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -459,7 +472,7 @@ export default function TranslatePage() {
|
|||||||
className="premium-button w-full py-5 text-[12px] uppercase tracking-[0.25em] flex items-center justify-center gap-3 !rounded-2xl cursor-pointer hover:scale-[1.01] active:scale-98"
|
className="premium-button w-full py-5 text-[12px] uppercase tracking-[0.25em] flex items-center justify-center gap-3 !rounded-2xl cursor-pointer hover:scale-[1.01] active:scale-98"
|
||||||
>
|
>
|
||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
Réessayer
|
{t('translate.retry')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -467,7 +480,7 @@ export default function TranslatePage() {
|
|||||||
className="w-full py-4 border border-black/10 dark:border-white/10 rounded-2xl text-[10px] font-bold uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-all flex items-center justify-center gap-3 cursor-pointer hover:bg-brand-muted/30 dark:hover:bg-white/5"
|
className="w-full py-4 border border-black/10 dark:border-white/10 rounded-2xl text-[10px] font-bold uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-all flex items-center justify-center gap-3 cursor-pointer hover:bg-brand-muted/30 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
Téléverser un autre fichier
|
{t('translate.uploadAnother')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -556,7 +569,7 @@ export default function TranslatePage() {
|
|||||||
{config.mode === 'classic' ? (
|
{config.mode === 'classic' ? (
|
||||||
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center">
|
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center">
|
||||||
<span className="text-[7.5px] font-black uppercase opacity-45 block">
|
<span className="text-[7.5px] font-black uppercase opacity-45 block">
|
||||||
Indisponible en mode Standard (IA uniquement)
|
{t('translate.unavailableStandard')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -590,7 +603,7 @@ export default function TranslatePage() {
|
|||||||
{t('dashboard.translate.pdfMode.preserveLayout') || 'Mise en page'}
|
{t('dashboard.translate.pdfMode.preserveLayout') || 'Mise en page'}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-[8px] text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-relaxed">
|
<p className="mt-1.5 text-[8px] text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-relaxed">
|
||||||
Conserver la mise en page
|
{t('translate.preserveLayoutDesc')}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -605,10 +618,10 @@ export default function TranslatePage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white">
|
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white">
|
||||||
<Languages className="size-3.5 text-brand-accent" />
|
<Languages className="size-3.5 text-brand-accent" />
|
||||||
Texte brut
|
{t('translate.textOnly')}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-[8px] text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-relaxed">
|
<p className="mt-1.5 text-[8px] text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-relaxed">
|
||||||
Traduction rapide du texte uniquement
|
{t('translate.textOnlyDesc')}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -624,7 +637,7 @@ export default function TranslatePage() {
|
|||||||
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
|
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] mb-8 flex items-center gap-3 text-brand-dark/45 dark:text-white/45 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] mb-8 flex items-center gap-3 text-brand-dark/45 dark:text-white/45 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
||||||
<div className="w-2 h-2 bg-brand-accent rounded-full animate-ping" />
|
<div className="w-2 h-2 bg-brand-accent rounded-full animate-ping" />
|
||||||
Moniteur IA
|
{t('translate.monitor')}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* File summary */}
|
{/* File summary */}
|
||||||
@@ -670,8 +683,8 @@ export default function TranslatePage() {
|
|||||||
{/* Quality progress */}
|
{/* Quality progress */}
|
||||||
<div className="pt-6 border-t border-black/5 dark:border-white/5 text-left">
|
<div className="pt-6 border-t border-black/5 dark:border-white/5 text-left">
|
||||||
<div className="flex justify-between text-[9px] font-bold uppercase tracking-[0.2em] mb-3">
|
<div className="flex justify-between text-[9px] font-bold uppercase tracking-[0.2em] mb-3">
|
||||||
<span className="text-brand-dark/40 dark:text-white/40">Intégrité Layout</span>
|
<span className="text-brand-dark/40 dark:text-white/40">{t('translate.layoutIntegrity')}</span>
|
||||||
<span className="text-brand-accent">100% SECURE</span>
|
<span className="text-brand-accent">{t('translate.secureHundred')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-brand-muted dark:bg-white/5 rounded-full overflow-hidden p-0.5">
|
<div className="h-2 bg-brand-muted dark:bg-white/5 rounded-full overflow-hidden p-0.5">
|
||||||
<div
|
<div
|
||||||
@@ -685,7 +698,7 @@ export default function TranslatePage() {
|
|||||||
onClick={handleNewTranslation}
|
onClick={handleNewTranslation}
|
||||||
className="w-full mt-12 py-4 border border-red-100 text-red-500 rounded-2xl text-[9px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-red-50 dark:border-red-950/20 dark:hover:bg-red-950/30 transition-all cursor-pointer"
|
className="w-full mt-12 py-4 border border-red-100 text-red-500 rounded-2xl text-[9px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-red-50 dark:border-red-950/20 dark:hover:bg-red-950/30 transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
⟳ Annuler le processus
|
{t('translate.cancelProcess')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -695,7 +708,7 @@ export default function TranslatePage() {
|
|||||||
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
|
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial h-full">
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] mb-8 flex items-center gap-3 text-brand-dark/45 dark:text-white/45 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] mb-8 flex items-center gap-3 text-brand-dark/45 dark:text-white/45 pb-3 border-b border-black/[0.03] dark:border-white/[0.03]">
|
||||||
<CheckCircle2 size={14} className="text-emerald-500" />
|
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||||
Récapitulatif
|
{t('translate.summary')}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="space-y-6 mb-8 px-2 text-left">
|
<div className="space-y-6 mb-8 px-2 text-left">
|
||||||
@@ -717,8 +730,8 @@ export default function TranslatePage() {
|
|||||||
|
|
||||||
<div className="pt-6 border-t border-black/5 dark:border-white/5 text-left">
|
<div className="pt-6 border-t border-black/5 dark:border-white/5 text-left">
|
||||||
<div className="flex justify-between text-[9px] font-bold uppercase tracking-[0.2em] mb-3">
|
<div className="flex justify-between text-[9px] font-bold uppercase tracking-[0.2em] mb-3">
|
||||||
<span className="text-brand-dark/40 dark:text-white/40">Intégrité Layout</span>
|
<span className="text-brand-dark/40 dark:text-white/40">{t('translate.layoutIntegrity')}</span>
|
||||||
<span className="text-brand-accent">100% OK</span>
|
<span className="text-brand-accent">{t('translate.okHundred')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-brand-muted dark:bg-white/5 rounded-full overflow-hidden p-0.5">
|
<div className="h-2 bg-brand-muted dark:bg-white/5 rounded-full overflow-hidden p-0.5">
|
||||||
<div className="h-full bg-brand-accent rounded-full" style={{ width: '100%' }} />
|
<div className="h-full bg-brand-accent rounded-full" style={{ width: '100%' }} />
|
||||||
@@ -773,9 +786,9 @@ export default function TranslatePage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{submit.isSubmitting ? (
|
{submit.isSubmitting ? (
|
||||||
<><Loader2 className="size-4 animate-spin" /> {t('dashboard.translate.actions.uploading') || 'Soumission...'}</>
|
<><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</>
|
||||||
) : (
|
) : (
|
||||||
<>Lancer la traduction <ArrowRight size={13} className={upload.file ? 'text-brand-accent' : 'opacity-20'} /></>
|
<>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.file ? 'text-brand-accent' : 'opacity-20'} /></>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,9 +83,30 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
|||||||
// Reset glossary selection when source language changes
|
// Reset glossary selection when source language changes
|
||||||
// (multilingual glossaries are compatible with any target language)
|
// (multilingual glossaries are compatible with any target language)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// If there is a query param, do not reset it on first load
|
||||||
|
const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
|
||||||
|
if (params.get('glossaryId') === glossaryId) return;
|
||||||
setGlossaryId(null);
|
setGlossaryId(null);
|
||||||
}, [sourceLang]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [sourceLang]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Pre-select glossary and LLM provider from query parameter
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || isLoadingProviders || availableProviders.length === 0) return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const qGlossaryId = params.get('glossaryId');
|
||||||
|
if (qGlossaryId) {
|
||||||
|
setGlossaryId(qGlossaryId);
|
||||||
|
|
||||||
|
const p = availableProviders.find((ap) => ap.id === provider);
|
||||||
|
if (!p || p.mode !== 'llm') {
|
||||||
|
const firstLlm = availableProviders.find((ap) => ap.mode === 'llm');
|
||||||
|
if (firstLlm) {
|
||||||
|
setProvider(firstLlm.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isLoadingProviders, availableProviders, provider]);
|
||||||
|
|
||||||
// Fetch available (admin-configured) providers
|
// Fetch available (admin-configured) providers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
export function useLogout() {
|
export function useLogout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem('refresh_token');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
|
queryClient.clear();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ThemeProvider } from "@/providers/ThemeProvider";
|
|||||||
import { NotificationProvider } from "@/components/ui/notification";
|
import { NotificationProvider } from "@/components/ui/notification";
|
||||||
import { I18nProvider } from "@/lib/i18n";
|
import { I18nProvider } from "@/lib/i18n";
|
||||||
import { Agentation } from "agentation";
|
import { Agentation } from "agentation";
|
||||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
import { ClientGoogleProvider } from "@/providers/ClientGoogleProvider";
|
||||||
import { CookieConsent } from "@/components/ui/cookie-consent";
|
import { CookieConsent } from "@/components/ui/cookie-consent";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -37,12 +37,12 @@ export default function RootLayout({
|
|||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""}>
|
<ClientGoogleProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
{children}
|
{children}
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</GoogleOAuthProvider>
|
</ClientGoogleProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
{process.env.NODE_ENV === "development" && <Agentation />}
|
{process.env.NODE_ENV === "development" && <Agentation />}
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ export default function PricingPage() {
|
|||||||
href={isLoggedIn ? "/dashboard" : "/"}
|
href={isLoggedIn ? "/dashboard" : "/"}
|
||||||
className="px-6 py-2 rounded-full text-[9px] font-black uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all"
|
className="px-6 py-2 rounded-full text-[9px] font-black uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all"
|
||||||
>
|
>
|
||||||
Dashboard
|
{t('pricing.dashboard')}
|
||||||
</Link>
|
</Link>
|
||||||
{isLoggedIn && (
|
{isLoggedIn && (
|
||||||
<Link
|
<Link
|
||||||
@@ -486,7 +486,7 @@ export default function PricingPage() {
|
|||||||
? "bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/50 dark:border-emerald-800 dark:text-emerald-300"
|
? "bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/50 dark:border-emerald-800 dark:text-emerald-300"
|
||||||
: "bg-red-50 border-red-200 text-red-700 dark:bg-red-950/50 dark:border-red-800 dark:text-red-300"
|
: "bg-red-50 border-red-200 text-red-700 dark:bg-red-950/50 dark:border-red-800 dark:text-red-300"
|
||||||
)}>
|
)}>
|
||||||
<span className="text-lg">{toastMsg.type === 'ok' ? '✓' : '✕'}</span>
|
<span className="text-lg">{toastMsg.type === 'ok' ? t('pricing.okSymbol') : t('pricing.errSymbol')}</span>
|
||||||
<p className="text-sm flex-1">{toastMsg.text}</p>
|
<p className="text-sm flex-1">{toastMsg.text}</p>
|
||||||
<button onClick={() => setToastMsg(null)} className="text-muted-foreground hover:text-foreground text-lg leading-none">×</button>
|
<button onClick={() => setToastMsg(null)} className="text-muted-foreground hover:text-foreground text-lg leading-none">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -878,11 +878,11 @@ export default function PricingPage() {
|
|||||||
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">{t('pricing.aiModels.premium.plan')}</Badge>
|
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">{t('pricing.aiModels.premium.plan')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground mb-3">
|
<div className="text-sm text-muted-foreground mb-3">
|
||||||
{t('pricing.aiModels.premium.descPrefix')} <strong className="text-foreground">Claude 3.5 Haiku</strong> {t('pricing.aiModels.premium.descSuffix')}
|
{t('pricing.aiModels.premium.descPrefix')} <strong className="text-foreground">Claude Sonnet 4.6</strong> {t('pricing.aiModels.premium.descSuffix')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.premium.context')}</span>
|
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.premium.context')}</span>
|
||||||
<span className="px-2 py-1 bg-muted rounded">$0.25/$1.25 per 1M</span>
|
<span className="px-2 py-1 bg-muted rounded">$3.00/$15.00 per 1M</span>
|
||||||
<span className="px-2 py-1 bg-accent/10 text-accent rounded">{t('pricing.aiModels.premium.precision')}</span>
|
<span className="px-2 py-1 bg-accent/10 text-accent rounded">{t('pricing.aiModels.premium.precision')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
FileText,
|
FileText,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Presentation,
|
Presentation,
|
||||||
X,
|
X,
|
||||||
Download,
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
Cpu,
|
Cpu,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Brain,
|
Brain,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
File,
|
File,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -35,6 +35,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { useTranslationStore, openaiModels, openrouterModels } from "@/lib/store";
|
import { useTranslationStore, openaiModels, openrouterModels } from "@/lib/store";
|
||||||
import { translateDocument, languages, providers, extractTextsFromDocument, reconstructDocument, TranslatedText } from "@/lib/api";
|
import { translateDocument, languages, providers, extractTextsFromDocument, reconstructDocument, TranslatedText } from "@/lib/api";
|
||||||
import { useWebLLM } from "@/lib/webllm";
|
import { useWebLLM } from "@/lib/webllm";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const fileIcons: Record<string, React.ElementType> = {
|
const fileIcons: Record<string, React.ElementType> = {
|
||||||
@@ -54,13 +55,14 @@ interface FilePreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
|
const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generatePreview = async () => {
|
const generatePreview = async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
@@ -120,7 +122,7 @@ const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
{getFileExtension(file.name).toUpperCase()}
|
{getFileExtension(file.name).toUpperCase()}
|
||||||
@@ -145,9 +147,9 @@ const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
|
|||||||
) : preview ? (
|
) : preview ? (
|
||||||
<div className="p-4 h-full overflow-hidden">
|
<div className="p-4 h-full overflow-hidden">
|
||||||
{file.type.startsWith('image/') ? (
|
{file.type.startsWith('image/') ? (
|
||||||
<img
|
<img
|
||||||
src={preview}
|
src={preview}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
className="w-full h-full object-contain rounded"
|
className="w-full h-full object-contain rounded"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -167,7 +169,7 @@ const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
|
|||||||
<div className="flex items-center justify-between p-4 border-t border-border-subtle">
|
<div className="flex items-center justify-between p-4 border-t border-border-subtle">
|
||||||
<div className="flex items-center gap-2 text-sm text-text-tertiary">
|
<div className="flex items-center gap-2 text-sm text-text-tertiary">
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
Preview
|
{t('fileUploader.preview')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon-sm">
|
<Button variant="ghost" size="icon-sm">
|
||||||
@@ -184,6 +186,7 @@ const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function FileUploader() {
|
export function FileUploader() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { settings } = useTranslationStore();
|
const { settings } = useTranslationStore();
|
||||||
const webllm = useWebLLM();
|
const webllm = useWebLLM();
|
||||||
|
|
||||||
@@ -198,7 +201,7 @@ export function FileUploader() {
|
|||||||
const [isTranslating, setTranslating] = useState(false);
|
const [isTranslating, setTranslating] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Sync with store settings when they change
|
// Sync with store settings when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTargetLanguage(settings.defaultTargetLanguage);
|
setTargetLanguage(settings.defaultTargetLanguage);
|
||||||
@@ -233,11 +236,11 @@ export function FileUploader() {
|
|||||||
// WebLLM specific validation
|
// WebLLM specific validation
|
||||||
if (provider === "webllm") {
|
if (provider === "webllm") {
|
||||||
if (!webllm.isWebGPUSupported()) {
|
if (!webllm.isWebGPUSupported()) {
|
||||||
setError("WebGPU is not supported in this browser. Please use Chrome 113+ or Edge 113+.");
|
setError(t('fileUploader.webgpuUnsupported'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!webllm.isLoaded) {
|
if (!webllm.isLoaded) {
|
||||||
setError("WebLLM model not loaded. Go to Settings > Translation Services to load a model first.");
|
setError(t('fileUploader.webllmNotLoaded'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,7 +259,7 @@ export function FileUploader() {
|
|||||||
await handleServerTranslation();
|
await handleServerTranslation();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Translation failed");
|
setError(err instanceof Error ? err.message : t('fileUploader.translationError'));
|
||||||
} finally {
|
} finally {
|
||||||
setTranslating(false);
|
setTranslating(false);
|
||||||
setTranslationStatus("");
|
setTranslationStatus("");
|
||||||
@@ -275,15 +278,15 @@ export function FileUploader() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Extract texts from document
|
// Step 1: Extract texts from document
|
||||||
setTranslationStatus("Extracting texts from document...");
|
setTranslationStatus(t('fileUploader.extracting'));
|
||||||
setProgress(5);
|
setProgress(5);
|
||||||
const extractResult = await extractTextsFromDocument(file);
|
const extractResult = await extractTextsFromDocument(file);
|
||||||
|
|
||||||
if (extractResult.texts.length === 0) {
|
if (extractResult.texts.length === 0) {
|
||||||
throw new Error("No translatable text found in document");
|
throw new Error(t('fileUploader.noTranslatable'));
|
||||||
}
|
}
|
||||||
|
|
||||||
setTranslationStatus(`Found ${extractResult.texts.length} texts to translate`);
|
setTranslationStatus(t('fileUploader.foundTexts', { count: extractResult.texts.length }));
|
||||||
setProgress(10);
|
setProgress(10);
|
||||||
|
|
||||||
// Step 2: Translate each text using WebLLM
|
// Step 2: Translate each text using WebLLM
|
||||||
@@ -293,15 +296,19 @@ export function FileUploader() {
|
|||||||
|
|
||||||
for (let i = 0; i < totalTexts; i++) {
|
for (let i = 0; i < totalTexts; i++) {
|
||||||
const item = extractResult.texts[i];
|
const item = extractResult.texts[i];
|
||||||
setTranslationStatus(`Translating ${i + 1}/${totalTexts}: "${item.text.substring(0, 30)}..."`);
|
setTranslationStatus(t('fileUploader.translatingItem', {
|
||||||
|
current: String(i + 1),
|
||||||
|
total: String(totalTexts),
|
||||||
|
preview: item.text.substring(0, 30),
|
||||||
|
}));
|
||||||
|
|
||||||
const translatedText = await webllm.translate(
|
const translatedText = await webllm.translate(
|
||||||
item.text,
|
item.text,
|
||||||
langName,
|
langName,
|
||||||
settings.systemPrompt || undefined,
|
settings.systemPrompt || undefined,
|
||||||
settings.glossary || undefined
|
settings.glossary || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
translations.push({
|
translations.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
translated_text: translatedText,
|
translated_text: translatedText,
|
||||||
@@ -313,7 +320,7 @@ export function FileUploader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Reconstruct document with translations
|
// Step 3: Reconstruct document with translations
|
||||||
setTranslationStatus("Reconstructing document...");
|
setTranslationStatus(t('fileUploader.reconstructing'));
|
||||||
setProgress(92);
|
setProgress(92);
|
||||||
const blob = await reconstructDocument(
|
const blob = await reconstructDocument(
|
||||||
extractResult.session_id,
|
extractResult.session_id,
|
||||||
@@ -322,7 +329,7 @@ export function FileUploader() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setProgress(100);
|
setProgress(100);
|
||||||
setTranslationStatus("Translation complete!");
|
setTranslationStatus(t('fileUploader.translationComplete'));
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setDownloadUrl(url);
|
setDownloadUrl(url);
|
||||||
@@ -406,10 +413,10 @@ export function FileUploader() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-3">
|
<CardTitle className="flex items-center gap-3">
|
||||||
<Upload className="h-5 w-5 text-primary" />
|
<Upload className="h-5 w-5 text-primary" />
|
||||||
Upload Document
|
{t('fileUploader.uploadDocument')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Drag and drop or click to select a file (Excel, Word, PowerPoint)
|
{t('fileUploader.uploadDesc')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
@@ -424,7 +431,7 @@ export function FileUploader() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
|
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
|
||||||
|
|
||||||
{/* Upload Icon with animation */}
|
{/* Upload Icon with animation */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-16 h-16 mx-auto mb-4 rounded-2xl bg-primary/10 flex items-center justify-center transition-all duration-300",
|
"w-16 h-16 mx-auto mb-4 rounded-2xl bg-primary/10 flex items-center justify-center transition-all duration-300",
|
||||||
@@ -435,16 +442,16 @@ export function FileUploader() {
|
|||||||
isDragActive ? "scale-110" : ""
|
isDragActive ? "scale-110" : ""
|
||||||
)} />
|
)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-lg font-medium text-foreground mb-2">
|
<p className="text-lg font-medium text-foreground mb-2">
|
||||||
{isDragActive
|
{isDragActive
|
||||||
? "Drop your file here..."
|
? t('fileUploader.dropHere')
|
||||||
: "Drag & drop your document here"}
|
: t('fileUploader.dragAndDrop')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text-tertiary mb-6">
|
<p className="text-sm text-text-tertiary mb-6">
|
||||||
or click to browse
|
{t('fileUploader.orClickBrowse')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Supported formats */}
|
{/* Supported formats */}
|
||||||
<div className="flex flex-wrap justify-center gap-3">
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
{[
|
{[
|
||||||
@@ -472,19 +479,19 @@ export function FileUploader() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-3">
|
<CardTitle className="flex items-center gap-3">
|
||||||
<Brain className="h-5 w-5 text-primary" />
|
<Brain className="h-5 w-5 text-primary" />
|
||||||
Translation Options
|
{t('fileUploader.translationOptions')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure your translation settings
|
{t('fileUploader.configureSettings')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Target Language */}
|
{/* Target Language */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="language" className="text-text-secondary font-medium">Target Language</Label>
|
<Label htmlFor="language" className="text-text-secondary font-medium">{t('fileUploader.targetLanguage')}</Label>
|
||||||
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
|
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
|
||||||
<SelectTrigger id="language" className="bg-surface border-border-subtle">
|
<SelectTrigger id="language" className="bg-surface border-border-subtle">
|
||||||
<SelectValue placeholder="Select language" />
|
<SelectValue placeholder={t('fileUploader.selectLanguage')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-surface-elevated border-border max-h-80">
|
<SelectContent className="bg-surface-elevated border-border max-h-80">
|
||||||
{languages.map((lang) => (
|
{languages.map((lang) => (
|
||||||
@@ -505,10 +512,10 @@ export function FileUploader() {
|
|||||||
|
|
||||||
{/* Provider Selection */}
|
{/* Provider Selection */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-text-secondary font-medium">Translation Provider</Label>
|
<Label className="text-text-secondary font-medium">{t('fileUploader.translationProvider')}</Label>
|
||||||
<Select value={provider} onValueChange={(value: ProviderType) => setProvider(value)}>
|
<Select value={provider} onValueChange={(value: ProviderType) => setProvider(value)}>
|
||||||
<SelectTrigger className="bg-surface border-border-subtle">
|
<SelectTrigger className="bg-surface border-border-subtle">
|
||||||
<SelectValue placeholder="Select provider" />
|
<SelectValue placeholder={t('fileUploader.selectProvider')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-surface-elevated border-border">
|
<SelectContent className="bg-surface-elevated border-border">
|
||||||
{providers.map((p) => (
|
{providers.map((p) => (
|
||||||
@@ -536,7 +543,7 @@ export function FileUploader() {
|
|||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
className="w-full justify-between text-primary hover:text-primary/80"
|
className="w-full justify-between text-primary hover:text-primary/80"
|
||||||
>
|
>
|
||||||
<span>Advanced Options</span>
|
<span>{t('fileUploader.advancedOptions')}</span>
|
||||||
<ChevronRight className={cn(
|
<ChevronRight className={cn(
|
||||||
"h-4 w-4 transition-transform duration-200",
|
"h-4 w-4 transition-transform duration-200",
|
||||||
showAdvanced && "rotate-90"
|
showAdvanced && "rotate-90"
|
||||||
@@ -547,7 +554,7 @@ export function FileUploader() {
|
|||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<div className="space-y-4 p-4 rounded-lg bg-surface/50 border border-border-subtle animate-slide-up">
|
<div className="space-y-4 p-4 rounded-lg bg-surface/50 border border-border-subtle animate-slide-up">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="translate-images" className="text-text-secondary">Translate Images</Label>
|
<Label htmlFor="translate-images" className="text-text-secondary">{t('fileUploader.translateImages')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="translate-images"
|
id="translate-images"
|
||||||
checked={translateImages}
|
checked={translateImages}
|
||||||
@@ -568,12 +575,12 @@ export function FileUploader() {
|
|||||||
{isTranslating ? (
|
{isTranslating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="me-2 h-5 w-5 animate-spin" />
|
<Loader2 className="me-2 h-5 w-5 animate-spin" />
|
||||||
Translating...
|
{t('fileUploader.translating')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Zap className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
<Zap className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||||
Translate Document
|
{t('fileUploader.translateDocument')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -583,7 +590,7 @@ export function FileUploader() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-text-secondary">
|
<span className="text-text-secondary">
|
||||||
{translationStatus || "Processing..."}
|
{translationStatus || t('fileUploader.processing')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-primary font-medium">{Math.round(progress)}%</span>
|
<span className="text-primary font-medium">{Math.round(progress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -591,7 +598,7 @@ export function FileUploader() {
|
|||||||
{provider === "webllm" && (
|
{provider === "webllm" && (
|
||||||
<div className="flex items-center gap-2 text-xs text-text-tertiary p-3 rounded-lg bg-primary/5">
|
<div className="flex items-center gap-2 text-xs text-text-tertiary p-3 rounded-lg bg-primary/5">
|
||||||
<Cpu className="h-3 w-3" />
|
<Cpu className="h-3 w-3" />
|
||||||
Translating locally with WebLLM...
|
{t('fileUploader.translatingLocally')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -603,7 +610,7 @@ export function FileUploader() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-destructive mb-1">Translation Error</p>
|
<p className="text-sm font-medium text-destructive mb-1">{t('fileUploader.translationError')}</p>
|
||||||
<p className="text-sm text-destructive/80">{error}</p>
|
<p className="text-sm text-destructive/80">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,9 +627,9 @@ export function FileUploader() {
|
|||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/20 flex items-center justify-center animate-pulse">
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/20 flex items-center justify-center animate-pulse">
|
||||||
<CheckCircle className="w-8 h-8 text-white" />
|
<CheckCircle className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl mb-2">Translation Complete!</CardTitle>
|
<CardTitle className="text-2xl mb-2">{t('fileUploader.translationComplete')}</CardTitle>
|
||||||
<CardDescription className="mb-6">
|
<CardDescription className="mb-6">
|
||||||
Your document has been translated successfully while preserving all formatting.
|
{t('fileUploader.translationCompleteDesc')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
@@ -631,7 +638,7 @@ export function FileUploader() {
|
|||||||
className="group px-8"
|
className="group px-8"
|
||||||
>
|
>
|
||||||
<Download className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
<Download className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||||
Download Translated Document
|
{t('fileUploader.download')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function SiteFooter() {
|
export function SiteFooter() {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-border py-6 text-center text-xs text-muted-foreground">
|
<footer className="border-t border-border py-6 text-center text-xs text-muted-foreground">
|
||||||
<div className="mx-auto max-w-5xl px-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
<div className="mx-auto max-w-5xl px-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
<span>© 2026 Wordly. All rights reserved.</span>
|
<span>{t('landing.footer.rights')}</span>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<Link href="/pricing" className="hover:text-foreground transition-colors">
|
<Link href="/pricing" className="hover:text-foreground transition-colors">
|
||||||
Pricing
|
{t('landing.nav.pricing')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/pricing#terms" className="hover:text-foreground transition-colors">
|
<Link href="/pricing#terms" className="hover:text-foreground transition-colors">
|
||||||
Terms
|
{t('layout.footer.terms')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/pricing#privacy" className="hover:text-foreground transition-colors">
|
<Link href="/pricing#privacy" className="hover:text-foreground transition-colors">
|
||||||
Privacy
|
{t('layout.footer.privacy')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import Link from "next/link"
|
"use client";
|
||||||
import { Languages } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import Link from "next/link";
|
||||||
|
import { Languages } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-lg">
|
<header className="sticky top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-lg">
|
||||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
|
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
|
||||||
@@ -17,23 +21,23 @@ export function SiteHeader() {
|
|||||||
|
|
||||||
<nav className="hidden items-center gap-1 md:flex">
|
<nav className="hidden items-center gap-1 md:flex">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/pricing">Pricing</Link>
|
<Link href="/pricing">{t('landing.nav.pricing')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/pricing#api">API Access</Link>
|
<Link href="/pricing#api">{t('layout.nav.apiAccess')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="mx-2 h-4 w-px bg-border" />
|
<div className="mx-2 h-4 w-px bg-border" />
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link href="/dashboard">Login</Link>
|
<Link href="/dashboard">{t('landing.nav.login')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link href="/dashboard">Login</Link>
|
<Link href="/dashboard">{t('landing.nav.login')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
48
frontend/src/lib/i18n/messages/ar/admin.json
Normal file
48
frontend/src/lib/i18n/messages/ar/admin.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"admin.login.title": "الإدارة",
|
||||||
|
"admin.login.required": "تسجيل الدخول مطلوب",
|
||||||
|
"admin.login.password": "كلمة مرور المسؤول",
|
||||||
|
"admin.login.connecting": "جارٍ الاتصال...",
|
||||||
|
"admin.login.access": "الدخول إلى لوحة الإدارة",
|
||||||
|
"admin.login.restricted": "مخصص للمسؤولين فقط",
|
||||||
|
"admin.layout.checking": "جارٍ التحقق من المصادقة...",
|
||||||
|
"admin.dashboard.title": "لوحة تحكم المسؤول",
|
||||||
|
"admin.dashboard.subtitle": "لوحة تحكم المسؤولين",
|
||||||
|
"admin.dashboard.refresh": "تحديث",
|
||||||
|
"admin.dashboard.refreshTooltip": "تحديث بيانات لوحة التحكم",
|
||||||
|
"admin.dashboard.config": "إعدادات النظام",
|
||||||
|
"admin.dashboard.maxFileSize": "الحد الأقصى لحجم الملف:",
|
||||||
|
"admin.dashboard.translationService": "خدمة الترجمة:",
|
||||||
|
"admin.dashboard.formats": "التنسيقات:",
|
||||||
|
"admin.nav.dashboard": "Dashboard",
|
||||||
|
"admin.nav.users": "المستخدمون",
|
||||||
|
"admin.nav.pricing": "الأسعار و Stripe",
|
||||||
|
"admin.nav.providers": "المزوّدون",
|
||||||
|
"admin.nav.system": "النظام",
|
||||||
|
"admin.nav.logs": "السجلات",
|
||||||
|
"admin.users.title": "إدارة المستخدمين",
|
||||||
|
"admin.users.subtitle": "عرض حسابات المستخدمين وإدارتها",
|
||||||
|
"admin.users.planUpdated": "تم تحديث الخطة",
|
||||||
|
"admin.users.planChanged": "تم تغيير الخطة إلى \\\"{plan}\\\" بنجاح.",
|
||||||
|
"admin.users.unknownError": "خطأ غير معروف",
|
||||||
|
"admin.users.error": "خطأ",
|
||||||
|
"admin.users.planUpdateError": "تعذر تحديث الخطة: {message}",
|
||||||
|
"admin.users.noKeys": "لا توجد مفاتيح",
|
||||||
|
"admin.users.noKeysDesc": "لا يمتلك هذا المستخدم مفاتيح API نشطة.",
|
||||||
|
"admin.users.keysRevoked": "تم إلغاء المفاتيح",
|
||||||
|
"admin.users.keysRevokedDesc": "تم إلغاء {count} مفتاح API بنجاح.",
|
||||||
|
"admin.users.revokeError": "تعذر إلغاء المفاتيح: {message}",
|
||||||
|
"admin.users.retry": "إعادة المحاولة",
|
||||||
|
"admin.system.title": "النظام",
|
||||||
|
"admin.system.subtitle": "مراقبة حالة النظام وإدارة الموارد",
|
||||||
|
"admin.system.quotas": "حصص الترجمة",
|
||||||
|
"admin.system.resetQuotas": "إعادة تعيين الحصص الشهرية",
|
||||||
|
"admin.system.resetting": "جارٍ إعادة التعيين...",
|
||||||
|
"admin.system.reset": "إعادة تعيين",
|
||||||
|
"admin.system.allOperational": "جميع الأنظمة تعمل بشكل طبيعي",
|
||||||
|
"admin.system.issuesDetected": "تم اكتشاف مشاكل في النظام",
|
||||||
|
"admin.system.waitingData": "في انتظار البيانات...",
|
||||||
|
"admin.system.purging": "جارٍ الحذف...",
|
||||||
|
"admin.system.clean": "تنظيف",
|
||||||
|
"admin.system.purge": "حذف"
|
||||||
|
}
|
||||||
72
frontend/src/lib/i18n/messages/ar/apiKeys.json
Normal file
72
frontend/src/lib/i18n/messages/ar/apiKeys.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
|
"apiKeys.webhook.codeParam": "webhook_url",
|
||||||
|
"apiKeys.title": "مفاتيح API",
|
||||||
|
"apiKeys.subtitle": "إدارة مفاتيح API للوصول البرمجي إلى واجهة الترجمة.",
|
||||||
|
"apiKeys.loading": "جارٍ التحميل...",
|
||||||
|
"apiKeys.sectionTitle": "API والأتمتة",
|
||||||
|
"apiKeys.sectionDesc": "إنشاء وإدارة مفاتيح API لسير عمل الأتمتة",
|
||||||
|
"apiKeys.keysUsed": "{total} من {max} مفاتيح مستخدمة",
|
||||||
|
"apiKeys.maxReached": "تم بلوغ الحد الأقصى للمفاتيح. قم بإلغاء مفتاح لإنشاء واحد جديد.",
|
||||||
|
"apiKeys.canGenerate": "يمكنك إنشاء {count} مفتاح إضافي",
|
||||||
|
"apiKeys.canGeneratePlural": "يمكنك إنشاء {count} مفاتيح إضافية",
|
||||||
|
"apiKeys.generateNew": "إنشاء مفتاح جديد",
|
||||||
|
"apiKeys.keyRevoked": "تم إلغاء المفتاح",
|
||||||
|
"apiKeys.keyRevokedDesc": "تم إلغاء مفتاح API بنجاح.",
|
||||||
|
"apiKeys.keyNotFound": "المفتاح غير موجود",
|
||||||
|
"apiKeys.keyNotFoundDesc": "مفتاح API لم يعد موجودًا. ربما تم إلغاؤه بالفعل.",
|
||||||
|
"apiKeys.error": "خطأ",
|
||||||
|
"apiKeys.revokeError": "فشل إلغاء مفتاح API. يرجى المحاولة مرة أخرى.",
|
||||||
|
"apiKeys.limitReached": "تم بلوغ الحد",
|
||||||
|
"apiKeys.limitReachedDesc": "لقد وصلت إلى الحد الأقصى وهو 10 مفاتيح API. قم بإلغاء مفتاح حالي لإنشاء واحد جديد.",
|
||||||
|
"apiKeys.proRequired": "ميزة Pro مطلوبة",
|
||||||
|
"apiKeys.proRequiredDesc": "مفاتيح API هي ميزة Pro. يرجى ترقية حسابك.",
|
||||||
|
"apiKeys.generateError": "فشل إنشاء مفتاح API. يرجى المحاولة مرة أخرى.",
|
||||||
|
"apiKeys.upgrade.title": "مفاتيح API",
|
||||||
|
"apiKeys.upgrade.subtitle": "أتمتة ترجماتك مع وصول API",
|
||||||
|
"apiKeys.upgrade.feat1": "إنشاء مفاتيح API غير محدودة",
|
||||||
|
"apiKeys.upgrade.feat2": "أتمتة ترجمة المستندات",
|
||||||
|
"apiKeys.upgrade.feat3": "إشعارات Webhook",
|
||||||
|
"apiKeys.upgrade.feat4": "أوضاع ترجمة LLM",
|
||||||
|
"apiKeys.upgrade.proFeature": "مفاتيح API هي ميزة {pro}. قم بالترقية لفتح أتمتة API.",
|
||||||
|
"apiKeys.upgrade.pro": "Pro",
|
||||||
|
"apiKeys.upgrade.cta": "الترقية إلى Pro",
|
||||||
|
"apiKeys.dialog.maxTitle": "تم بلوغ الحد الأقصى للمفاتيح",
|
||||||
|
"apiKeys.dialog.maxDesc": "لقد وصلت إلى الحد الأقصى وهو 10 مفاتيح API. يرجى إلغاء مفتاح حالي قبل إنشاء واحد جديد.",
|
||||||
|
"apiKeys.dialog.close": "إغلاق",
|
||||||
|
"apiKeys.dialog.generated": "تم إنشاء مفتاح API!",
|
||||||
|
"apiKeys.dialog.generatedDesc": "تم إنشاء مفتاح API الجديد. انسخه الآن - لن يظهر مرة أخرى.",
|
||||||
|
"apiKeys.dialog.important": "مهم:",
|
||||||
|
"apiKeys.dialog.importantDesc": "هذه هي المرة الوحيدة التي سترى فيها هذا المفتاح. احفظه في مكان آمن.",
|
||||||
|
"apiKeys.dialog.apiKey": "مفتاح API",
|
||||||
|
"apiKeys.dialog.name": "الاسم:",
|
||||||
|
"apiKeys.dialog.done": "تم",
|
||||||
|
"apiKeys.dialog.copied": "لقد نسخت المفتاح",
|
||||||
|
"apiKeys.dialog.generateTitle": "إنشاء مفتاح API جديد",
|
||||||
|
"apiKeys.dialog.generateDesc": "إنشاء مفتاح API جديد للوصول البرمجي إلى واجهة الترجمة.",
|
||||||
|
"apiKeys.dialog.keyName": "اسم المفتاح (اختياري)",
|
||||||
|
"apiKeys.dialog.keyNamePlaceholder": "مثال: الإنتاج، الاختبار",
|
||||||
|
"apiKeys.dialog.keyNameHint": "اسم وصفي لمساعدتك على التعرف على هذا المفتاح لاحقًا.",
|
||||||
|
"apiKeys.dialog.nameTooLong": "يجب ألا يتجاوز الاسم {max} حرفًا",
|
||||||
|
"apiKeys.dialog.nameInvalid": "يمكن أن يحتوي الاسم على أحرف وأرقام ومسافات وشرطات وشرطات سفلية فقط",
|
||||||
|
"apiKeys.dialog.cancel": "إلغاء",
|
||||||
|
"apiKeys.dialog.generating": "جارٍ الإنشاء...",
|
||||||
|
"apiKeys.dialog.generate": "إنشاء مفتاح",
|
||||||
|
"apiKeys.table.name": "الاسم",
|
||||||
|
"apiKeys.table.prefix": "البادئة",
|
||||||
|
"apiKeys.table.created": "تاريخ الإنشاء",
|
||||||
|
"apiKeys.table.lastUsed": "آخر استخدام",
|
||||||
|
"apiKeys.table.never": "أبدًا",
|
||||||
|
"apiKeys.table.actions": "الإجراءات",
|
||||||
|
"apiKeys.table.revoke": "إلغاء",
|
||||||
|
"apiKeys.table.copyPrefix": "نسخ بادئة المفتاح",
|
||||||
|
"apiKeys.table.revokeKey": "إلغاء المفتاح",
|
||||||
|
"apiKeys.revokeDialog.title": "إلغاء مفتاح API",
|
||||||
|
"apiKeys.revokeDialog.desc": "هل أنت متأكد من إلغاء المفتاح \\\"{name}\\\"؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"apiKeys.revokeDialog.confirm": "نعم، إلغاء",
|
||||||
|
"apiKeys.revokeDialog.cancel": "إلغاء",
|
||||||
|
"apiKeys.noKeysGenerated": "لم يتم إنشاء مفاتيح",
|
||||||
|
"apiKeys.copied": "تم النسخ!"
|
||||||
|
}
|
||||||
3
frontend/src/lib/i18n/messages/ar/auth.json
Normal file
3
frontend/src/lib/i18n/messages/ar/auth.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"auth.brandName": "Wordly"
|
||||||
|
}
|
||||||
12
frontend/src/lib/i18n/messages/ar/checkout.json
Normal file
12
frontend/src/lib/i18n/messages/ar/checkout.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"checkout.activating": "جارٍ التفعيل…",
|
||||||
|
"checkout.activatingDesc": "نقوم بتحديث اشتراكك، يرجى الانتظار.",
|
||||||
|
"checkout.paymentConfirmed": "تم تأكيد الدفع!",
|
||||||
|
"checkout.subscriptionActivated": "تم تفعيل الاشتراك!",
|
||||||
|
"checkout.planActivated": "تم تفعيل خطة {plan}!",
|
||||||
|
"checkout.redirectingToProfile": "إعادة التوجيه إلى ملفك الشخصي…",
|
||||||
|
"checkout.paymentReceived": "تم استلام الدفع",
|
||||||
|
"checkout.redirecting": "جارٍ إعادة التوجيه…",
|
||||||
|
"checkout.syncError": "خطأ في المزامنة",
|
||||||
|
"checkout.networkError": "خطأ في الشبكة. تم تأكيد الدفع — يرجى إعادة تحميل ملفك الشخصي."
|
||||||
|
}
|
||||||
4
frontend/src/lib/i18n/messages/ar/common.json
Normal file
4
frontend/src/lib/i18n/messages/ar/common.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"common.loading": "جارٍ التحميل...",
|
||||||
|
"common.backToHome": "العودة للرئيسية"
|
||||||
|
}
|
||||||
25
frontend/src/lib/i18n/messages/ar/context.json
Normal file
25
frontend/src/lib/i18n/messages/ar/context.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"context.proTitle": "ميزة Pro",
|
||||||
|
"context.proDesc": "السياق والمصطلحات المهنية متاحة مع خطط Pro وBusiness وEnterprise. توفر ترجمات أكثر دقة من خلال تعليمات ومفردات خاصة بمجالك.",
|
||||||
|
"context.viewPlans": "عرض الخطط",
|
||||||
|
"context.title": "السياق والمصطلحات",
|
||||||
|
"context.subtitle": "حسّن جودة الترجمة من خلال تعليمات ومفردات خاصة بمجالك.",
|
||||||
|
"context.presets.title": "المصطلحات المهنية",
|
||||||
|
"context.presets.desc": "تحميل مصطلحات كاملة مع تعليمات ومصطلحات متخصصة",
|
||||||
|
"context.instructions.title": "تعليمات السياق",
|
||||||
|
"context.instructions.desc": "تعليمات سيتبعها الذكاء الاصطناعي أثناء الترجمة",
|
||||||
|
"context.instructions.placeholder": "مثال: أنت تترجم مستندات تقنية HVAC. استخدم مصطلحات الهندسة الدقيقة...",
|
||||||
|
"context.glossary.title": "المصطلحات التقنية",
|
||||||
|
"context.glossary.desc": "التنسيق: source=target (واحد في كل سطر). المصطلحات المحملة عبر الإعدادات المسبقة قابلة للتعديل.",
|
||||||
|
"context.glossary.terms": "مصطلحات في القاموس",
|
||||||
|
"context.clearAll": "مسح الكل",
|
||||||
|
"context.saving": "جارٍ الحفظ...",
|
||||||
|
"context.save": "حفظ",
|
||||||
|
"context.presets.createGlossary": "إنشاء مسرد",
|
||||||
|
"context.presets.created": "تم إنشاء المسرد",
|
||||||
|
"context.presets.createdDesc": "تم إنشاء المسرد \\\"{name}\\\" بـ {count} مصطلحات.",
|
||||||
|
"context.presets.hint": "انقر على إعداد مسبق لإنشاء مسرد بمصطلحات خاصة بالمجال. أد穹 مصادرك في قسم المسرات.",
|
||||||
|
"context.glossary.manage": "إدارة المسرات",
|
||||||
|
"context.saved": "تم الحفظ",
|
||||||
|
"context.savedDesc": "تم حفظ تعليمات السياق الخاصة بك."
|
||||||
|
}
|
||||||
7
frontend/src/lib/i18n/messages/ar/cookieConsent.json
Normal file
7
frontend/src/lib/i18n/messages/ar/cookieConsent.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"cookieConsent.title": "ملفات تعريف الارتباط على Wordly",
|
||||||
|
"cookieConsent.description": "نستخدم ملفات تعريف الارتباط الأساسية لعمل التطبيق (الجلسة، الأمان، اللغة). بإذنك، نستخدم أيضًا ملفات تعريف ارتباط اختيارية لقياس حركة المرور وتحسين المنتج.",
|
||||||
|
"cookieConsent.acceptAll": "قبول الكل",
|
||||||
|
"cookieConsent.essentialOnly": "الأساسية فقط",
|
||||||
|
"cookieConsent.learnMore": "معرفة المزيد"
|
||||||
|
}
|
||||||
111
frontend/src/lib/i18n/messages/ar/dashboard.json
Normal file
111
frontend/src/lib/i18n/messages/ar/dashboard.json
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"dashboard.nav.translate": "ترجمة",
|
||||||
|
"dashboard.nav.profile": "ملفي الشخصي",
|
||||||
|
"dashboard.nav.settings": "الإعدادات",
|
||||||
|
"dashboard.nav.context": "السياق",
|
||||||
|
"dashboard.nav.services": "الخدمات",
|
||||||
|
"dashboard.nav.apiKeys": "مفاتيح API",
|
||||||
|
"dashboard.nav.glossaries": "المعاجم",
|
||||||
|
"dashboard.header.title": "لوحة التحكم",
|
||||||
|
"dashboard.header.subtitle": "إدارة ترجماتك",
|
||||||
|
"dashboard.header.toggleMenu": "القائمة",
|
||||||
|
"dashboard.header.profileTitle": "ملفي الشخصي",
|
||||||
|
"dashboard.sidebar.theme": "المظهر",
|
||||||
|
"dashboard.sidebar.signOut": "تسجيل الخروج",
|
||||||
|
"dashboard.sidebar.backHome": "العودة إلى الصفحة الرئيسية",
|
||||||
|
"dashboard.sidebar.upgradeToPro": "الترقية إلى Pro ←",
|
||||||
|
"dashboard.translate.pageTitle": "ترجمة مستند",
|
||||||
|
"dashboard.translate.pageSubtitle": "استورد ملفًا واختر اللغة الهدف",
|
||||||
|
"dashboard.translate.errorNotificationTitle": "خطأ",
|
||||||
|
"dashboard.translate.dropzone.uploadAria": "منطقة إسقاط الملفات",
|
||||||
|
"dashboard.translate.dropzone.title": "اسحب ملفك وأفلته هنا",
|
||||||
|
"dashboard.translate.dropzone.subtitle": "أو انقر للاختيار (DOCX, XLSX, PPTX, PDF)",
|
||||||
|
"dashboard.translate.dropzone.replaceFile": "استبدال الملف",
|
||||||
|
"dashboard.translate.language.source": "لغة المصدر",
|
||||||
|
"dashboard.translate.language.target": "اللغة الهدف",
|
||||||
|
"dashboard.translate.language.loading": "جارٍ تحميل اللغات…",
|
||||||
|
"dashboard.translate.language.autoDetect": "كشف تلقائي",
|
||||||
|
"dashboard.translate.language.selectPlaceholder": "اختيار…",
|
||||||
|
"dashboard.translate.language.loadErrorPrefix": "فشل تحميل اللغات",
|
||||||
|
"dashboard.translate.provider.loading": "جارٍ تحميل مزوّدي الخدمة…",
|
||||||
|
"dashboard.translate.provider.noneConfigured": "لا يوجد مزوّدو خدمة مُعدّون",
|
||||||
|
"dashboard.translate.provider.modelTitle": "النموذج",
|
||||||
|
"dashboard.translate.provider.sectionTitle": "مزوّد الخدمة",
|
||||||
|
"dashboard.translate.provider.llmDivider": "ذكاء اصطناعي · مدرك للسياق",
|
||||||
|
"dashboard.translate.provider.llmDividerPro": "ذكاء اصطناعي · مدرك للسياق (Pro)",
|
||||||
|
"dashboard.translate.provider.upgrade": "الترقية إلى Pro",
|
||||||
|
"dashboard.translate.provider.upgradeSuffix": "لفتح ترجمة الذكاء الاصطناعي",
|
||||||
|
"dashboard.translate.trust.zeroRetention": "صفر احتفاظ بالبيانات",
|
||||||
|
"dashboard.translate.trust.deletedAfter": "تُحذف الملفات بعد المعالجة",
|
||||||
|
"dashboard.translate.actions.uploading": "جارٍ الرفع…",
|
||||||
|
"dashboard.translate.actions.translate": "ترجمة",
|
||||||
|
"dashboard.translate.actions.filePrefix": "الملف: ",
|
||||||
|
"dashboard.translate.actions.cancel": "إلغاء",
|
||||||
|
"dashboard.translate.actions.tryAgain": "إعادة المحاولة",
|
||||||
|
"dashboard.translate.steps.uploading": "جارٍ رفع الملف…",
|
||||||
|
"dashboard.translate.steps.starting": "جارٍ بدء الترجمة…",
|
||||||
|
"dashboard.translate.complete.title": "تمت الترجمة بنجاح!",
|
||||||
|
"dashboard.translate.complete.descNamed": "تمت ترجمة ملفك {name} بنجاح.",
|
||||||
|
"dashboard.translate.complete.descGeneric": "تمت ترجمة ملفك بنجاح.",
|
||||||
|
"dashboard.translate.complete.downloading": "جارٍ التنزيل…",
|
||||||
|
"dashboard.translate.complete.download": "تنزيل",
|
||||||
|
"dashboard.translate.complete.newTranslation": "ترجمة جديدة",
|
||||||
|
"dashboard.translate.complete.toastOkTitle": "تم بنجاح",
|
||||||
|
"dashboard.translate.complete.toastOkDesc": "تم تنزيل {name} بنجاح.",
|
||||||
|
"dashboard.translate.complete.toastFailTitle": "فشل",
|
||||||
|
"dashboard.translate.complete.toastFailDesc": "فشلت الترجمة. يرجى إعادة المحاولة.",
|
||||||
|
"dashboard.translate.sourceDocument": "المستند المصدر",
|
||||||
|
"dashboard.translate.configuration": "الإعدادات",
|
||||||
|
"dashboard.translate.translating": "جارٍ الترجمة",
|
||||||
|
"dashboard.translate.liveMonitor": "المراقبة المباشرة",
|
||||||
|
"dashboard.translate.summary": "الملخص",
|
||||||
|
"dashboard.translate.engine": "المحرك",
|
||||||
|
"dashboard.translate.confidence": "الثقة",
|
||||||
|
"dashboard.translate.cancel": "إلغاء",
|
||||||
|
"dashboard.translate.segments": "الأجزاء",
|
||||||
|
"dashboard.translate.characters": "الأحرف",
|
||||||
|
"dashboard.translate.elapsed": "المنقضي",
|
||||||
|
"dashboard.translate.segPerMin": "جزء/دقيقة",
|
||||||
|
"dashboard.translate.highQuality": "جودة عالية",
|
||||||
|
"dashboard.translate.quality": "الجودة",
|
||||||
|
"dashboard.translate.completed": "اكتملت الترجمة",
|
||||||
|
"dashboard.translate.replace": "استبدال",
|
||||||
|
"dashboard.translate.pdfMode.title": "وضع ترجمة PDF",
|
||||||
|
"dashboard.translate.pdfMode.preserveLayout": "الحفاظ على التخطيط",
|
||||||
|
"dashboard.translate.pdfMode.textOnly": "نص فقط",
|
||||||
|
"dashboard.translate.pdfMode.preserveLayoutDesc": "يحافظ على الصور والجداول والتنسيق. مثالي لملفات PDF البسيطة.",
|
||||||
|
"dashboard.translate.pdfMode.textOnlyDesc": "يترجم كل النص بشكل مثالي. مخرجات نظيفة دون مشاكل تخطيط.",
|
||||||
|
"dashboard.translate.pipeline.upload": "رفع",
|
||||||
|
"dashboard.translate.pipeline.analyze": "تحليل",
|
||||||
|
"dashboard.translate.pipeline.translate": "ترجمة",
|
||||||
|
"dashboard.translate.pipeline.rebuild": "إعادة بناء",
|
||||||
|
"dashboard.translate.pipeline.finalize": "إنهاء",
|
||||||
|
"dashboard.translate.progress.processingFallback": "جارٍ المعالجة…",
|
||||||
|
"dashboard.translate.progress.connectionLost": "فُقد الاتصال. إعادة المحاولة…",
|
||||||
|
"dashboard.translate.progress.failedTitle": "فشلت الترجمة",
|
||||||
|
"dashboard.translate.error.unexpected": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى.",
|
||||||
|
"dashboard.translate.error.noResult": "لم تُنتج الترجمة أي نتائج. تحقق من أن المستند يحتوي على نص، ثم أعد المحاولة أو اختر محركًا آخر.",
|
||||||
|
"dashboard.translate.error.apiKey": "مفتاح API غير صالح أو مفقود. اتصل بالمسؤول لإعداد مفاتيح API.",
|
||||||
|
"dashboard.translate.error.quota": "تم بلوغ حد الاستخدام. أعد المحاولة بعد بضع دقائق أو اختر محركًا آخر.",
|
||||||
|
"dashboard.translate.error.timeout": "انتهت مهلة الاتصال بخدمة الترجمة. تحقق من شبكتك وأعد المحاولة.",
|
||||||
|
"dashboard.translate.error.sessionExpired": "انتهت الجلسة. انقر على إعادة المحاولة لإعادة تشغيل الترجمة.",
|
||||||
|
"dashboard.translate.error.empty": "يبدو أن المستند فارغ أو لا يحتوي على نص قابل للترجمة (صورة PDF ممسوحة ضوئيًا؟).",
|
||||||
|
"dashboard.translate.error.unsupported": "تنسيق ملف غير مدعوم أو ملف تالف.",
|
||||||
|
"dashboard.translate.error.connection": "فُقد الاتصال. تحقق من شبكتك وأعد المحاولة.",
|
||||||
|
"dashboard.translate.error.generic": "فشلت الترجمة: {detail}",
|
||||||
|
"dashboard.translate.error.title": "فشلت الترجمة",
|
||||||
|
"dashboard.translate.retry": "إعادة محاولة الترجمة",
|
||||||
|
"dashboard.translate.newFile": "ملف جديد",
|
||||||
|
"dashboard.translate.modeAI": "وضع الذكاء الاصطناعي",
|
||||||
|
"dashboard.translate.modeClassic": "الوضع الكلاسيكي",
|
||||||
|
"dashboard.translate.glossaryLLMHint": "القواميس المصطلحية متاحة في وضع الذكاء الاصطناعي",
|
||||||
|
"dashboard.translate.submitting": "جارٍ الإرسال...",
|
||||||
|
"dashboard.translate.submit": "بدء الترجمة",
|
||||||
|
"dashboard.translate.noFile": "قم بتحميل ملف أولاً",
|
||||||
|
"dashboard.translate.noTargetLang": "اختر لغة الهدف",
|
||||||
|
"dashboard.topbar.interfaceLabel": "واجهة الترجمة",
|
||||||
|
"dashboard.topbar.premiumAccess": "وصول مميز",
|
||||||
|
"dashboard.checkoutSyncError": "خطأ في مزامنة الدفع.",
|
||||||
|
"dashboard.networkRefresh": "خطأ في الشبكة. يرجى تحديث الصفحة.",
|
||||||
|
"dashboard.continueToTranslate": "متابعة إلى الترجمة"
|
||||||
|
}
|
||||||
31
frontend/src/lib/i18n/messages/ar/fileUploader.json
Normal file
31
frontend/src/lib/i18n/messages/ar/fileUploader.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"fileUploader.uploadDocument": "تحميل المستند",
|
||||||
|
"fileUploader.uploadDesc": "اسحب وأفلت أو انقر لتحديد ملف (Excel, Word, PowerPoint)",
|
||||||
|
"fileUploader.dropHere": "أفلت ملفك هنا…",
|
||||||
|
"fileUploader.dragAndDrop": "اسحب وأفلت المستند هنا",
|
||||||
|
"fileUploader.orClickBrowse": "أو انقر للتصفح",
|
||||||
|
"fileUploader.preview": "معاينة",
|
||||||
|
"fileUploader.translationOptions": "خيارات الترجمة",
|
||||||
|
"fileUploader.configureSettings": "اضبط إعدادات الترجمة",
|
||||||
|
"fileUploader.targetLanguage": "اللغة المستهدفة",
|
||||||
|
"fileUploader.selectLanguage": "اختر اللغة",
|
||||||
|
"fileUploader.translationProvider": "مزود الترجمة",
|
||||||
|
"fileUploader.selectProvider": "اختر المزود",
|
||||||
|
"fileUploader.advancedOptions": "خيارات متقدمة",
|
||||||
|
"fileUploader.translateImages": "ترجمة الصور",
|
||||||
|
"fileUploader.translating": "جارٍ الترجمة…",
|
||||||
|
"fileUploader.translateDocument": "ترجمة المستند",
|
||||||
|
"fileUploader.processing": "جارٍ المعالجة…",
|
||||||
|
"fileUploader.translationError": "خطأ في الترجمة",
|
||||||
|
"fileUploader.translationComplete": "اكتملت الترجمة!",
|
||||||
|
"fileUploader.translationCompleteDesc": "تمت ترجمة مستندك بنجاح مع الحفاظ على جميع التنسيقات.",
|
||||||
|
"fileUploader.download": "تنزيل المستند المترجم",
|
||||||
|
"fileUploader.webgpuUnsupported": "WebGPU غير مدعوم في هذا المتصفح. يرجى استخدام Chrome 113+ أو Edge 113+.",
|
||||||
|
"fileUploader.webllmNotLoaded": "نموذج WebLLM غير محمّل. اذهب إلى الإعدادات > خدمات الترجمة لتحميل نموذج أولاً.",
|
||||||
|
"fileUploader.extracting": "استخراج النصوص من المستند…",
|
||||||
|
"fileUploader.noTranslatable": "لم يتم العثور على نص قابل للترجمة في المستند",
|
||||||
|
"fileUploader.foundTexts": "تم العثور على {count} نصوص للترجمة",
|
||||||
|
"fileUploader.translatingItem": "ترجمة {current}/{total}: \\\"{preview}\\\"",
|
||||||
|
"fileUploader.reconstructing": "إعادة بناء المستند…",
|
||||||
|
"fileUploader.translatingLocally": "ترجمة محلية باستخدام WebLLM…"
|
||||||
|
}
|
||||||
189
frontend/src/lib/i18n/messages/ar/glossaries.json
Normal file
189
frontend/src/lib/i18n/messages/ar/glossaries.json
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"glossaries.yourGlossaries": "معاجمك",
|
||||||
|
"glossaries.title": "المعاجم والسياق",
|
||||||
|
"glossaries.description": "قم بإدارة معاجمك وتعليمات السياق للحصول على ترجمات أكثر دقة.",
|
||||||
|
"glossaries.createNew": "إنشاء جديد",
|
||||||
|
"glossaries.empty": "لا توجد معاجم بعد",
|
||||||
|
"glossaries.emptyDesc": "أنشئ أول معجم لك أو حمّل إعدادًا مسبقًا احترافيًا أعلاه",
|
||||||
|
"glossaries.defineTerms": "مصطلحات",
|
||||||
|
"glossaries.aboutTitle": "حول المعاجم",
|
||||||
|
"glossaries.aboutDesc": "تتيح لك المعاجم تحديد ترجمات دقيقة لمصطلحات معينة. عند الترجمة، يتم استخدام مصطلحات المعجم لضمان ترجمات متسقة ودقيقة.",
|
||||||
|
"glossaries.aboutFormat": "كل مصطلح له كلمة مصدر وترجمات بلغات متعددة. حدد معجمًا في صفحة الترجمة لتطبيقه.",
|
||||||
|
"glossaries.toast.created": "تم إنشاء المعجم",
|
||||||
|
"glossaries.toast.createdDesc": "تم إنشاء المعجم \\\"{name}\\\".",
|
||||||
|
"glossaries.toast.imported": "تم استيراد المعجم",
|
||||||
|
"glossaries.toast.importedDesc": "تم استيراد المعجم \\\"{name}\\\".",
|
||||||
|
"glossaries.toast.updated": "تم تحديث المعجم",
|
||||||
|
"glossaries.toast.updatedDesc": "تم تحديث المعجم \\\"{name}\\\".",
|
||||||
|
"glossaries.toast.deleted": "تم حذف المعجم",
|
||||||
|
"glossaries.toast.deletedDesc": "تم حذف المعجم.",
|
||||||
|
"glossaries.toast.error": "خطأ",
|
||||||
|
"glossaries.toast.errorCreate": "فشل إنشاء المعجم",
|
||||||
|
"glossaries.toast.errorImport": "فشل استيراد المعجم",
|
||||||
|
"glossaries.toast.errorUpdate": "فشل تحديث المعجم",
|
||||||
|
"glossaries.toast.errorDelete": "فشل حذف المعجم",
|
||||||
|
"glossaries.dialog.title": "معجم جديد",
|
||||||
|
"glossaries.dialog.description": "أنشئ معجمًا لترجماتك",
|
||||||
|
"glossaries.dialog.nameLabel": "الاسم",
|
||||||
|
"glossaries.dialog.namePlaceholder": "معجمي",
|
||||||
|
"glossaries.dialog.tabTemplates": "القوالب",
|
||||||
|
"glossaries.dialog.tabFile": "ملف",
|
||||||
|
"glossaries.dialog.tabManual": "يدوي",
|
||||||
|
"glossaries.dialog.cancel": "إلغاء",
|
||||||
|
"glossaries.dialog.creating": "جارٍ الإنشاء…",
|
||||||
|
"glossaries.dialog.importing": "جارٍ الاستيراد…",
|
||||||
|
"glossaries.dialog.importBtn": "استيراد",
|
||||||
|
"glossaries.dialog.selectPrompt": "اختيار",
|
||||||
|
"glossaries.dialog.createBtn": "إنشاء",
|
||||||
|
"glossaries.dialog.createEmpty": "إنشاء فارغ",
|
||||||
|
"glossaries.dialog.terms": "مصطلحات",
|
||||||
|
"glossaries.dialog.templatesDesc": "اختر قالبًا مُعدًّا مسبقًا",
|
||||||
|
"glossaries.dialog.templatesEmpty": "لا توجد قوالب متاحة",
|
||||||
|
"glossaries.dialog.dropTitle": "اسحب ملف CSV هنا",
|
||||||
|
"glossaries.dialog.dropOr": "أو",
|
||||||
|
"glossaries.dialog.dropFormats": "CSV, TSV, TXT",
|
||||||
|
"glossaries.termEditor.addTerm": "إضافة مصطلح",
|
||||||
|
"glossaries.termEditor.maxReached": "تم بلوغ الحد الأقصى {max} من المصطلحات لكل قاموس.",
|
||||||
|
"glossaries.dialog.formatTitle": "التنسيق",
|
||||||
|
"glossaries.dialog.formatDesc": "المصدر،الهدف (واحدة لكل سطر)",
|
||||||
|
"glossaries.dialog.formatNote": "يُتجاهل السطر الأول عند اكتشاف ترويسة",
|
||||||
|
"glossaries.dialog.errorFormat": "تنسيق غير مدعوم",
|
||||||
|
"glossaries.dialog.errorSize": "الملف كبير جدًا",
|
||||||
|
"glossaries.dialog.errorEmpty": "ملف فارغ",
|
||||||
|
"glossaries.dialog.errorRead": "خطأ في القراءة",
|
||||||
|
"glossaries.dialog.parsing": "جارٍ التحليل…",
|
||||||
|
"glossaries.dialog.termsImported": "مصطلحات مستوردة",
|
||||||
|
"glossaries.dialog.changeFile": "تغيير الملف",
|
||||||
|
"glossaries.dialog.retry": "إعادة المحاولة",
|
||||||
|
"glossaries.edit.title": "Modifier le glossaire",
|
||||||
|
"glossaries.edit.description": "Modifiez le nom, la paire de langues et les termes du glossaire.",
|
||||||
|
"glossaries.edit.nameLabel": "Nom du glossaire",
|
||||||
|
"glossaries.edit.namePlaceholder": "Entrez le nom du glossaire...",
|
||||||
|
"glossaries.edit.sourceLang": "Langue source",
|
||||||
|
"glossaries.edit.targetLang": "Langue cible",
|
||||||
|
"glossaries.edit.termsLabel": "Termes ({count} valides)",
|
||||||
|
"glossaries.edit.exportCsv": "Exporter CSV",
|
||||||
|
"glossaries.edit.importCsv": "Importer CSV",
|
||||||
|
"glossaries.edit.cancel": "Annuler",
|
||||||
|
"glossaries.edit.saving": "Enregistrement...",
|
||||||
|
"glossaries.edit.saveChanges": "Enregistrer les modifications",
|
||||||
|
"glossaries.edit.importFailedTitle": "Échec de l'importation",
|
||||||
|
"glossaries.edit.importFailedMaxDesc": "Le CSV contient {count} termes, le maximum est de {max}. Veuillez réduire le nombre de termes.",
|
||||||
|
"glossaries.edit.importSuccessTitle": "Importation réussie",
|
||||||
|
"glossaries.edit.importSuccessDesc": "{count} termes importés avec succès.",
|
||||||
|
"glossaries.edit.importFailedEmptyDesc": "Aucun terme valide trouvé dans le fichier CSV.",
|
||||||
|
"glossaries.edit.importFailedReadDesc": "Impossible de lire le fichier CSV.",
|
||||||
|
"glossaries.delete.title": "Supprimer le glossaire",
|
||||||
|
"glossaries.delete.description": "Êtes-vous sûr de vouloir supprimer ce glossaire ?",
|
||||||
|
"glossaries.delete.warning": "Cette action est irréversible",
|
||||||
|
"glossaries.delete.warningDesc": "Toutes les paires de termes seront définitivement supprimées.",
|
||||||
|
"glossaries.delete.cancel": "Annuler",
|
||||||
|
"glossaries.delete.deleting": "Suppression...",
|
||||||
|
"glossaries.delete.deleteBtn": "Supprimer",
|
||||||
|
"glossaries.upgrade.title": "Glossaires",
|
||||||
|
"glossaries.upgrade.description": "Personnalisez vos traductions avec une terminologie personnalisée",
|
||||||
|
"glossaries.upgrade.feature1": "Créez plusieurs glossaires",
|
||||||
|
"glossaries.upgrade.feature2": "Définissez des paires de termes source→cible",
|
||||||
|
"glossaries.upgrade.feature3": "Importez/exportez via CSV",
|
||||||
|
"glossaries.upgrade.feature4": "Appliquez aux traductions LLM",
|
||||||
|
"glossaries.upgrade.proFeatureBefore": "Les glossaires sont une fonctionnalité ",
|
||||||
|
"glossaries.upgrade.proFeatureAfter": ". Passez à un forfait supérieur pour débloquer la terminologie personnalisée.",
|
||||||
|
"glossaries.upgrade.proLabel": "Pro",
|
||||||
|
"glossaries.upgrade.upgradeBtn": "Passer à Pro",
|
||||||
|
"glossaries.loading": "Chargement...",
|
||||||
|
"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.",
|
||||||
|
"glossaries.howItWorks.step2Title": "Activez dans Traduire",
|
||||||
|
"glossaries.howItWorks.step2Desc": "Sur la page de traduction, dans la colonne de droite, sélectionnez votre glossaire.",
|
||||||
|
"glossaries.howItWorks.warning": "Les instructions de contexte s'appliquent automatiquement à toutes vos traductions IA une fois enregistrées. Les glossaires doivent être sélectionnés manuellement sur la page Traduire.",
|
||||||
|
"glossaries.howItWorks.goToTranslate": "Aller à Traduire",
|
||||||
|
"glossaries.status.unsaved": "Non enregistré",
|
||||||
|
"glossaries.status.active": "Actif · s'applique à toutes les traductions IA",
|
||||||
|
"glossaries.status.inactive": "Inactif",
|
||||||
|
"glossaries.instructions.whatForBold": "À quoi ça sert ?",
|
||||||
|
"glossaries.instructions.whatForDesc": "Ces instructions sont envoyées automatiquement à l'IA avant chaque traduction, sans que vous ayez besoin de faire quoi que ce soit sur la page Traduire. Utilisez-les pour guider le style, le registre ou la terminologie générale.",
|
||||||
|
"glossaries.instructions.example": "Exemple : « Vous traduisez des rapports financiers. Soyez formel, précis et conservez tous les chiffres. »",
|
||||||
|
"glossaries.instructions.charCount": "{count} caractères",
|
||||||
|
"glossaries.instructions.emptyHint": "Vide — aucune instruction envoyée à l'IA",
|
||||||
|
"glossaries.instructions.clearAll": "Tout effacer",
|
||||||
|
"glossaries.instructions.saving": "Enregistrement…",
|
||||||
|
"glossaries.instructions.saved": "Enregistré",
|
||||||
|
"glossaries.presets.whatForBold": "À quoi ça sert ?",
|
||||||
|
"glossaries.presets.whatForDesc": "Cliquer sur une carte crée un glossaire pré-rempli avec les termes spécialisés du domaine. Ce glossaire apparaîtra dans vos glossaires ci-dessous, et vous pourrez le sélectionner manuellement sur la page Traduire pour forcer des traductions de termes précis.",
|
||||||
|
"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.it.title": "IT / Logiciel",
|
||||||
|
"glossaries.presets.it.desc": "Développement, infrastructure, DevOps",
|
||||||
|
"glossaries.presets.legal.title": "Juridique / Contrats",
|
||||||
|
"glossaries.presets.legal.desc": "Droit des affaires, contentieux",
|
||||||
|
"glossaries.presets.medical.title": "Médical / Santé",
|
||||||
|
"glossaries.presets.medical.desc": "Pharmacologie, chirurgie, diagnostic",
|
||||||
|
"glossaries.presets.finance.title": "Finance / Comptabilité",
|
||||||
|
"glossaries.presets.finance.desc": "IFRS, bilans, fiscalité",
|
||||||
|
"glossaries.presets.marketing.title": "Marketing / Publicité",
|
||||||
|
"glossaries.presets.marketing.desc": "Digital, branding, analytics",
|
||||||
|
"glossaries.presets.hr.title": "RH / Ressources Humaines",
|
||||||
|
"glossaries.presets.hr.desc": "Contrats, politiques, recrutement",
|
||||||
|
"glossaries.presets.scientific.title": "Scientifique / Recherche",
|
||||||
|
"glossaries.presets.scientific.desc": "Publications, thèses, articles",
|
||||||
|
"glossaries.presets.ecommerce.title": "E-commerce / Vente",
|
||||||
|
"glossaries.presets.ecommerce.desc": "Boutiques en ligne, catalogues, CRM",
|
||||||
|
"glossaries.grid.title": "Vos",
|
||||||
|
"glossaries.grid.titleHighlight": "glossaires",
|
||||||
|
"glossaries.grid.countWithAction": "{count} glossaire({plural}) — cliquez sur une carte pour la modifier",
|
||||||
|
"glossaries.grid.emptyAction": "Créez votre premier glossaire ou importez un preset ci-dessus",
|
||||||
|
"glossaries.grid.activeTranslation": "Traduction active :",
|
||||||
|
"glossaries.grid.goToTranslate": "Aller à Traduire pour activer",
|
||||||
|
"glossaries.badge.compatible": "Compatible",
|
||||||
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
|
"glossaries.card.created": "تم الإنشاء",
|
||||||
|
"glossaries.card.term": "مصطلح",
|
||||||
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.sourceLangNote": "'The original source is in French. For other languages, we read the term's translations field (if available).'",
|
||||||
|
"glossaries.detail.sourceLocked": "fixed",
|
||||||
|
"glossaries.detail.sourceLockedNote": "Templates only store the source in French. Multilingual source is on the roadmap.",
|
||||||
|
"glossaries.detail.targetLangNote": "Pick a language to see the matching translations, or « Multilingual » for the default value.",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file."
|
||||||
|
}
|
||||||
56
frontend/src/lib/i18n/messages/ar/index.ts
Normal file
56
frontend/src/lib/i18n/messages/ar/index.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Auto-generated by scripts/generate_i18n_index.py
|
||||||
|
// Merges all namespace JSON files for locale "ar".
|
||||||
|
|
||||||
|
import admin from "./admin.json";
|
||||||
|
import apiKeys from "./apiKeys.json";
|
||||||
|
import auth from "./auth.json";
|
||||||
|
import checkout from "./checkout.json";
|
||||||
|
import common from "./common.json";
|
||||||
|
import context from "./context.json";
|
||||||
|
import cookieConsent from "./cookieConsent.json";
|
||||||
|
import dashboard from "./dashboard.json";
|
||||||
|
import fileUploader from "./fileUploader.json";
|
||||||
|
import glossaries from "./glossaries.json";
|
||||||
|
import landing from "./landing.json";
|
||||||
|
import langSelector from "./langSelector.json";
|
||||||
|
import layout from "./layout.json";
|
||||||
|
import login from "./login.json";
|
||||||
|
import memento from "./memento.json";
|
||||||
|
import pricing from "./pricing.json";
|
||||||
|
import profile from "./profile.json";
|
||||||
|
import providerSelector from "./providerSelector.json";
|
||||||
|
import providerTheme from "./providerTheme.json";
|
||||||
|
import register from "./register.json";
|
||||||
|
import services from "./services.json";
|
||||||
|
import settings from "./settings.json";
|
||||||
|
import translate from "./translate.json";
|
||||||
|
import translateComplete from "./translateComplete.json";
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
...admin,
|
||||||
|
...apiKeys,
|
||||||
|
...auth,
|
||||||
|
...checkout,
|
||||||
|
...common,
|
||||||
|
...context,
|
||||||
|
...cookieConsent,
|
||||||
|
...dashboard,
|
||||||
|
...fileUploader,
|
||||||
|
...glossaries,
|
||||||
|
...landing,
|
||||||
|
...langSelector,
|
||||||
|
...layout,
|
||||||
|
...login,
|
||||||
|
...memento,
|
||||||
|
...pricing,
|
||||||
|
...profile,
|
||||||
|
...providerSelector,
|
||||||
|
...providerTheme,
|
||||||
|
...register,
|
||||||
|
...services,
|
||||||
|
...settings,
|
||||||
|
...translate,
|
||||||
|
...translateComplete,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default messages;
|
||||||
146
frontend/src/lib/i18n/messages/ar/landing.json
Normal file
146
frontend/src/lib/i18n/messages/ar/landing.json
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
{
|
||||||
|
"landing.nav.why": "لماذا نحن؟",
|
||||||
|
"landing.nav.formats": "التنسيقات",
|
||||||
|
"landing.nav.pricing": "الأسعار",
|
||||||
|
"landing.nav.login": "تسجيل الدخول",
|
||||||
|
"landing.nav.startFree": "ابدأ مجانًا",
|
||||||
|
"landing.hero.tag": "ذكاء اصطناعي احترافي للمستندات",
|
||||||
|
"landing.hero.titleLine1": "ترجم مستنداتك.",
|
||||||
|
"landing.hero.titleLine2": "مع تنسيق مثالي.",
|
||||||
|
"landing.hero.description": "المترجم الوحيد الذي يحافظ على SmartArt والرسوم البيانية وجداول المحتويات والأشكال والتخطيطات المعقدة — تمامًا كما كانت.",
|
||||||
|
"landing.hero.ctaMain": "ابدأ مجانًا — مستندان/شهر",
|
||||||
|
"landing.hero.ctaSec": "عرض العروض",
|
||||||
|
"landing.hero.deleted": "الملفات تُحذف بعد 60 دقيقة",
|
||||||
|
"landing.hero.noHidden": "بدون رسوم خفية",
|
||||||
|
"landing.hero.preview": "معاينة قبل الدفع",
|
||||||
|
"landing.hero.formattedOk": "التنسيق سليم",
|
||||||
|
"landing.hero.aiActive": "ترجمة الذكاء الاصطناعي نشطة",
|
||||||
|
"landing.steps.title": "كيف يعمل؟",
|
||||||
|
"landing.steps.subtitle": "ثلاث خطوات. صفر فقدان في التنسيق.",
|
||||||
|
"landing.steps.step1.num": "01",
|
||||||
|
"landing.steps.step1.title": "ارفع ملفك",
|
||||||
|
"landing.steps.step1.desc": "اسحب وأفلت مستند Excel أو Word أو PowerPoint أو PDF.",
|
||||||
|
"landing.steps.step2.num": "02",
|
||||||
|
"landing.steps.step2.title": "اختر اللغة والمحرك",
|
||||||
|
"landing.steps.step2.desc": "حدد اللغة المستهدفة والمحرك — كلاسيكي أو ذكاء اصطناعي سياقي.",
|
||||||
|
"landing.steps.step3.num": "03",
|
||||||
|
"landing.steps.step3.title": "حمّل النتيجة",
|
||||||
|
"landing.steps.step3.desc": "احصل على مستندك المترجم بتنسيق مطابق تمامًا للأصل.",
|
||||||
|
"landing.features.tag": "محرك ترجمة بالذكاء الاصطناعي",
|
||||||
|
"landing.features.title": "ترجمة تفهم مجالك",
|
||||||
|
"landing.features.description": "نماذج الذكاء الاصطناعي لدينا تحلل السياق وتحترم مصطلحاتك وحتى تترجم النصوص داخل الصور.",
|
||||||
|
"landing.features.context.title": "سياق الصناعة",
|
||||||
|
"landing.features.context.desc": "صِف مجالك واحصل على ترجمات مخصصة، وليس عامة.",
|
||||||
|
"landing.features.glossary.title": "قواميس المصطلحات",
|
||||||
|
"landing.features.glossary.desc": "حدد مصطلحاتك التقنية. CTA تبقى «وحدة معالجة الهواء»، وليس «الدعوة للإجراء».",
|
||||||
|
"landing.features.vision.title": "التعرف على الصور",
|
||||||
|
"landing.features.vision.desc": "النص المضمن في الصور والمخططات والرسوم البياني يتم اكتشافه وترجمته.",
|
||||||
|
"landing.features.demo.source": "المصدر (FR)",
|
||||||
|
"landing.features.demo.google": "Google Translate",
|
||||||
|
"landing.features.demo.ours": "ذكاؤنا الاصطناعي",
|
||||||
|
"landing.layout.title": "تنسيقك،",
|
||||||
|
"landing.layout.title2": "محفوظ بشكل مثالي",
|
||||||
|
"landing.layout.subtitle": "المترجمون الآخرون يدمرون تخطيطك. نحن لا.",
|
||||||
|
"landing.layout.p1.title": "SmartArt والمخططات",
|
||||||
|
"landing.layout.p1.desc": "الهياكل التنظيمية، المخططات الانسيابية، التسلسلات الهرمية — كلها مترجمة بشكل مطابق.",
|
||||||
|
"landing.layout.p2.title": "جداول المحتويات",
|
||||||
|
"landing.layout.p2.desc": "إدخالات جدول المحتويات وأرقام الصفحات والمراجع المتبادلة محدثة بشكل صحيح.",
|
||||||
|
"landing.layout.p3.title": "الرسوم والمخططات البيانية",
|
||||||
|
"landing.layout.p3.desc": "العناوين، تسميات المحاور، التسميات التوضيحية وأسماء السلاسل — كل شيء مترجم.",
|
||||||
|
"landing.layout.p4.title": "الأشكال ومربعات النص",
|
||||||
|
"landing.layout.p4.desc": "مستطيلات، كتل مستديرة، تعليقات توضيحية — محلية في كل مكان.",
|
||||||
|
"landing.layout.p5.title": "الرؤوس والتذييلات",
|
||||||
|
"landing.layout.p5.desc": "الرؤوس والتذييلات والحواشي لا تُفوت أبدًا.",
|
||||||
|
"landing.layout.p6.title": "130+ لغة",
|
||||||
|
"landing.layout.p6.desc": "Google Translate و DeepL ومحركات ذكاء اصطناعي احترافية.",
|
||||||
|
"landing.formats.title": "كل تنسيق،",
|
||||||
|
"landing.formats.title2": "كل عنصر",
|
||||||
|
"landing.formats.subtitle": "نترجم ما يفوته الآخرون. عملك يستحق توثيقًا لا تشوبه شائبة.",
|
||||||
|
"landing.formats.word.name": "Word",
|
||||||
|
"landing.formats.word.i1": "الفقرات والعناوين",
|
||||||
|
"landing.formats.word.i2": "الجداول والرسوم البيانية",
|
||||||
|
"landing.formats.word.i3": "مخططات SmartArt",
|
||||||
|
"landing.formats.word.i4": "جدول المحتويات",
|
||||||
|
"landing.formats.word.i5": "الرؤوس والتذييلات",
|
||||||
|
"landing.formats.word.i6": "الأشكال ومربعات النص",
|
||||||
|
"landing.formats.word.i7": "الحواشي والتعليقات الختامية",
|
||||||
|
"landing.formats.excel.name": "Excel",
|
||||||
|
"landing.formats.excel.i1": "قيم الخلايا",
|
||||||
|
"landing.formats.excel.i2": "أسماء الأوراق",
|
||||||
|
"landing.formats.excel.i3": "الرسوم والتسميات",
|
||||||
|
"landing.formats.excel.i4": "الرؤوس والتذييلات",
|
||||||
|
"landing.formats.excel.i5": "الخلايا المدمجة محفوظة",
|
||||||
|
"landing.formats.pptx.name": "PowerPoint",
|
||||||
|
"landing.formats.pptx.i1": "نص الشرائح والملاحظات",
|
||||||
|
"landing.formats.pptx.i2": "الرسوم والمخططات",
|
||||||
|
"landing.formats.pptx.i3": "الأشكال ومربعات النص",
|
||||||
|
"landing.formats.pptx.i4": "التخطيطات الرئيسية",
|
||||||
|
"landing.formats.pptx.i5": "الرسوم المتحركة محفوظة",
|
||||||
|
"landing.formats.pdf.name": "PDF",
|
||||||
|
"landing.formats.pdf.i1": "ملفات PDF النصية",
|
||||||
|
"landing.formats.pdf.i2": "التخطيط محفوظ",
|
||||||
|
"landing.formats.pdf.i3": "الصور في مكانها",
|
||||||
|
"landing.formats.pdf.i4": "الجداول محفوظة",
|
||||||
|
"landing.formats.pdf.i5": "الإخراج كـ DOCX أو PDF",
|
||||||
|
"landing.pricing.title": "أسعار بسيطة وشفافة",
|
||||||
|
"landing.pricing.subtitle": "ما تراه هو ما تدفعه. بدون رسوم خفية.",
|
||||||
|
"landing.pricing.monthly": "شهريًا",
|
||||||
|
"landing.pricing.annual": "سنويًا",
|
||||||
|
"landing.pricing.bestValue": "الأكثر شعبية",
|
||||||
|
"landing.pricing.month": "/شهر",
|
||||||
|
"landing.pricing.footer": "السعر المعروض هو السعر الذي تدفعه. بدون رسوم خفية بعد الترجمة.",
|
||||||
|
"landing.pricing.starter.name": "Starter",
|
||||||
|
"landing.pricing.starter.desc": "للأفراد والمشاريع الصغيرة",
|
||||||
|
"landing.pricing.starter.f1": "50 مستند / شهر",
|
||||||
|
"landing.pricing.starter.f2": "حتى 50 صفحة لكل مستند",
|
||||||
|
"landing.pricing.starter.f3": "Google Translate + DeepL",
|
||||||
|
"landing.pricing.starter.f4": "ملفات حتى 10 ميجابايت",
|
||||||
|
"landing.pricing.starter.cta": "ابدأ الآن",
|
||||||
|
"landing.pricing.pro.name": "Pro",
|
||||||
|
"landing.pricing.pro.desc": "للمحترفين المتميزين",
|
||||||
|
"landing.pricing.pro.f1": "200 مستند / شهر",
|
||||||
|
"landing.pricing.pro.f2": "حتى 200 صفحة لكل مستند",
|
||||||
|
"landing.pricing.pro.f3": "ترجمة بالذكاء الاصطناعي",
|
||||||
|
"landing.pricing.pro.f4": "Google + DeepL مشمولان",
|
||||||
|
"landing.pricing.pro.f5": "قواميس وأوامر مخصصة",
|
||||||
|
"landing.pricing.pro.f6": "دعم ذو أولوية",
|
||||||
|
"landing.pricing.pro.cta": "جرّب Pro",
|
||||||
|
"landing.pricing.business.name": "الأعمال",
|
||||||
|
"landing.pricing.business.desc": "للفرق ذات الاحتياجات الكبيرة",
|
||||||
|
"landing.pricing.business.f1": "1,000 مستند / شهر",
|
||||||
|
"landing.pricing.business.f2": "حتى 500 صفحة لكل مستند",
|
||||||
|
"landing.pricing.business.f3": "ذكاء اصطناعي متميز (Claude)",
|
||||||
|
"landing.pricing.business.f4": "جميع المزودين + واجهة برمجة التطبيقات",
|
||||||
|
"landing.pricing.business.f5": "خطاطف الويب والأتمتة",
|
||||||
|
"landing.pricing.business.f6": "5 مقاعد للفريق",
|
||||||
|
"landing.pricing.business.cta": "تواصل معنا",
|
||||||
|
"landing.cta.title": "ابدأ الترجمة في 30 ثانية",
|
||||||
|
"landing.cta.subtitle": "لا حاجة لبطاقة ائتمان. جرّب مجانًا الآن وأعد الحياة إلى مستنداتك متعددة اللغات.",
|
||||||
|
"landing.cta.button": "إنشاء حساب مجاني",
|
||||||
|
"landing.cta.secure": "محمي بتشفير AES-256",
|
||||||
|
"landing.footer.desc": "خبراء في الترجمة الذكية للمستندات. نمزج بين فن التنسيق وعلم الذكاء الاصطناعي السياقي.",
|
||||||
|
"landing.footer.product": "المنتج",
|
||||||
|
"landing.footer.resources": "الموارد",
|
||||||
|
"landing.footer.legal": "قانوني",
|
||||||
|
"landing.footer.rights": "© 2026 Wordly.art — جميع الحقوق محفوظة.",
|
||||||
|
"landing.hero.contextEngine": "ترجمة مكتشفة: مصطلح صيانة تقني لأنظمة التدفئة والتهوية...",
|
||||||
|
"landing.hero.liveAnalysis": "تحليل مباشر",
|
||||||
|
"landing.hero.termsDetected": "مصطلحات مكتشفة",
|
||||||
|
"landing.steps.process": "العملية",
|
||||||
|
"landing.translate.newProject": "مشروع جديد",
|
||||||
|
"landing.translate.title": "ترجمة مستند",
|
||||||
|
"landing.translate.subtitle": "استورد ملفًا واختر اللغة المستهدفة",
|
||||||
|
"landing.translate.sourceDocument": "المستند المصدر",
|
||||||
|
"landing.translate.configuration": "الإعدادات",
|
||||||
|
"landing.translate.sourceLang": "لغة المصدر",
|
||||||
|
"landing.translate.targetLang": "اللغة المستهدفة",
|
||||||
|
"landing.translate.provider": "المزود",
|
||||||
|
"landing.translate.startTranslation": "بدء الترجمة",
|
||||||
|
"landing.translate.zeroRetention": "صفر احتفاظ",
|
||||||
|
"landing.translate.filesDeleted": "الملفات محذوفة بعد المعالجة",
|
||||||
|
"landing.translate.dropHere": "اسحب وأفلت هنا",
|
||||||
|
"landing.translate.supportedFormats": "ملفات DOCX, XLSX, PPTX أو PDF مدعومة",
|
||||||
|
"landing.translate.aiAnalysis": "تحليل AI نشط",
|
||||||
|
"landing.translate.processing": "جاري المعالجة",
|
||||||
|
"landing.translate.preservingLayout": "جاري الحفاظ على التنسيق"
|
||||||
|
}
|
||||||
7
frontend/src/lib/i18n/messages/ar/langSelector.json
Normal file
7
frontend/src/lib/i18n/messages/ar/langSelector.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"langSelector.search": "بحث…",
|
||||||
|
"langSelector.noResults": "لا توجد نتائج",
|
||||||
|
"langSelector.source": "المصدر",
|
||||||
|
"langSelector.target": "الهدف",
|
||||||
|
"langSelector.swap": "تبديل"
|
||||||
|
}
|
||||||
5
frontend/src/lib/i18n/messages/ar/layout.json
Normal file
5
frontend/src/lib/i18n/messages/ar/layout.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"layout.nav.apiAccess": "وصول API",
|
||||||
|
"layout.footer.terms": "الشروط",
|
||||||
|
"layout.footer.privacy": "الخصوصية"
|
||||||
|
}
|
||||||
6
frontend/src/lib/i18n/messages/ar/login.json
Normal file
6
frontend/src/lib/i18n/messages/ar/login.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"login.orContinueWith": "أو تابع بالبريد الإلكتروني",
|
||||||
|
"login.google.connecting": "جارٍ الاتصال…",
|
||||||
|
"login.google.errorGeneric": "حدث خطأ في تسجيل الدخول عبر Google.",
|
||||||
|
"login.google.errorFailed": "فشل تسجيل الدخول عبر Google. حاول مرة أخرى."
|
||||||
|
}
|
||||||
6
frontend/src/lib/i18n/messages/ar/memento.json
Normal file
6
frontend/src/lib/i18n/messages/ar/memento.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"memento.title": "اكتشف Momento",
|
||||||
|
"memento.slogan": "Momento ليس مجرد تطبيق ملاحظات. إنه نظام بيئي ذكي يربط ويحلل ويطور أفكارك في الوقت الفعلي باستخدام 6 وكلاء ذكاء اصطناعي وبحث دلالي متقدم.",
|
||||||
|
"memento.ctaFree": "ابدأ مجاناً",
|
||||||
|
"memento.ctaMore": "اعرف المزيد"
|
||||||
|
}
|
||||||
151
frontend/src/lib/i18n/messages/ar/pricing.json
Normal file
151
frontend/src/lib/i18n/messages/ar/pricing.json
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"pricing.nav.back": "رجوع",
|
||||||
|
"pricing.nav.home": "الرئيسية",
|
||||||
|
"pricing.nav.mySubscription": "اشتراكي",
|
||||||
|
"pricing.header.badge": "نماذج الذكاء الاصطناعي المحدّثة — مارس 2026",
|
||||||
|
"pricing.header.title": "خطة لكل احتياج",
|
||||||
|
"pricing.header.subtitle": "ترجم مستندات Word و Excel و PowerPoint مع الحفاظ على التنسيق الأصلي. بدون الحاجة إلى مفتاح API.",
|
||||||
|
"pricing.billing.monthly": "شهري",
|
||||||
|
"pricing.billing.yearly": "سنوي",
|
||||||
|
"pricing.plans.free.name": "مجاني",
|
||||||
|
"pricing.plans.starter.name": "Starter",
|
||||||
|
"pricing.plans.pro.name": "Pro",
|
||||||
|
"pricing.plans.business.name": "Business",
|
||||||
|
"pricing.plans.enterprise.name": "Enterprise",
|
||||||
|
"pricing.plans.free.description": "مثالي لاكتشاف التطبيق",
|
||||||
|
"pricing.plans.starter.description": "للأفراد والمشاريع الصغيرة",
|
||||||
|
"pricing.plans.pro.description": "للمحترفين والفرق النامية",
|
||||||
|
"pricing.plans.business.description": "للفرق والمؤسسات",
|
||||||
|
"pricing.plans.enterprise.description": "حلول مخصصة للمؤسسات الكبيرة",
|
||||||
|
"pricing.plans.pro.highlight": "الأكثر شعبية",
|
||||||
|
"pricing.plans.pro.badge": "رائج",
|
||||||
|
"pricing.plans.enterprise.badge": "حسب الطلب",
|
||||||
|
"pricing.plans.free.feat1": "5 مستندات / شهر",
|
||||||
|
"pricing.plans.free.feat2": "حتى 15 صفحة لكل مستند",
|
||||||
|
"pricing.plans.free.feat3": "ترجمة Google مشمولة",
|
||||||
|
"pricing.plans.free.feat4": "جميع اللغات (130+)",
|
||||||
|
"pricing.plans.free.feat5": "دعم المجتمع",
|
||||||
|
"pricing.plans.starter.feat1": "50 مستندًا / شهر",
|
||||||
|
"pricing.plans.starter.feat2": "حتى 50 صفحة لكل مستند",
|
||||||
|
"pricing.plans.starter.feat3": "ترجمة Google + DeepL",
|
||||||
|
"pricing.plans.starter.feat4": "ملفات حتى 10 ميغابايت",
|
||||||
|
"pricing.plans.starter.feat5": "دعم عبر البريد الإلكتروني",
|
||||||
|
"pricing.plans.starter.feat6": "سجل 30 يومًا",
|
||||||
|
"pricing.plans.pro.feat1": "200 مستند / شهر",
|
||||||
|
"pricing.plans.pro.feat2": "حتى 200 صفحة لكل مستند",
|
||||||
|
"pricing.plans.pro.feat3": "ترجمة الذكاء الاصطناعي الأساسية",
|
||||||
|
"pricing.plans.pro.feat4": "ترجمة Google + DeepL",
|
||||||
|
"pricing.plans.pro.feat5": "ملفات حتى 25 ميغابايت",
|
||||||
|
"pricing.plans.pro.feat6": "معاجم مخصصة",
|
||||||
|
"pricing.plans.pro.feat7": "دعم ذو أولوية",
|
||||||
|
"pricing.plans.pro.feat8": "سجل 90 يومًا",
|
||||||
|
"pricing.plans.business.feat1": "1 000 مستند / شهر",
|
||||||
|
"pricing.plans.business.feat2": "حتى 500 صفحة لكل مستند",
|
||||||
|
"pricing.plans.business.feat3": "AI أساسية + متميزة (Claude Haiku)",
|
||||||
|
"pricing.plans.business.feat4": "جميع مزوّدي الترجمة",
|
||||||
|
"pricing.plans.business.feat5": "ملفات حتى 50 ميغابايت",
|
||||||
|
"pricing.plans.business.feat6": "وصول API (10 000 استدعاء/شهر)",
|
||||||
|
"pricing.plans.business.feat7": "ويب هوك للإشعارات",
|
||||||
|
"pricing.plans.business.feat8": "دعم مخصص",
|
||||||
|
"pricing.plans.business.feat9": "سجل سنة واحدة",
|
||||||
|
"pricing.plans.business.feat10": "تحليلات متقدمة",
|
||||||
|
"pricing.plans.enterprise.feat1": "مستندات بلا حدود",
|
||||||
|
"pricing.plans.enterprise.feat2": "جميع نماذج AI (GPT-5, Claude Opus 4.6…)",
|
||||||
|
"pricing.plans.enterprise.feat3": "نشر محلي أو سحابة مخصصة",
|
||||||
|
"pricing.plans.enterprise.feat4": "SLA 99.9% مضمون",
|
||||||
|
"pricing.plans.enterprise.feat5": "دعم مخصص على مدار الساعة",
|
||||||
|
"pricing.plans.enterprise.feat6": "العلامة البيضاء (White-label)",
|
||||||
|
"pricing.plans.enterprise.feat7": "فرق بلا حدود",
|
||||||
|
"pricing.plans.enterprise.feat8": "تكاملات مخصصة",
|
||||||
|
"pricing.card.onRequest": "حسب الطلب",
|
||||||
|
"pricing.card.free": "مجاني",
|
||||||
|
"pricing.card.perMonth": "/شهر",
|
||||||
|
"pricing.card.billedYearly": "يُحاسب {price} € / سنة",
|
||||||
|
"pricing.card.documents": "المستندات",
|
||||||
|
"pricing.card.pagesMax": "الحد الأقصى للصفحات",
|
||||||
|
"pricing.card.aiTranslation": "ترجمة AI",
|
||||||
|
"pricing.card.unlimited": "بلا حدود",
|
||||||
|
"pricing.card.perMonthStat": "/ شهر",
|
||||||
|
"pricing.card.perDoc": "ص / مستند",
|
||||||
|
"pricing.card.aiEssential": "أساسي",
|
||||||
|
"pricing.card.aiEssentialPremium": "أساسي + متميز",
|
||||||
|
"pricing.card.aiCustom": "مخصص",
|
||||||
|
"pricing.card.myPlan": "خطتي",
|
||||||
|
"pricing.card.managePlan": "إدارة خطتي",
|
||||||
|
"pricing.card.startFree": "ابدأ مجانًا",
|
||||||
|
"pricing.card.contactUs": "تواصل معنا",
|
||||||
|
"pricing.card.choosePlan": "اختر هذه الخطة",
|
||||||
|
"pricing.card.processing": "جارٍ المعالجة…",
|
||||||
|
"pricing.comparison.title": "مقارنة تفصيلية",
|
||||||
|
"pricing.comparison.subtitle": "كل ما هو مشمول في كل خطة",
|
||||||
|
"pricing.comparison.feature": "الميزة",
|
||||||
|
"pricing.comparison.docsPerMonth": "المستندات / شهر",
|
||||||
|
"pricing.comparison.pagesMaxPerDoc": "الحد الأقصى للصفحات / مستند",
|
||||||
|
"pricing.comparison.maxFileSize": "الحد الأقصى لحجم الملف",
|
||||||
|
"pricing.comparison.googleTranslation": "ترجمة Google",
|
||||||
|
"pricing.comparison.deepl": "DeepL",
|
||||||
|
"pricing.comparison.aiEssential": "ترجمة AI أساسية",
|
||||||
|
"pricing.comparison.aiPremium": "ترجمة AI متميزة",
|
||||||
|
"pricing.comparison.apiAccess": "وصول API",
|
||||||
|
"pricing.comparison.priorityProcessing": "معالجة ذات أولوية",
|
||||||
|
"pricing.comparison.support": "الدعم",
|
||||||
|
"pricing.comparison.support.community": "المجتمع",
|
||||||
|
"pricing.comparison.support.email": "البريد الإلكتروني",
|
||||||
|
"pricing.comparison.support.priority": "ذو أولوية",
|
||||||
|
"pricing.comparison.support.dedicated": "مخصص",
|
||||||
|
"pricing.comparison.mb": "ميغابايت",
|
||||||
|
"pricing.credits.title": "أرصدة إضافية",
|
||||||
|
"pricing.credits.subtitle": "تحتاج المزيد؟ اشترِ أرصدة فردية بدون اشتراك.",
|
||||||
|
"pricing.credits.perPage": "رصيد واحد = صفحة واحدة مترجمة.",
|
||||||
|
"pricing.credits.bestValue": "أفضل قيمة",
|
||||||
|
"pricing.credits.unit": "أرصدة",
|
||||||
|
"pricing.credits.centsPerCredit": "سنت / رصيد",
|
||||||
|
"pricing.credits.buy": "شراء",
|
||||||
|
"pricing.trust.encryption.title": "تشفير شامل",
|
||||||
|
"pricing.trust.encryption.sub": "TLS 1.3 + AES-256 في حالة السكون",
|
||||||
|
"pricing.trust.languages.title": "+130 لغة",
|
||||||
|
"pricing.trust.languages.sub": "بما فيها العربية والفارسية والعبرية (RTL)",
|
||||||
|
"pricing.trust.parallel.title": "معالجة متوازية",
|
||||||
|
"pricing.trust.parallel.sub": "ذكاء اصطناعي متعدد المسارات فائق السرعة",
|
||||||
|
"pricing.trust.availability.title": "متاح على مدار الساعة",
|
||||||
|
"pricing.trust.availability.sub": "ضمان وقت تشغيل 99.9%",
|
||||||
|
"pricing.aiModels.title": "نماذج الذكاء الاصطناعي — مارس 2026",
|
||||||
|
"pricing.aiModels.essential.title": "ترجمة AI أساسية",
|
||||||
|
"pricing.aiModels.essential.plan": "خطة Pro",
|
||||||
|
"pricing.aiModels.essential.descPrefix": "مبني على",
|
||||||
|
"pricing.aiModels.essential.descSuffix": "— أنموذج الذكاء الاصطناعي الأكثر فعالية من حيث التكلفة لعام 2026. جودة مماثلة للنماذج المتقدمة بتكلفة أقل بكثير.",
|
||||||
|
"pricing.aiModels.essential.modelName": "نموذج الذكاء الاصطناعي الأساسي",
|
||||||
|
"pricing.aiModels.essential.context": "163K رمز سياقي",
|
||||||
|
"pricing.aiModels.essential.value": "قيمة ممتازة مقابل المال",
|
||||||
|
"pricing.aiModels.premium.title": "ترجمة AI متميزة",
|
||||||
|
"pricing.aiModels.premium.plan": "خطة Business",
|
||||||
|
"pricing.aiModels.premium.descPrefix": "مبني على",
|
||||||
|
"pricing.aiModels.premium.descSuffix": "من Anthropic — دقة عالية في المستندات القانونية والطبية والتقنية المعقدة.",
|
||||||
|
"pricing.aiModels.premium.context": "200K رمز سياقي",
|
||||||
|
"pricing.aiModels.premium.precision": "أعلى دقة",
|
||||||
|
"pricing.faq.title": "الأسئلة الشائعة",
|
||||||
|
"pricing.faq.q1": "هل يمكنني تغيير خطتي في أي وقت؟",
|
||||||
|
"pricing.faq.a1": "نعم. الترقية فورية ومُحاسب عليها بالتناسب. التخفيض يسري في نهاية الفترة الحالية.",
|
||||||
|
"pricing.faq.q2": "ما هي «ترجمة AI الأساسية»؟",
|
||||||
|
"pricing.faq.a2": "إنه محرك الذكاء الاصطناعي الخاص بنا. يفهم سياق مستنداتك، ويحافظ على التخطيط، ويتعامل مع المصطلحات التقنية بشكل أفضل بكثير من الترجمة الكلاسيكية.",
|
||||||
|
"pricing.faq.q3": "ما الفرق بين AI الأساسية وAI المتميزة؟",
|
||||||
|
"pricing.faq.a3": "الذكاء الاصطناعي الأساسي يستخدم نموذجًا محسّنًا (قيمة ممتازة مقابل المال). الذكاء الاصطناعي المتميز يستخدم Claude 3.5 Haiku من Anthropic، أكثر دقة في المستندات القانونية والطبية والتقنية المعقدة.",
|
||||||
|
"pricing.faq.q4": "هل تُحفظ مستنداتي بعد الترجمة؟",
|
||||||
|
"pricing.faq.a4": "الملفات المترجمة متاحة حسب خطتك (30 يومًا لـ Starter، 90 يومًا لـ Pro، سنة واحدة لـ Business). وهي مشفرة أثناء التخزين والنقل.",
|
||||||
|
"pricing.faq.q5": "ماذا يحدث إذا تجاوزت حصتي الشهرية؟",
|
||||||
|
"pricing.faq.a5": "يمكنك شراء أرصدة إضافية بشكل فردي أو ترقية خطتك. يتم إشعارك عند بلوغ 80% من الاستخدام.",
|
||||||
|
"pricing.faq.q6": "هل توجد فترة تجريبية مجانية للخطط المدفوعة؟",
|
||||||
|
"pricing.faq.a6": "الخطة المجانية دائمة ولا تتطلب بطاقة ائتمان. للخطط Pro و Business تواصل معنا للحصول على تجربة 14 يومًا.",
|
||||||
|
"pricing.faq.q7": "ما تنسيقات الملفات المدعومة؟",
|
||||||
|
"pricing.faq.a7": "Word (.docx), Excel (.xlsx/.xls), PowerPoint (.pptx), and soon PDF. All plans support the same formats.",
|
||||||
|
"pricing.cta.title": "مستعد للبدء؟",
|
||||||
|
"pricing.cta.subtitle": "ابدأ مجانًا بدون بطاقة ائتمان. رقِّق عندما تحتاج.",
|
||||||
|
"pricing.cta.createAccount": "إنشاء حساب مجاني",
|
||||||
|
"pricing.cta.login": "تسجيل الدخول",
|
||||||
|
"pricing.toast.demo": "الوضع التجريبي — Stripe لم يُعدّ بعد. في بيئة الإنتاج سيتم توجيهك إلى الدفع لتفعيل خطة {planId}.",
|
||||||
|
"pricing.toast.networkError": "خطأ في الشبكة. يرجى إعادة المحاولة.",
|
||||||
|
"pricing.toast.paymentError": "خطأ أثناء إنشاء الدفع.",
|
||||||
|
"pricing.dashboard": "لوحة التحكم",
|
||||||
|
"pricing.okSymbol": "✓",
|
||||||
|
"pricing.errSymbol": "✕"
|
||||||
|
}
|
||||||
58
frontend/src/lib/i18n/messages/ar/profile.json
Normal file
58
frontend/src/lib/i18n/messages/ar/profile.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"profile.header.title": "ملفي الشخصي",
|
||||||
|
"profile.header.subtitle": "إدارة حسابك وتفضيلاتك.",
|
||||||
|
"profile.tabs.account": "الحساب",
|
||||||
|
"profile.tabs.subscription": "الاشتراك",
|
||||||
|
"profile.tabs.preferences": "التفضيلات",
|
||||||
|
"profile.account.user": "المستخدم",
|
||||||
|
"profile.account.memberSince": "عضو منذ",
|
||||||
|
"profile.plan.label": "الخطة",
|
||||||
|
"profile.plan.free": "Free",
|
||||||
|
"profile.plan.starter": "Starter",
|
||||||
|
"profile.plan.pro": "Pro",
|
||||||
|
"profile.plan.business": "Business",
|
||||||
|
"profile.plan.enterprise": "Enterprise",
|
||||||
|
"profile.plan.pricePerMonth": "{price} €/شهر",
|
||||||
|
"profile.subscription.canceling": "جارٍ الإلغاء",
|
||||||
|
"profile.subscription.active": "نشط",
|
||||||
|
"profile.subscription.unknown": "غير معروف",
|
||||||
|
"profile.subscription.accessUntil": "الوصول حتى",
|
||||||
|
"profile.subscription.renewalOn": "التجديد في",
|
||||||
|
"profile.subscription.upgradePlan": "الترقية إلى خطة مدفوعة",
|
||||||
|
"profile.subscription.changePlan": "تغيير الخطة",
|
||||||
|
"profile.subscription.manageBilling": "إدارة الفوترة",
|
||||||
|
"profile.subscription.billingUnavailable": "بوابة الفوترة غير متاحة.",
|
||||||
|
"profile.subscription.billingError": "خطأ في الوصول إلى بوابة الفوترة.",
|
||||||
|
"profile.subscription.cancelSuccess": "تم إلغاء الاشتراك. تحتفظ بالوصول حتى نهاية الفترة.",
|
||||||
|
"profile.subscription.cancelError": "حدث خطأ أثناء الإلغاء.",
|
||||||
|
"profile.subscription.networkError": "خطأ في الشبكة.",
|
||||||
|
"profile.usage.title": "الاستخدام هذا الشهر",
|
||||||
|
"profile.usage.resetOn": "إعادة التعيين في",
|
||||||
|
"profile.usage.documents": "المستندات",
|
||||||
|
"profile.usage.pages": "الصفحات",
|
||||||
|
"profile.usage.extraCredits": "رصيد إضافي",
|
||||||
|
"profile.usage.extraCreditsPlural": "أرصدة إضافية",
|
||||||
|
"profile.usage.quotaReached": "تم بلوغ الحصة",
|
||||||
|
"profile.usage.quotaReachedDesc": "قم بالترقية إلى خطة أعلى للمتابعة.",
|
||||||
|
"profile.usage.unlockMore": "احصل على المزيد من الترجمات مع خطة مدفوعة.",
|
||||||
|
"profile.usage.viewPlans": "عرض الخطط",
|
||||||
|
"profile.usage.includedInPlan": "مضمن في خطتك",
|
||||||
|
"profile.danger.title": "منطقة الخطر",
|
||||||
|
"profile.danger.description": "يتم تطبيق الإلغاء في نهاية الفترة الحالية. تحتفظ بالوصول حتى ذلك التاريخ.",
|
||||||
|
"profile.danger.confirm": "هل أنت متأكد؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"profile.danger.confirmCancel": "تأكيد الإلغاء",
|
||||||
|
"profile.danger.cancelSubscription": "إلغاء اشتراكي",
|
||||||
|
"profile.danger.keep": "لا، إبقاء",
|
||||||
|
"profile.prefs.interfaceLang": "لغة الواجهة",
|
||||||
|
"profile.prefs.interfaceLangDesc": "يتم اكتشاف اللغة تلقائيًا بناءً على متصفحك. يمكنك تغييرها يدويًا.",
|
||||||
|
"profile.prefs.defaultTargetLang": "اللغة الهدف الافتراضية",
|
||||||
|
"profile.prefs.selectLanguage": "اختر لغة",
|
||||||
|
"profile.prefs.defaultTargetLangDesc": "سيتم تحديد هذه اللغة مسبقًا لترجماتك.",
|
||||||
|
"profile.prefs.save": "حفظ",
|
||||||
|
"profile.prefs.theme": "المظهر",
|
||||||
|
"profile.prefs.themeDesc": "اختر مظهر الواجهة",
|
||||||
|
"profile.prefs.cache": "ذاكرة التخزين المؤقت",
|
||||||
|
"profile.prefs.cacheDesc": "مسح ذاكرة التخزين المؤقت المحلية قد يحل بعض مشاكل العرض.",
|
||||||
|
"profile.prefs.clearing": "جارٍ المسح...",
|
||||||
|
"profile.prefs.clearCache": "مسح ذاكرة التخزين المؤقت"
|
||||||
|
}
|
||||||
7
frontend/src/lib/i18n/messages/ar/providerSelector.json
Normal file
7
frontend/src/lib/i18n/messages/ar/providerSelector.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"providerSelector.noClassic": "لا يوجد مترجم قياسي متاح.",
|
||||||
|
"providerSelector.noLlm": "لم يتم تكوين نموذج ذكاء اصطناعي.",
|
||||||
|
"providerSelector.costOne": "التكلفة: رصيد واحد لكل صفحة",
|
||||||
|
"providerSelector.costFive": "التكلفة: 5 أرصدة لكل صفحة (عامل مميز)",
|
||||||
|
"providerSelector.unlockContextual": "افتح الترجمة السياقية المتميزة لمستنداتك بالكامل."
|
||||||
|
}
|
||||||
29
frontend/src/lib/i18n/messages/ar/providerTheme.json
Normal file
29
frontend/src/lib/i18n/messages/ar/providerTheme.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"providerTheme.deepseek.badge": "أساسي",
|
||||||
|
"providerTheme.deepseek.subBadge": "تقني واقتصادي",
|
||||||
|
"providerTheme.deepseek.desc": "ترجمة فائقة الدقة واقتصادية، مثالية للمستندات التقنية والبرمجة.",
|
||||||
|
"providerTheme.openai.badge": "مميز",
|
||||||
|
"providerTheme.openai.subBadge": "دقة عالية",
|
||||||
|
"providerTheme.openai.desc": "معيار الذكاء الاصطناعي العالمي. أقصى اتساق نصي واحترام صارم للأسلوب.",
|
||||||
|
"providerTheme.minimax.badge": "متقدم",
|
||||||
|
"providerTheme.minimax.subBadge": "الأداء",
|
||||||
|
"providerTheme.minimax.desc": "سرعة تنفيذ مذهلة وفهم ممتاز للهياكل المعقدة.",
|
||||||
|
"providerTheme.openrouter.badge": "سريع",
|
||||||
|
"providerTheme.openrouter.subBadge": "متعدد النماذج",
|
||||||
|
"providerTheme.openrouter.desc": "وصول موحد إلى أفضل النماذج مفتوحة المصدر المحسّنة للترجمة.",
|
||||||
|
"providerTheme.openrouter_premium.badge": "فائق",
|
||||||
|
"providerTheme.openrouter_premium.subBadge": "السياق الأقصى",
|
||||||
|
"providerTheme.openrouter_premium.desc": "مدعوم من أحدث النماذج (GPT-4o، Claude Sonnet 4.6) للمستندات الطويلة.",
|
||||||
|
"providerTheme.zai.badge": "متخصص",
|
||||||
|
"providerTheme.zai.subBadge": "المال والقانون",
|
||||||
|
"providerTheme.zai.desc": "نموذج مضبوط بدقة للمصطلحات التجارية المتطلبة (قانونية، مالية).",
|
||||||
|
"providerTheme.default.badge": "حديث",
|
||||||
|
"providerTheme.default.subBadge": "تفكير الذكاء الاصطناعي",
|
||||||
|
"providerTheme.default.desc": "ترجمة بنموذج لغوي كبير (LLM) مع تحليل دلالي متقدم.",
|
||||||
|
"providerTheme.classic.google.label": "ترجمة Google",
|
||||||
|
"providerTheme.classic.google.desc": "ترجمة فائقة السرعة تغطي أكثر من 130 لغة. موصى بها للتدفقات العامة.",
|
||||||
|
"providerTheme.classic.deepl.label": "DeepL Pro",
|
||||||
|
"providerTheme.classic.deepl.desc": "ترجمة عالية الدقة مشهور بسيولتها وصياغاتها الطبيعية.",
|
||||||
|
"providerTheme.classic.google_cloud.label": "Google Cloud API",
|
||||||
|
"providerTheme.classic.google_cloud.desc": "محرك سحابي احترافي محسّن لمعالجة كميات كبيرة من المستندات."
|
||||||
|
}
|
||||||
29
frontend/src/lib/i18n/messages/ar/register.json
Normal file
29
frontend/src/lib/i18n/messages/ar/register.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"register.title": "إنشاء حساب",
|
||||||
|
"register.subtitle": "ابدأ الترجمة مجانًا",
|
||||||
|
"register.error.failed": "فشل التسجيل",
|
||||||
|
"register.name.label": "الاسم",
|
||||||
|
"register.name.placeholder": "اسمك",
|
||||||
|
"register.name.error": "يجب أن يحتوي الاسم على حرفين على الأقل",
|
||||||
|
"register.email.label": "البريد الإلكتروني",
|
||||||
|
"register.email.placeholder": "you@example.com",
|
||||||
|
"register.email.error": "عنوان بريد إلكتروني غير صالح",
|
||||||
|
"register.password.label": "كلمة المرور",
|
||||||
|
"register.password.error": "يجب أن تتكون كلمة المرور من 8 أحرف على الأقل، مع حرف كبير وحرف صغير ورقم",
|
||||||
|
"register.password.show": "إظهار كلمة المرور",
|
||||||
|
"register.password.hide": "إخفاء كلمة المرور",
|
||||||
|
"register.password.strengthLabel": "القوة:",
|
||||||
|
"register.password.strength.weak": "ضعيفة",
|
||||||
|
"register.password.strength.medium": "متوسطة",
|
||||||
|
"register.password.strength.strong": "قوية",
|
||||||
|
"register.confirmPassword.label": "تأكيد كلمة المرور",
|
||||||
|
"register.confirmPassword.error": "كلمتا المرور غير متطابقتين",
|
||||||
|
"register.confirmPassword.show": "إظهار",
|
||||||
|
"register.confirmPassword.hide": "إخفاء",
|
||||||
|
"register.submit.creating": "جارٍ إنشاء الحساب...",
|
||||||
|
"register.submit.create": "إنشاء حسابي",
|
||||||
|
"register.hasAccount": "لديك حساب بالفعل؟",
|
||||||
|
"register.login": "تسجيل الدخول",
|
||||||
|
"register.terms.prefix": "بإنشاء حساب، فإنك تقبل",
|
||||||
|
"register.terms.link": "شروط الخدمة الخاصة بنا"
|
||||||
|
}
|
||||||
14
frontend/src/lib/i18n/messages/ar/services.json
Normal file
14
frontend/src/lib/i18n/messages/ar/services.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"services.title": "مزودو الترجمة",
|
||||||
|
"services.subtitle": "تم تكوين المزودين من قبل المسؤول. يمكنك معرفة أيهم متاح حاليًا لحسابك.",
|
||||||
|
"services.loading": "جارٍ تحميل المزودين...",
|
||||||
|
"services.noProviders": "لا يوجد مزودون مكوّنون حاليًا. اتصل بالمسؤول.",
|
||||||
|
"services.classic": "ترجمة كلاسيكية",
|
||||||
|
"services.llmPro": "LLM · مدرك للسياق (Pro)",
|
||||||
|
"services.available": "متاح",
|
||||||
|
"services.model": "النموذج",
|
||||||
|
"services.adminOnly.title": "تكوين المزودين للمسؤولين فقط",
|
||||||
|
"services.adminOnly.desc": "يتم إدارة مفاتيح API واختيار النماذج وتفعيل المزودين حصريًا من قبل المسؤول في لوحة الإدارة. لا تحتاج أبدًا إلى إدخال مفتاح API.",
|
||||||
|
"services.fallback.google.label": "ترجمة Google",
|
||||||
|
"services.fallback.google.desc": "ترجمة سريعة، أكثر من 130 لغة"
|
||||||
|
}
|
||||||
20
frontend/src/lib/i18n/messages/ar/settings.json
Normal file
20
frontend/src/lib/i18n/messages/ar/settings.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"settings.title": "الإعدادات",
|
||||||
|
"settings.subtitle": "إعدادات التطبيق العامة",
|
||||||
|
"settings.formats.title": "التنسيقات المدعومة",
|
||||||
|
"settings.formats.subtitle": "أنواع المستندات التي يمكنك ترجمتها",
|
||||||
|
"settings.formats.formulas": "الصيغ",
|
||||||
|
"settings.formats.styles": "الأنماط",
|
||||||
|
"settings.formats.images": "الصور",
|
||||||
|
"settings.formats.headers": "الرؤوس",
|
||||||
|
"settings.formats.tables": "الجداول",
|
||||||
|
"settings.formats.slides": "الشرائح",
|
||||||
|
"settings.formats.notes": "الملاحظات",
|
||||||
|
"settings.cache.title": "ذاكرة التخزين المؤقت",
|
||||||
|
"settings.cache.desc": "مسح ذاكرة التخزين المؤقت المحلية قد يحل بعض مشاكل العرض.",
|
||||||
|
"settings.cache.clearing": "جارٍ المسح...",
|
||||||
|
"settings.cache.clear": "مسح ذاكرة التخزين المؤقت",
|
||||||
|
"settings.formats.excel.name": "Excel",
|
||||||
|
"settings.formats.word.name": "Word",
|
||||||
|
"settings.formats.powerpoint.name": "PowerPoint"
|
||||||
|
}
|
||||||
92
frontend/src/lib/i18n/messages/ar/translate.json
Normal file
92
frontend/src/lib/i18n/messages/ar/translate.json
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"translate.mode.label": "Mode de traduction",
|
||||||
|
"translate.mode.classic": "Classique",
|
||||||
|
"translate.mode.classicDesc": "Rapide",
|
||||||
|
"translate.mode.proLlm": "Pro LLM",
|
||||||
|
"translate.mode.proLlmDesc": "Contextuel",
|
||||||
|
"translate.mode.tooltip": "Passez à Pro pour la traduction LLM",
|
||||||
|
"translate.mode.upgradeLink": "Passer à Pro",
|
||||||
|
"translate.mode.upgradeDesc": "pour des traductions alimentées par LLM",
|
||||||
|
"translate.glossary.title": "المسرد",
|
||||||
|
"translate.glossary.select": "اختر مسرداً",
|
||||||
|
"translate.glossary.none": "بدون",
|
||||||
|
"translate.glossary.terms": "مصطلحات",
|
||||||
|
"translate.glossary.proOnly": "قم بالترقية إلى Pro لاستخدام المصطلحات",
|
||||||
|
"translate.glossary.myGlossaries": "مصطلحاتي",
|
||||||
|
"translate.glossary.fromTemplate": "إنشاء من قالب",
|
||||||
|
"translate.glossary.noGlossaryForPair": "لا مصطلحات لـ",
|
||||||
|
"translate.glossary.noGlossaries": "لا مصطلحات",
|
||||||
|
"translate.glossary.loading": "جاري التحميل...",
|
||||||
|
"translate.glossary.classicMode": "محرك محايد بدون مسرد (AI فقط)",
|
||||||
|
"translate.glossary.selectPlaceholder": "اختر مسرداً...",
|
||||||
|
"translate.glossary.multilingual": "متعدد اللغات",
|
||||||
|
"translate.glossary.noGlossaryAvailable": "لا يوجد مسرد متاح",
|
||||||
|
"translate.glossary.filterByLang": "تصفية حسب اللغة",
|
||||||
|
"translate.glossary.active": "نشط",
|
||||||
|
"translate.glossary.inactive": "غير نشط",
|
||||||
|
"translate.glossary.availableTemplates": "القوالب المتاحة",
|
||||||
|
"translate.glossary.importing": "جاري الاستيراد...",
|
||||||
|
"translate.glossary.imported": "(مستورد)",
|
||||||
|
"translate.glossary.noGlossaryForSource": "لا يوجد مسرد أو قالب للغة المصدر",
|
||||||
|
"translate.glossary.createGlossary": "إنشاء مسرد",
|
||||||
|
"translate.glossary.showAll": "عرض جميع المسارد",
|
||||||
|
"translate.glossary.activePreview": "معاينة التطابقات النشطة:",
|
||||||
|
"translate.glossary.total": "الإجمالي",
|
||||||
|
"translate.glossary.moreTerms": "مصطلحات إضافية",
|
||||||
|
"translate.glossary.noTerms": "لا توجد مصطلحات في هذا المسرد.",
|
||||||
|
"translate.glossary.sourceTerm": "مصطلح المصدر",
|
||||||
|
"translate.glossary.translation": "الترجمة",
|
||||||
|
"translate.glossary.addTerm": "إضافة مصطلح",
|
||||||
|
"translate.glossary.disabledMode": "محرك محايد بدون مسرد مطبق",
|
||||||
|
"translate.glossary.addTermError": "خطأ في إضافة المصطلح",
|
||||||
|
"translate.glossary.networkError": "خطأ في الشبكة",
|
||||||
|
"translate.glossary.importFailed": "فشل الاستيراد ({status})",
|
||||||
|
"translate.glossary.helpText": "يسرد المسرد ترجمة دقيقة للمصطلحات. اختر مسرداً تطابق لغته المصدر اللغة الأصلية لمستندك.",
|
||||||
|
"translate.glossary.sourceWarning": "تحذير: هذا المسرد يستخدم لغة المصدر",
|
||||||
|
"translate.glossary.sourceWarningBut": "ولكن مستندك مهيأ بـ",
|
||||||
|
"translate.glossary.targetWarning": "عدم توافق الهدف: هذا المسرد مصمم للترجمة إلى",
|
||||||
|
"translate.glossary.targetWarningBut": "ولكن مستندك يستهدف",
|
||||||
|
"translate.glossary.targetWarningEnd": "قد لا تكون المصطلحات ذات صلة.",
|
||||||
|
"translate.header.processing": "جارٍ المعالجة",
|
||||||
|
"translate.header.aiActive": "تحليل الذكاء الاصطناعي نشط",
|
||||||
|
"translate.header.aiActiveDesc": "يتم الحفاظ على تخطيطك بواسطة محركنا السياقي.",
|
||||||
|
"translate.header.completed": "مكتمل",
|
||||||
|
"translate.header.completedTitle": "اكتملت الترجمة",
|
||||||
|
"translate.header.proSpace": "مساحة Pro",
|
||||||
|
"translate.header.translateDoc": "ترجمة مستند",
|
||||||
|
"translate.header.translateDocDesc": "حافظ على التخطيط الأصلي من خلال محرك الترجمة فائق الدقة.",
|
||||||
|
"translate.upload.nativeFormat": "التنسيق الأصلي",
|
||||||
|
"translate.fileType.word": "Word (.docx)",
|
||||||
|
"translate.fileType.excel": "Excel (.xlsx)",
|
||||||
|
"translate.fileType.slides": "شرائح (.pptx)",
|
||||||
|
"translate.fileType.pdf": "PDF (.pdf)",
|
||||||
|
"translate.startTranslation": "بدء الترجمة",
|
||||||
|
"translate.submit": "جارٍ الإرسال…",
|
||||||
|
"translate.chooseTargetLang": "يرجى اختيار لغة مستهدفة",
|
||||||
|
"translate.pleaseLoadFile": "يرجى تحميل ملف أولاً",
|
||||||
|
"translate.contextEngineActive": "المحرك السياقي نشط",
|
||||||
|
"translate.phase1": "المرحلة 1: التهيئة",
|
||||||
|
"translate.phase2": "المرحلة 2: إعادة البناء السياقي",
|
||||||
|
"translate.stat.segments": "مقاطع",
|
||||||
|
"translate.stat.precision": "الدقة",
|
||||||
|
"translate.stat.speedLabel": "السرعة",
|
||||||
|
"translate.stat.turbo": "تيربو",
|
||||||
|
"translate.stat.time": "الوقت",
|
||||||
|
"translate.complete.masterQuality": "✓ جودة رئيسية",
|
||||||
|
"translate.download": "تنزيل",
|
||||||
|
"translate.newTranslation": "+ ترجمة جديدة",
|
||||||
|
"translate.failedTitle": "خطأ في الترجمة",
|
||||||
|
"translate.retry": "إعادة المحاولة",
|
||||||
|
"translate.uploadAnother": "تحميل ملف آخر",
|
||||||
|
"translate.monitor": "مراقب الذكاء الاصطناعي",
|
||||||
|
"translate.summary": "ملخص",
|
||||||
|
"translate.cancelProcess": "⟳ إلغاء العملية",
|
||||||
|
"translate.layoutIntegrity": "سلامة التخطيط",
|
||||||
|
"translate.secureHundred": "100% آمن",
|
||||||
|
"translate.okHundred": "100% OK",
|
||||||
|
"translate.preserveLayout": "الحفاظ على التخطيط",
|
||||||
|
"translate.preserveLayoutDesc": "الحفاظ على التخطيط",
|
||||||
|
"translate.textOnly": "نص فقط",
|
||||||
|
"translate.textOnlyDesc": "ترجمة سريعة للنص فقط",
|
||||||
|
"translate.unavailableStandard": "غير متاح في الوضع القياسي (AI فقط)"
|
||||||
|
}
|
||||||
6
frontend/src/lib/i18n/messages/ar/translateComplete.json
Normal file
6
frontend/src/lib/i18n/messages/ar/translateComplete.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"translateComplete.highQuality": "جودة عالية",
|
||||||
|
"translateComplete.segments": "المقاطع",
|
||||||
|
"translateComplete.characters": "الأحرف",
|
||||||
|
"translateComplete.confidence": "الثقة"
|
||||||
|
}
|
||||||
48
frontend/src/lib/i18n/messages/de/admin.json
Normal file
48
frontend/src/lib/i18n/messages/de/admin.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"admin.login.title": "Administration",
|
||||||
|
"admin.login.required": "Anmeldung erforderlich",
|
||||||
|
"admin.login.password": "Admin-Passwort",
|
||||||
|
"admin.login.connecting": "Verbindung wird hergestellt...",
|
||||||
|
"admin.login.access": "Admin-Panel öffnen",
|
||||||
|
"admin.login.restricted": "Nur für Administratoren",
|
||||||
|
"admin.layout.checking": "Authentifizierung wird überprüft...",
|
||||||
|
"admin.dashboard.title": "Admin-Dashboard",
|
||||||
|
"admin.dashboard.subtitle": "Administratorkontrollpanel",
|
||||||
|
"admin.dashboard.refresh": "Aktualisieren",
|
||||||
|
"admin.dashboard.refreshTooltip": "Dashboard-Daten aktualisieren",
|
||||||
|
"admin.dashboard.config": "Systemkonfiguration",
|
||||||
|
"admin.dashboard.maxFileSize": "Max. Dateigröße:",
|
||||||
|
"admin.dashboard.translationService": "Übersetzungsdienst:",
|
||||||
|
"admin.dashboard.formats": "Formate:",
|
||||||
|
"admin.nav.dashboard": "Dashboard",
|
||||||
|
"admin.nav.users": "Benutzer",
|
||||||
|
"admin.nav.pricing": "Preise & Stripe",
|
||||||
|
"admin.nav.providers": "Anbieter",
|
||||||
|
"admin.nav.system": "System",
|
||||||
|
"admin.nav.logs": "Protokolle",
|
||||||
|
"admin.users.title": "Benutzerverwaltung",
|
||||||
|
"admin.users.subtitle": "Benutzerkonten anzeigen und verwalten",
|
||||||
|
"admin.users.planUpdated": "Plan aktualisiert",
|
||||||
|
"admin.users.planChanged": "Der Plan wurde erfolgreich zu \\\"{plan}\\\" geändert.",
|
||||||
|
"admin.users.unknownError": "Unbekannter Fehler",
|
||||||
|
"admin.users.error": "Fehler",
|
||||||
|
"admin.users.planUpdateError": "Plan konnte nicht aktualisiert werden: {message}",
|
||||||
|
"admin.users.noKeys": "Keine Schlüssel",
|
||||||
|
"admin.users.noKeysDesc": "Dieser Benutzer hat keine aktiven API-Schlüssel.",
|
||||||
|
"admin.users.keysRevoked": "Schlüssel widerrufen",
|
||||||
|
"admin.users.keysRevokedDesc": "{count} API-Schlüssel erfolgreich widerrufen.",
|
||||||
|
"admin.users.revokeError": "Schlüssel konnten nicht widerrufen werden: {message}",
|
||||||
|
"admin.users.retry": "Erneut versuchen",
|
||||||
|
"admin.system.title": "System",
|
||||||
|
"admin.system.subtitle": "Systemstatus überwachen und Ressourcen verwalten",
|
||||||
|
"admin.system.quotas": "Übersetzungskontingente",
|
||||||
|
"admin.system.resetQuotas": "Monatliche Kontingente zurücksetzen",
|
||||||
|
"admin.system.resetting": "Zurücksetzen...",
|
||||||
|
"admin.system.reset": "Zurücksetzen",
|
||||||
|
"admin.system.allOperational": "Alle Systeme betriebsbereit",
|
||||||
|
"admin.system.issuesDetected": "Systemprobleme erkannt",
|
||||||
|
"admin.system.waitingData": "Warte auf Daten...",
|
||||||
|
"admin.system.purging": "Bereinigung läuft...",
|
||||||
|
"admin.system.clean": "Bereinigen",
|
||||||
|
"admin.system.purge": "Bereinigen"
|
||||||
|
}
|
||||||
72
frontend/src/lib/i18n/messages/de/apiKeys.json
Normal file
72
frontend/src/lib/i18n/messages/de/apiKeys.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
|
"apiKeys.webhook.codeParam": "webhook_url",
|
||||||
|
"apiKeys.title": "API-Schlüssel",
|
||||||
|
"apiKeys.subtitle": "Verwalten Sie Ihre API-Schlüssel für den programmgesteuerten Zugriff auf die Übersetzungs-API.",
|
||||||
|
"apiKeys.loading": "Laden...",
|
||||||
|
"apiKeys.sectionTitle": "API & Automatisierung",
|
||||||
|
"apiKeys.sectionDesc": "Generieren und verwalten Sie Ihre API-Schlüssel für Automatisierungs-Workflows",
|
||||||
|
"apiKeys.keysUsed": "{total} von {max} Schlüsseln verwendet",
|
||||||
|
"apiKeys.maxReached": "Maximale Anzahl an Schlüsseln erreicht. Widerrufen Sie einen Schlüssel, um einen neuen zu generieren.",
|
||||||
|
"apiKeys.canGenerate": "Sie können {count} weiteren Schlüssel generieren",
|
||||||
|
"apiKeys.canGeneratePlural": "Sie können {count} weitere Schlüssel generieren",
|
||||||
|
"apiKeys.generateNew": "Neuen Schlüssel generieren",
|
||||||
|
"apiKeys.keyRevoked": "Schlüssel widerrufen",
|
||||||
|
"apiKeys.keyRevokedDesc": "Der API-Schlüssel wurde erfolgreich widerrufen.",
|
||||||
|
"apiKeys.keyNotFound": "Schlüssel nicht gefunden",
|
||||||
|
"apiKeys.keyNotFoundDesc": "Der API-Schlüssel existiert nicht mehr. Er wurde möglicherweise bereits widerrufen.",
|
||||||
|
"apiKeys.error": "Fehler",
|
||||||
|
"apiKeys.revokeError": "Der API-Schlüssel konnte nicht widerrufen werden. Bitte versuchen Sie es erneut.",
|
||||||
|
"apiKeys.limitReached": "Limit erreicht",
|
||||||
|
"apiKeys.limitReachedDesc": "Sie haben das Maximum von 10 API-Schlüsseln erreicht. Widerrufen Sie einen vorhandenen Schlüssel, um einen neuen zu generieren.",
|
||||||
|
"apiKeys.proRequired": "Pro-Funktion erforderlich",
|
||||||
|
"apiKeys.proRequiredDesc": "API-Schlüssel sind eine Pro-Funktion. Bitte upgraden Sie Ihr Konto.",
|
||||||
|
"apiKeys.generateError": "API-Schlüssel konnte nicht generiert werden. Bitte versuchen Sie es erneut.",
|
||||||
|
"apiKeys.upgrade.title": "API-Schlüssel",
|
||||||
|
"apiKeys.upgrade.subtitle": "Automatisieren Sie Ihre Übersetzungen mit API-Zugang",
|
||||||
|
"apiKeys.upgrade.feat1": "Generieren Sie unbegrenzt API-Schlüssel",
|
||||||
|
"apiKeys.upgrade.feat2": "Automatisieren Sie die Dokumentübersetzung",
|
||||||
|
"apiKeys.upgrade.feat3": "Webhook-Benachrichtigungen",
|
||||||
|
"apiKeys.upgrade.feat4": "LLM-Übersetzungsmodi",
|
||||||
|
"apiKeys.upgrade.proFeature": "API-Schlüssel sind eine {pro}-Funktion. Upgraden Sie, um API-Automatisierung freizuschalten.",
|
||||||
|
"apiKeys.upgrade.pro": "Pro",
|
||||||
|
"apiKeys.upgrade.cta": "Auf Pro upgraden",
|
||||||
|
"apiKeys.dialog.maxTitle": "Maximale Anzahl an Schlüsseln erreicht",
|
||||||
|
"apiKeys.dialog.maxDesc": "Sie haben das Maximum von 10 API-Schlüsseln erreicht. Bitte widerrufen Sie einen vorhandenen Schlüssel, bevor Sie einen neuen generieren.",
|
||||||
|
"apiKeys.dialog.close": "Schließen",
|
||||||
|
"apiKeys.dialog.generated": "API-Schlüssel generiert!",
|
||||||
|
"apiKeys.dialog.generatedDesc": "Ihr neuer API-Schlüssel wurde erstellt. Kopieren Sie ihn jetzt – er wird nicht erneut angezeigt.",
|
||||||
|
"apiKeys.dialog.important": "Wichtig:",
|
||||||
|
"apiKeys.dialog.importantDesc": "Dies ist das einzige Mal, dass Sie diesen Schlüssel sehen. Speichern Sie ihn sicher.",
|
||||||
|
"apiKeys.dialog.apiKey": "API-Schlüssel",
|
||||||
|
"apiKeys.dialog.name": "Name:",
|
||||||
|
"apiKeys.dialog.done": "Fertig",
|
||||||
|
"apiKeys.dialog.copied": "Ich habe den Schlüssel kopiert",
|
||||||
|
"apiKeys.dialog.generateTitle": "Neuen API-Schlüssel generieren",
|
||||||
|
"apiKeys.dialog.generateDesc": "Erstellen Sie einen neuen API-Schlüssel für den programmgesteuerten Zugriff auf die Übersetzungs-API.",
|
||||||
|
"apiKeys.dialog.keyName": "Schlüsselname (optional)",
|
||||||
|
"apiKeys.dialog.keyNamePlaceholder": "z.B., Produktion, Staging",
|
||||||
|
"apiKeys.dialog.keyNameHint": "Ein beschreibender Name, um diesen Schlüssel später zu identifizieren.",
|
||||||
|
"apiKeys.dialog.nameTooLong": "Der Name muss {max} Zeichen oder weniger haben",
|
||||||
|
"apiKeys.dialog.nameInvalid": "Der Name darf nur Buchstaben, Zahlen, Leerzeichen, Bindestriche und Unterstriche enthalten",
|
||||||
|
"apiKeys.dialog.cancel": "Abbrechen",
|
||||||
|
"apiKeys.dialog.generating": "Wird generiert...",
|
||||||
|
"apiKeys.dialog.generate": "Schlüssel generieren",
|
||||||
|
"apiKeys.table.name": "Name",
|
||||||
|
"apiKeys.table.prefix": "Präfix",
|
||||||
|
"apiKeys.table.created": "Erstellt",
|
||||||
|
"apiKeys.table.lastUsed": "Zuletzt verwendet",
|
||||||
|
"apiKeys.table.never": "Nie",
|
||||||
|
"apiKeys.table.actions": "Aktionen",
|
||||||
|
"apiKeys.table.revoke": "Widerrufen",
|
||||||
|
"apiKeys.table.copyPrefix": "Schlüsselpräfix kopieren",
|
||||||
|
"apiKeys.table.revokeKey": "Schlüssel widerrufen",
|
||||||
|
"apiKeys.revokeDialog.title": "API-Schlüssel widerrufen",
|
||||||
|
"apiKeys.revokeDialog.desc": "Sind Sie sicher, dass Sie den Schlüssel \\\"{name}\\\" widerrufen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"apiKeys.revokeDialog.confirm": "Ja, widerrufen",
|
||||||
|
"apiKeys.revokeDialog.cancel": "Abbrechen",
|
||||||
|
"apiKeys.noKeysGenerated": "Keine Schlüssel generiert",
|
||||||
|
"apiKeys.copied": "Kopiert!"
|
||||||
|
}
|
||||||
3
frontend/src/lib/i18n/messages/de/auth.json
Normal file
3
frontend/src/lib/i18n/messages/de/auth.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"auth.brandName": "Wordly"
|
||||||
|
}
|
||||||
12
frontend/src/lib/i18n/messages/de/checkout.json
Normal file
12
frontend/src/lib/i18n/messages/de/checkout.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"checkout.activating": "Wird aktiviert…",
|
||||||
|
"checkout.activatingDesc": "Wir aktualisieren Ihr Abonnement, bitte warten Sie.",
|
||||||
|
"checkout.paymentConfirmed": "Zahlung bestätigt!",
|
||||||
|
"checkout.subscriptionActivated": "Abonnement aktiviert!",
|
||||||
|
"checkout.planActivated": "{plan}-Plan aktiviert!",
|
||||||
|
"checkout.redirectingToProfile": "Weiterleitung zu Ihrem Profil…",
|
||||||
|
"checkout.paymentReceived": "Zahlung erhalten",
|
||||||
|
"checkout.redirecting": "Weiterleitung…",
|
||||||
|
"checkout.syncError": "Synchronisierungsfehler",
|
||||||
|
"checkout.networkError": "Netzwerkfehler. Ihre Zahlung ist bestätigt — bitte laden Sie Ihr Profil neu."
|
||||||
|
}
|
||||||
4
frontend/src/lib/i18n/messages/de/common.json
Normal file
4
frontend/src/lib/i18n/messages/de/common.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"common.loading": "Laden...",
|
||||||
|
"common.backToHome": "Zurück zur Startseite"
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user