Compare commits

...

54 Commits

Author SHA1 Message Date
ebb2537fda feat(glossaries): implement a 3-step wizard for CSV/file imports with custom source/target language selection
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m21s
2026-06-28 11:38:18 +02:00
36aeac2c5e fix(glossaries): prevent translation data destruction on language selector change for multi glossaries
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m53s
2026-06-28 11:03:31 +02:00
de9407f974 revert(docker): remove automated database glossary translation from startup sequence
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m0s
2026-06-28 10:55:19 +02:00
5e3fb0098b feat(docker): run glossary translation script automatically on backend container startup
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
2026-06-28 10:53:58 +02:00
a57b8a8e4d fix(glossaries): normalize language code casing when retrieving display translations 2026-06-28 10:52:08 +02:00
dde80f6bc3 feat(glossaries): update script to translate missing terms in any glossary, even if target_language is already 'multi'
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m43s
2026-06-28 10:46:48 +02:00
7398cae359 feat(glossaries): add script to translate non-multilingual database glossaries using Google Translate adapter
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m25s
2026-06-28 10:42:41 +02:00
030950c962 feat(glossaries): option C - page management + wizard /new 2-step creation
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m6s
2026-06-28 09:55:14 +02:00
5d6afd2dad fix(contrast): replace all opacity-based colors with concrete hex values meeting WCAG 4.5:1 ratio
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m44s
2026-06-21 10:54:17 +02:00
07c50151cb fix(contrast): increase text contrast on glossary page for light mode readability
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m24s
2026-06-20 19:39:33 +02:00
299173fff7 ux(glossary): clarify selection flow - compact banner, Utiliser button, hide create section by default
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m27s
2026-06-20 19:18:46 +02:00
9b354befe3 fix(glossary): hide create section by default when glossaries exist - only shown on demand via toggle button
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m11s
2026-06-20 19:07:30 +02:00
4a8f33d36f fix(glossary): eliminate all redundant elements - filter imported templates, remove duplicate buttons and links
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
2026-06-20 19:03:44 +02:00
489df66c0f fix(i18n): add missing translation keys for tabs and presets 2026-06-20 19:01:09 +02:00
96bac2e792 style(glossary): remove redundant buttons and links, filter already imported presets
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m26s
2026-06-20 18:46:38 +02:00
d78f08e24f feat(glossary): restructure page with tabs, direct translate redirects and dynamic mapping
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m39s
2026-06-20 18:43:02 +02:00
c17dd2c6e1 fix(glossary): resolve data loss for non-FR/EN languages, fix prompt injection reference notes, and classic mode label wording
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m25s
2026-06-20 18:09:13 +02:00
1fe714aa1a ux(glossaries): simplify dialog, auto-save detail import, show templates and upload zone directly on main page
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m6s
2026-06-20 12:27:07 +02:00
d505b479cd refactor(glossaries): align CreateGlossaryDialog with editorial design system
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m56s
2026-06-20 09:36:39 +02:00
81cb4e09b7 fix(tests): update PDF format test to use truly unsupported format (.txt)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m8s
2026-06-14 19:50:44 +02:00
a20ebe2295 fix(tests): update glossary service tests for dict return type
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
2026-06-14 19:49:21 +02:00
233a054e34 fix(tests): isolate test DB and sync tier=pro in Pro user fixtures
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m34s
2026-06-14 19:44:25 +02:00
f85e5eef9b fix(tests): escape Windows paths in cleanup Redis mock
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s
2026-06-14 19:34:01 +02:00
b9446f166d fix(translate): French error messages and update mock users for quota checks
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m52s
2026-06-14 19:20:44 +02:00
adc3583358 fix(db): make migrations and glossary index SQLite-compatible
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m57s
2026-06-14 19:01:07 +02:00
cb8ce697d2 fix(translate): enforce Pro feature gating for glossary, custom_prompt and prompt_id
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s
2026-06-14 18:40:45 +02:00
f05399aeba fix(i18n): return French error messages in auth, register, download endpoints
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m44s
2026-06-14 18:13:41 +02:00
45e44dd7b2 fix(billing): unify quota counters, fix Stripe webhooks, tier/plan sync
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m16s
2026-06-14 17:39:34 +02:00
fa637abff0 perf+security: fix build, secure downloads, dedupe translations, refactor i18n
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m49s
Frontend:
- Fix Framer Motion / motion-dom build error by pinning framer-motion to
  11.18.2 (compatible with React 19 and Next.js 16).
- Add cross-env and build:local script to bypass standalone symlink errors
  on Windows without Developer Mode.
- Allow NEXT_OUTPUT=default to disable standalone output for local builds.
- Refactor i18n: split 14,177-line src/lib/i18n.tsx into per-locale,
  per-namespace JSON files under src/lib/i18n/messages/.
- Load English synchronously; other locales loaded on demand via dynamic
  imports (reduces initial bundle, improves maintainability).
- Remove unused next-intl message files src/messages/en.json and fr.json.

Backend:
- Remove insecure legacy /api/v1/download/{filename} and /api/v1/cleanup/{filename}
  endpoints. The job-based /api/v1/download/{job_id} already enforces ownership.
- Deduplicate texts in TranslationService.translate_batch before sending them
  to the provider, reducing API calls for repeated strings.
- Pin httpx to <0.28 to fix TestClient incompatibility with starlette 0.35.1.
- Add pytest-cov and ruff dev dependencies/config.

DevOps:
- Remove hardcoded Grafana password from docker-compose.yml and
  docker-compose.monitoring.yml; use GRAFANA_PASSWORD env var.
- Change default TRANSLATION_SERVICE from ollama to google in
  docker-compose.yml (Ollama is an optional profile).
- Add GRAFANA_PASSWORD to .env.example.
- Add .coverage and frontend/pnpm-workspace.yaml to .gitignore.

Tests:
- Update API versioning tests for removed legacy endpoints.
- Add tests/test_translation_service.py for deduplication behavior.

Verified:
- pnpm run build:local passes.
- uv run pytest tests/test_providers/* tests/test_translation_service.py
  tests/test_story_3_5_api_versioning.py tests/test_download_endpoint.py
  tests/test_translators/test_excel_translator.py: provider/translator tests
  pass; one pre-existing French error-message test still fails (message is
  returned in English, unrelated to this change).
2026-06-14 16:44:18 +02:00
eda6821632 i18n: fix missing keys and translate all non-admin frontend strings
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m43s
- Add 12 missing i18n keys (t() was returning the literal key string) to
  all 13 locales: dashboard.topbar.premiumAccess,
  dashboard.translate.complete.toastOkDesc,
  dashboard.translate.progress.{connectionLost,processingFallback},
  glossaries.card.{term,created}, glossaries.termEditor.{addTerm,maxReached},
  login.google.{connecting,errorFailed,errorGeneric}, login.orContinueWith
- Add 6 FR-drift keys (landing.pricing.{free,enterprise}.{name,desc,cta})
- Add ~120 new i18n keys covering site header/footer, file-uploader,
  checkout success, dashboard pages, translate page, provider selector
  themes, language selector, translation complete, api-keys, services,
  settings, pricing (~1800 new key/locale pairs)
- Wrap hardcoded French/English in components with t() calls
- Convert LLM_THEMES/CLASSIC_THEMES/FALLBACK_PROVIDERS maps from
  hardcoded constants to t()-driven factories
- Admin pages intentionally left untouched per request

Files: 15 components/pages + src/lib/i18n.tsx
Typecheck: passes (tsc --noEmit exit 0)
2026-06-14 12:45:12 +02:00
9b0b2ae6f9 docs: remove remaining Claude 3.5 references and display page credit costs in translation provider selector
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m11s
2026-06-14 11:41:47 +02:00
c7506e6aca fix: resolve critical security and UI session mismatch by clearing React Query cache on login/logout and invalidating on subscription updates
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m24s
2026-06-14 11:15:09 +02:00
136d40c7d8 feat: update to June 2026 models (Claude Sonnet 4.6, Gemini 3.5 Flash), add glossary button, and implement cost factor quota & vision fallback
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m4s
2026-06-14 11:05:53 +02:00
5fd087979b feat: unify multimodels translation providers, remove self-hosting (Ollama/LibreTranslate), and fix local SQLite configuration
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m21s
2026-06-14 10:44:46 +02:00
feea02033b fix: resolve Google login hydration mismatch and dynamic env load
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m42s
2026-06-07 12:21:06 +02:00
5b8c29dae6 feat: enable passing NEXT_PUBLIC_GOOGLE_CLIENT_ID at docker compose build time
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m30s
2026-06-07 11:41:04 +02:00
29753881a6 feat: add Telegram notifications for user signup and Stripe events
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m34s
2026-06-07 11:37:01 +02:00
8659b6761f docs: update README.md to act as central documentation portal with French links
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s
2026-06-07 11:21:06 +02:00
fa19f33ab0 docs: add French restore procedure guide PROCEDURE_RESTAURATION.md
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
2026-06-07 11:20:04 +02:00
9bb02927c3 fix: redirect logs to stderr and reduce size thresholds in backup/verify scripts
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m42s
2026-06-07 11:16:45 +02:00
ddf6b8f6bc fix: ignore unbound variables when sourcing .env in all backup/DR scripts
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m35s
2026-06-07 11:14:04 +02:00
3f980ad537 feat: add NAS backup, verification, and DR scripts
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
2026-06-07 11:12:01 +02:00
Sepehr
fb6740f333 fix(glossaries): correct source/target display mapping (FR=source, EN=target, others=translations[lang])
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m55s
Avant : getDisplaySource(term, 'en') lisait term.translations.en
(qui n'existe pas) puis fallback sur term.source = francais.
C'est ce qui affichait du francais et du néerlandais au mauvais endroit.

Apres : le mapping reflete la structure reelle des donnees :
- FR (lang='fr') → term.source
- EN (lang='en') → term.target
- autres (de, es, it, pt, nl, ru, ja, ko, zh, ar, fa)
  → term.translations[lang]
- si manquant → '' (placeholder, JAMAIS une autre langue en fallback)

Memes regles pour getDisplayTarget, inversees (defaut = target).

Edition (handleTermChange) ecrit au bon endroit :
- FR → term.source
- EN (ou multi) → term.target
- autres → translations[lang]

Le remap automatique de term.target au changement de targetLanguage
est supprime (lecture a la volee maintenant, plus besoin de modifier
l'etat des termes).

Aucun changement de donnees, aucun changement backend, aucun
changement de schema. Fix purement frontend.
2026-06-07 10:59:57 +02:00
Sepehr
79848230c0 fix(glossaries): restore selectable source language (data was already there)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m37s
Revert du commit e11a6b1 : la langue source doit etre selectionnable
(car l'utilisateur peut vouloir traduire depuis n'importe quelle
des 12 langues supportees, pas seulement le francais).

Le data modele support deja le cas : chaque terme a un champ
\	ranslations\ (dict de 11 langues) qui contient la traduction du
terme source. Donc pour traduire depuis l'italien, on lit
\	erm.translations.it\ comme source, et \	erm.translations.es\
comme cible si la cible est l'espagnol.

Changements :
- Le combobox 'Langue source' est restaure (12 langues)
- Nouvelle fonction \getDisplaySource(term, lang)\ :
  * 'fr' ou 'multi' → term.source (le francais original)
  * autre → term.translations[lang] (la traduction dans la langue)
  * fallback → term.source si la traduction manque
- handleTermChange ecrit au bon endroit selon la langue :
  * source FR → term.source
  * autre source → term.translations[sourceLanguage]
  * target 'multi'/'en' → term.target
  * autre target → term.translations[targetLanguage]
- hasUnsavedChanges compare aussi le dict translations (avant
  il ne comparait que source|target, donc un edit dans une autre
  langue ne declenchait pas l'alerte 'non enregistre')
- Note sous le combobox source explique la regle
  (FR = source originale, autre = champ translations)
- i18n : nouvelle cle \glossaries.detail.sourceLangNote\
  ajoutee aux 13 locales (FR + EN traduit)

L'utilisateur peut maintenant choisir 'Italien' comme source et
'Espagnol' comme cible, et voir les termes correspondants.
2026-06-07 10:11:19 +02:00
Sepehr
e11a6b16a0 fix(glossaries): source language combobox was a lie — replace with fixed FR label
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m32s
Les templates data/glossaires/*.json ne stockent les termes sources
qu'en francais. Le combobox 'Langue source' laissait l'utilisateur
croire qu'il pouvait traduire depuis une autre langue, mais le
backend renverrait toujours des termes en francais.

Fix : remplacer le select par un label fixe 'Francais' avec un badge
'fixe' et une note explicative indiquant que le multilingue source
est sur la roadmap.

Le select 'Langue cible' reste : il determine quelle traduction du
terme est affichee dans la colonne 'Cible' (FR+10 langues via le
champ translations).
2026-06-07 09:58:16 +02:00
80b49ee354 Robustness: Implement multi-destination backups (LOCAL, NAS, SCP) and backup/restore of NPM configurations 2026-06-07 09:50:51 +02:00
c7299228cd Robustness: Add fallback path handling in disaster-recovery.sh for NAS offline cases
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m15s
2026-06-07 09:48:13 +02:00
Sepehr
02a4a7ded8 fix(glossaries): sync data files name/description with index.json (→ Multilingue)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m15s
Le index.json avait ete mis a jour '→ Multilingue' mais les fichiers
data/glossaries/*.json gardaient l'ancien nom '→ Anglais' (commit c66252b
n'avait touche que l'index, pas les donnees). Consequence : importer un
template creait un glossaire avec le nom '→ Anglais' alors que les termes
sont en 11 langues (multilingues).

Sync de name + description des 8 fichiers sur l'index.
2026-06-07 09:42:47 +02:00
670d3f4376 Documentation: Add French Disaster Recovery Playbook for server failovers 2026-06-07 09:39:26 +02:00
Sepehr
e497f2d218 refactor(glossaries): single source of truth + dedicated detail page
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m15s
UX refonte :
- Retire la section 'Glossaires professionnels' de la vue principale
  (les 8 cartes de templates sont maintenant dans le dialog de creation)
- Cartes 'Vos glossaires' plus simples : nom, langues, termes, date
  - Cliquer sur la carte navigue vers /dashboard/glossaries/[id]
  - Plus de boutons Edit/Delete sur la carte (deplaces dans la page detail)
- Recherche par nom (visible si > 3 glossaires)
- Badge 'Non enregistre' si modifications non sauvegardees

Nouvelle page /dashboard/glossaries/[id] :
- Edition inline du nom (input), langues source/cible (select)
- Tableau des termes avec recherche et edition en place
- Ajout/suppression de termes (max 500)
- Export / Import CSV (meme logique que l'edit dialog)
- Zone danger : confirmation en 2 temps pour la suppression
- Back link vers la liste
- i18n : 40 nouvelles cles ajoutees aux 13 locales (FR + EN traduit,
  les autres utilisent le fallback EN)

Design preserve : editorial-card, brand-accent, meme typographie,
meme palette. Refactor structurel uniquement, pas de restyling.

Le system prompt (Instructions de contexte) reste tel quel, au-dessus
de la liste des glossaires, comme dans le design actuel.
2026-06-07 09:38:19 +02:00
23373ab91a Robustness: Update backup-database.sh with robust python resolution and cross-platform path handling 2026-06-07 09:29:19 +02:00
9104f6123d Security: Enforce strict CORS domain restriction in production environment 2026-06-07 09:26:45 +02:00
e7b5ea9a61 Robustness: Add json-file log rotation limits to all docker services 2026-06-07 09:26:39 +02:00
8805044bb6 Security: Untrack SSL private keys and certificates and add to .gitignore 2026-06-07 09:24:10 +02:00
468 changed files with 30690 additions and 16735 deletions

View 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
```

View 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.

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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 | 05s | ~1 min | Autorestart + WAL |
| Corruption DB partielle | 06h | ~5 min | Restore depuis NAS |
| Serveur .151 mort | 06h | **~25 min** | Restore NAS sur .98 + NPM auto |
| Erreur humaine (DROP) | 06h | ~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 : 2025 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
```

View File

@@ -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)

View File

@@ -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
View 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
View File

@@ -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 lhôte ; tout passe par le proxy.
- Détails : [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) (SSL/TLS, santé `/health`, variables denvironnement).
## 📝 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`

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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é)

View 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_

View File

@@ -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)

View File

@@ -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'))"
)

View File

@@ -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("""

View File

@@ -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("""

View File

@@ -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("""

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
}, },

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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"],

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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);

View File

@@ -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 ? (

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
); );

View 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>
);
}

View 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à é 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>
);
}

View File

@@ -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 «&nbsp;{searchQuery}&nbsp;»
</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>
); );
} }

View File

@@ -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>
); );

View File

@@ -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>
)} )}

View File

@@ -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");

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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>
)} )}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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('/');
}; };

View File

@@ -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 />}

View File

@@ -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">&times;</button> <button onClick={() => setToastMsg(null)} className="text-muted-foreground hover:text-foreground text-lg leading-none">&times;</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>

View File

@@ -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>

View File

@@ -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>
) );
} }

View File

@@ -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

View 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": "حذف"
}

View 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": "تم النسخ!"
}

View File

@@ -0,0 +1,3 @@
{
"auth.brandName": "Wordly"
}

View 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": "خطأ في الشبكة. تم تأكيد الدفع — يرجى إعادة تحميل ملفك الشخصي."
}

View File

@@ -0,0 +1,4 @@
{
"common.loading": "جارٍ التحميل...",
"common.backToHome": "العودة للرئيسية"
}

View 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": "تم حفظ تعليمات السياق الخاصة بك."
}

View File

@@ -0,0 +1,7 @@
{
"cookieConsent.title": "ملفات تعريف الارتباط على Wordly",
"cookieConsent.description": "نستخدم ملفات تعريف الارتباط الأساسية لعمل التطبيق (الجلسة، الأمان، اللغة). بإذنك، نستخدم أيضًا ملفات تعريف ارتباط اختيارية لقياس حركة المرور وتحسين المنتج.",
"cookieConsent.acceptAll": "قبول الكل",
"cookieConsent.essentialOnly": "الأساسية فقط",
"cookieConsent.learnMore": "معرفة المزيد"
}

View 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": "متابعة إلى الترجمة"
}

View 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…"
}

View 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."
}

View 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;

View 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": "جاري الحفاظ على التنسيق"
}

View File

@@ -0,0 +1,7 @@
{
"langSelector.search": "بحث…",
"langSelector.noResults": "لا توجد نتائج",
"langSelector.source": "المصدر",
"langSelector.target": "الهدف",
"langSelector.swap": "تبديل"
}

View File

@@ -0,0 +1,5 @@
{
"layout.nav.apiAccess": "وصول API",
"layout.footer.terms": "الشروط",
"layout.footer.privacy": "الخصوصية"
}

View File

@@ -0,0 +1,6 @@
{
"login.orContinueWith": "أو تابع بالبريد الإلكتروني",
"login.google.connecting": "جارٍ الاتصال…",
"login.google.errorGeneric": "حدث خطأ في تسجيل الدخول عبر Google.",
"login.google.errorFailed": "فشل تسجيل الدخول عبر Google. حاول مرة أخرى."
}

View File

@@ -0,0 +1,6 @@
{
"memento.title": "اكتشف Momento",
"memento.slogan": "Momento ليس مجرد تطبيق ملاحظات. إنه نظام بيئي ذكي يربط ويحلل ويطور أفكارك في الوقت الفعلي باستخدام 6 وكلاء ذكاء اصطناعي وبحث دلالي متقدم.",
"memento.ctaFree": "ابدأ مجاناً",
"memento.ctaMore": "اعرف المزيد"
}

View 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": "✕"
}

View 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": "مسح ذاكرة التخزين المؤقت"
}

View File

@@ -0,0 +1,7 @@
{
"providerSelector.noClassic": "لا يوجد مترجم قياسي متاح.",
"providerSelector.noLlm": "لم يتم تكوين نموذج ذكاء اصطناعي.",
"providerSelector.costOne": "التكلفة: رصيد واحد لكل صفحة",
"providerSelector.costFive": "التكلفة: 5 أرصدة لكل صفحة (عامل مميز)",
"providerSelector.unlockContextual": "افتح الترجمة السياقية المتميزة لمستنداتك بالكامل."
}

View 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": "محرك سحابي احترافي محسّن لمعالجة كميات كبيرة من المستندات."
}

View 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": "شروط الخدمة الخاصة بنا"
}

View 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 لغة"
}

View 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"
}

View 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 فقط)"
}

View File

@@ -0,0 +1,6 @@
{
"translateComplete.highQuality": "جودة عالية",
"translateComplete.segments": "المقاطع",
"translateComplete.characters": "الأحرف",
"translateComplete.confidence": "الثقة"
}

View 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"
}

View 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!"
}

View File

@@ -0,0 +1,3 @@
{
"auth.brandName": "Wordly"
}

View 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."
}

View 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