diff --git a/web/package-lock.json b/web/package-lock.json index 0cabec2..69ccfc6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,8 @@ "@angular/platform-browser": "^21.2.16", "@angular/platform-browser-dynamic": "^21.2.16", "@angular/router": "^21.2.16", + "@ngx-translate/core": "^18.0.0", + "@ngx-translate/http-loader": "^18.0.0", "dompurify": "^3.4.1", "lucide-angular": "^1.0.0", "marked": "^18.0.2", @@ -4486,6 +4488,35 @@ "webpack": "^5.54.0" } }, + "node_modules/@ngx-translate/core": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-18.0.0.tgz", + "integrity": "sha512-Z9u9AXaeuyeHHimivvJQvhal/LJNB+5tlH+FimSU4QQ61FrtRDqgyn6nfFbc6Cb+59NDZdsh4QeTJyPCldFUwA==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=18", + "@angular/core": ">=18", + "rxjs": ">=7" + } + }, + "node_modules/@ngx-translate/http-loader": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-18.0.0.tgz", + "integrity": "sha512-p6QpNcU3rkCYjHbKVIadcYtml/Njpr0aLp8neJ4uJWQ4Xm9f09bIePhOjdjAFHupTptKqKrG1J2gSi3RSwgYeA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=18", + "@angular/core": ">=18", + "@ngx-translate/core": ">=18.0.0" + } + }, "node_modules/@noble/hashes": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", diff --git a/web/package.json b/web/package.json index a260bdc..41fb34e 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,8 @@ "@angular/platform-browser": "^21.2.16", "@angular/platform-browser-dynamic": "^21.2.16", "@angular/router": "^21.2.16", + "@ngx-translate/core": "^18.0.0", + "@ngx-translate/http-loader": "^18.0.0", "dompurify": "^3.4.1", "lucide-angular": "^1.0.0", "marked": "^18.0.2", diff --git a/web/scripts/i18n-merge.mjs b/web/scripts/i18n-merge.mjs new file mode 100644 index 0000000..34b70cc --- /dev/null +++ b/web/scripts/i18n-merge.mjs @@ -0,0 +1,49 @@ +// Fusionne les fragments de traduction par fonctionnalité dans les fichiers +// centraux fr.json / en.json. +// +// Chaque agent de traduction écrit un fragment dédié sous +// src/assets/i18n/fragments/..json contenant uniquement SON +// namespace (clé de premier niveau distincte de common/language/settings). +// Ce script deep-merge tous ces fragments dans fr.json / en.json — c'est le +// SEUL endroit qui écrit les fichiers centraux, ce qui évite tout conflit +// d'écriture entre agents parallèles. +// +// Usage : node scripts/i18n-merge.mjs +import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const i18nDir = join(here, '..', 'src', 'assets', 'i18n'); +const fragDir = join(i18nDir, 'fragments'); + +const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v); + +function deepMerge(target, source) { + for (const key of Object.keys(source)) { + if (isObject(source[key]) && isObject(target[key])) { + deepMerge(target[key], source[key]); + } else { + target[key] = source[key]; + } + } + return target; +} + +for (const lang of ['fr', 'en']) { + const basePath = join(i18nDir, `${lang}.json`); + const base = JSON.parse(readFileSync(basePath, 'utf8')); + + if (existsSync(fragDir)) { + const fragments = readdirSync(fragDir) + .filter((f) => f.endsWith(`.${lang}.json`)) + .sort(); + for (const frag of fragments) { + const data = JSON.parse(readFileSync(join(fragDir, frag), 'utf8')); + deepMerge(base, data); + } + } + + writeFileSync(basePath, JSON.stringify(base, null, 2) + '\n', 'utf8'); + console.log(`✓ ${lang}.json mis à jour`); +} diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.html b/web/src/app/campaigns/arc/arc-create/arc-create.component.html index 41c9472..975be8e 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.html +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.html @@ -1,58 +1,58 @@
- +
- +
- +
- +
- +
diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts index 513376d..f929644 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts @@ -4,6 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule, BookOpen } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -21,7 +22,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; */ @Component({ selector: 'app-arc-create', - imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent], + imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent, TranslatePipe], templateUrl: './arc-create.component.html', styleUrls: ['./arc-create.component.scss'] }) @@ -43,7 +44,8 @@ export class ArcCreateComponent implements OnInit, OnDestroy { private npcService: NpcService, private randomTableService: RandomTableService, private enemyService: EnemyService, - private layoutService: LayoutService + private layoutService: LayoutService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -66,7 +68,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy { }).subscribe(({ campaign, allCampaigns, treeData }) => { this.existingArcCount = treeData.arcs.length; - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html index 4b2976c..19e7ee3 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html @@ -2,24 +2,24 @@ @@ -28,118 +28,118 @@
- + - Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max. + {{ 'arcEdit.illustrationsHint' | translate }}
- + - Cartes regionales et plans utiles aux joueurs pour situer l'action. + {{ 'arcEdit.mapsHint' | translate }}
- +
- +
- +
- +
- +
- +
- + - Ces notes sont privées et ne seront pas exportées vers FoundryVTT. + {{ 'arcEdit.gmNotesHint' | translate }}
- +
- +
@@ -147,7 +147,7 @@ @if (loreId) {
- + - Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée. + {{ 'arcEdit.relatedPagesHint' | translate }}
} @@ -163,8 +163,7 @@ @if (!loreId) {
- 💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne - pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.). + {{ 'arcEdit.noLoreHint' | translate }}
} @@ -179,7 +178,7 @@ entityType="arc" [entityId]="arcId" [isOpen]="chatOpen" - welcomeMessage="Je vois cet arc. Demande-moi d'enrichir ses thèmes, ses enjeux ou son dénouement." + [welcomeMessage]="'arcEdit.chatWelcome' | translate" [quickSuggestions]="chatQuickSuggestions" (close)="chatOpen = false"> diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index ad7923f..eb6ac97 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -34,7 +35,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-arc-edit', - imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent], + imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, TranslatePipe], templateUrl: './arc-edit.component.html', styleUrls: ['./arc-edit.component.scss'] }) @@ -46,11 +47,13 @@ export class ArcEditComponent implements OnInit, OnDestroy { /** État drawer chat IA (b5.7 — intégration Campagne). */ chatOpen = false; - readonly chatQuickSuggestions = [ - 'Propose 3 thèmes majeurs pour cet arc', - 'Imagine des enjeux qui mettent la pression sur les joueurs', - 'Suggère un dénouement en deux actes' - ]; + get chatQuickSuggestions(): string[] { + return [ + this.translate.instant('arcEdit.chatSuggestion1'), + this.translate.instant('arcEdit.chatSuggestion2'), + this.translate.instant('arcEdit.chatSuggestion3') + ]; + } toggleChat(): void { this.chatOpen = !this.chatOpen; } @@ -83,7 +86,8 @@ export class ArcEditComponent implements OnInit, OnDestroy { private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -149,7 +153,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { resolution: arc.resolution ?? '' }); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } @@ -178,10 +182,10 @@ export class ArcEditComponent implements OnInit, OnDestroy { delete(): void { this.confirmDialog.confirm({ - title: 'Supprimer l\'arc', - message: `Supprimer l'arc "${this.arc?.name}" ?`, - details: ['Cette action est irréversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('arcEdit.deleteTitle'), + message: this.translate.instant('arcEdit.deleteMessage', { name: this.arc?.name }), + details: [this.translate.instant('arcEdit.irreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.html b/web/src/app/campaigns/arc/arc-view/arc-view.component.html index ebca109..4b8338a 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.html +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.html @@ -9,17 +9,17 @@ {{ arc.name }}

- {{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }} + {{ (arc.type === 'HUB' ? 'arcView.subtitleHub' : 'arcView.subtitleLinear') | translate }}

-
@@ -32,7 +32,7 @@ @if ((arc.mapImageIds?.length ?? 0) > 0) {
-

🗺️ Cartes & plans

+

🗺️ {{ 'arcView.mapsTitle' | translate }}

} @@ -40,10 +40,10 @@ Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. --> @if (arc.type === 'HUB') {
-

🗺️ Quêtes du hub (scénario)

+

🗺️ {{ 'arcView.hubQuestsTitle' | translate }}

@if (hubQuests.length === 0) {

- Aucune quête pour ce hub. Créez-en une pour démarrer. + {{ 'arcView.hubQuestsEmpty' | translate }}

} @if (hubQuests.length > 0) { @@ -66,7 +66,7 @@ @if ((q.prerequisites?.length ?? 0) > 0) {
- {{ q.prerequisites!.length }} condition(s) de déblocage + {{ 'arcView.unlockConditions' | translate:{ n: q.prerequisites!.length } }}
    @for (p of q.prerequisites; track p) {
  • {{ describePrerequisite(p) }}
  • @@ -81,45 +81,45 @@
}
-

📜 Synopsis

+

📜 {{ 'arcView.synopsisTitle' | translate }}

@if (arc.description?.trim()) {

{{ arc.description }}

} @else { -

Non renseigné

+

{{ 'arcView.notProvided' | translate }}

}
-

Thèmes principaux

+

{{ 'arcView.themesTitle' | translate }}

@if (arc.themes?.trim()) {

{{ arc.themes }}

} @else { -

Non renseigné

+

{{ 'arcView.notProvided' | translate }}

}
-

⚖️ Enjeux globaux

+

⚖️ {{ 'arcView.stakesTitle' | translate }}

@if (arc.stakes?.trim()) {

{{ arc.stakes }}

} @else { -

Non renseigné

+

{{ 'arcView.notProvided' | translate }}

}
-

🎁 Récompenses et progression

+

🎁 {{ 'arcView.rewardsTitle' | translate }}

@if (arc.rewards?.trim()) {

{{ arc.rewards }}

} @else { -

Non renseigné

+

{{ 'arcView.notProvided' | translate }}

}
-

🎬 Dénouement prévu

+

🎬 {{ 'arcView.resolutionTitle' | translate }}

@if (arc.resolution?.trim()) {

{{ arc.resolution }}

} @else { -

Non renseigné

+

{{ 'arcView.notProvided' | translate }}

}
@@ -127,7 +127,7 @@

🔒 - Notes et planification du MJ + {{ 'arcView.gmNotesTitle' | translate }}

{{ arc.gmNotes }}

@@ -135,7 +135,7 @@ @if (loreId && (arc.relatedPageIds?.length ?? 0) > 0) {
-

🔗 Pages Lore associées

+

🔗 {{ 'arcView.relatedPagesTitle' | translate }}

@for (relId of arc.relatedPageIds; track relId) { { if (c.id) this.allChaptersById[c.id] = c; }) ); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } @@ -119,11 +121,11 @@ export class ArcViewComponent implements OnInit, OnDestroy { describePrerequisite(p: Prerequisite): string { switch (p.kind) { case 'QUEST_COMPLETED': - return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`; + return this.translate.instant('arcView.prereqQuestCompleted', { name: this.allChaptersById[p.questId]?.name ?? '?' }); case 'SESSION_REACHED': - return `Session ${p.minSessionNumber} atteinte`; + return this.translate.instant('arcView.prereqSessionReached', { n: p.minSessionNumber }); case 'FLAG_SET': - return `Fait : ${p.flagName}`; + return this.translate.instant('arcView.prereqFlagSet', { flag: p.flagName }); } } @@ -135,7 +137,7 @@ export class ArcViewComponent implements OnInit, OnDestroy { } titleOfRelated(pageId: string): string { - return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)'; + return this.availablePages.find(p => p.id === pageId)?.title ?? this.translate.instant('arcView.deletedPage'); } editMode(): void { @@ -153,20 +155,24 @@ export class ArcViewComponent implements OnInit, OnDestroy { this.campaignService.getArcDeletionImpact(arc.id!).subscribe({ next: impact => { const parts: string[] = []; - if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`); - if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`); + if (impact.chapters > 0) { + parts.push(this.translate.instant(impact.chapters > 1 ? 'arcView.chaptersPlural' : 'arcView.chaptersSingular', { n: impact.chapters })); + } + if (impact.scenes > 0) { + parts.push(this.translate.instant(impact.scenes > 1 ? 'arcView.scenesPlural' : 'arcView.scenesSingular', { n: impact.scenes })); + } const details: string[] = []; if (parts.length) { - details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`); + details.push(this.translate.instant('arcView.deleteCascade', { parts: parts.join(', ') })); } - details.push('Cette action est irréversible.'); + details.push(this.translate.instant('arcView.irreversible')); this.confirmDialog.confirm({ - title: 'Supprimer l\'arc', - message: `Supprimer l'arc "${arc.name}" ?`, + title: this.translate.instant('arcView.deleteConfirmTitle'), + message: this.translate.instant('arcView.deleteConfirmMessage', { name: arc.name }), details, - confirmLabel: 'Supprimer', + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index 9c988f3..658cc81 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -1,5 +1,6 @@ import { Observable, forkJoin, of } from 'rxjs'; import { switchMap, map } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../services/campaign.service'; import { CharacterService } from '../services/character.service'; import { NpcService } from '../services/npc.service'; @@ -92,7 +93,7 @@ export function loadCampaignTreeData( ); } -export function buildCampaignTree(campaignId: string, data: CampaignTreeData): TreeItem[] { +export function buildCampaignTree(campaignId: string, data: CampaignTreeData, translate: TranslateService): TreeItem[] { // Tri FR avec `numeric: true` pour que "1. Intro", "2. Voyage", "10. Final" soient // classés 1, 2, 10 (et pas 1, 10, 2). `sensitivity: 'base'` ignore la casse. const byName = (a: { name: string }, b: { name: string }) => @@ -140,7 +141,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T const npcsNode: TreeItem = { id: 'npcs-root', - label: 'PNJ', + label: translate.instant('campaignTree.npcs'), iconKey: 'c-drama', children: npcChildren, meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined, @@ -149,10 +150,10 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T route: `/campaigns/${campaignId}/npcs`, // Porte le header de section "Personnages" (les PJ ayant migré vers la Partie). // Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar. - sectionHeaderBefore: 'Personnages', + sectionHeaderBefore: translate.instant('campaignTree.sectionCharacters'), createActions: [{ id: 'new-npc', - label: 'Nouveau PNJ', + label: translate.instant('campaignTree.newNpc'), route: `/campaigns/${campaignId}/npcs/create`, actionIcon: 'plus' }] @@ -216,7 +217,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`, createActions: [{ id: `new-scene-${ch.id}`, - label: 'Nouvelle scène', + label: translate.instant('campaignTree.newScene'), route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`, actionIcon: 'plus' }] @@ -228,12 +229,12 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T iconKey: arc.icon ?? undefined, children: chapterItems, route: `/campaigns/${campaignId}/arcs/${arc.id}`, - sectionHeaderBefore: idx === 0 ? 'Narration' : undefined, + sectionHeaderBefore: idx === 0 ? translate.instant('campaignTree.sectionNarration') : undefined, createActions: [{ id: `new-chapter-${arc.id}`, // Dans un arc hub, un "chapitre" est présenté comme une "quête". - label: arc.type === 'HUB' ? 'Nouvelle quête' : 'Nouveau chapitre', + label: arc.type === 'HUB' ? translate.instant('campaignTree.newQuest') : translate.instant('campaignTree.newChapter'), route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`, actionIcon: 'plus' }] @@ -250,14 +251,14 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T const tablesNode: TreeItem = { id: 'random-tables-root', - label: 'Tables aléatoires', + label: translate.instant('campaignTree.randomTables'), iconKey: 'dice', children: tableItems, meta: tableItems.length ? String(tableItems.length) : undefined, - sectionHeaderBefore: 'Outils', + sectionHeaderBefore: translate.instant('campaignTree.sectionTools'), createActions: [{ id: 'new-random-table', - label: 'Nouvelle table', + label: translate.instant('campaignTree.newTable'), route: `/campaigns/${campaignId}/random-tables/create`, actionIcon: 'plus' }] @@ -266,7 +267,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T // Lien simple vers les ateliers (la liste se charge sur sa page — pas de fetch ici). const notebooksNode: TreeItem = { id: 'notebooks-root', - label: 'Ateliers (IA + PDF)', + label: translate.instant('campaignTree.notebooks'), iconKey: 'book-open', route: `/campaigns/${campaignId}/notebooks` }; @@ -274,7 +275,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T // Catalogues d'objets (boutiques, butins…) → page de liste (outil). const catalogsNode: TreeItem = { id: 'item-catalogs-root', - label: 'Catalogues d\'objets', + label: translate.instant('campaignTree.itemCatalogs'), iconKey: 'package', route: `/campaigns/${campaignId}/item-catalogs` }; @@ -284,14 +285,14 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T // Libellé → page de liste ; chevron → arbre dépliable (dossiers → fiches). const enemiesNode: TreeItem = { id: 'enemies-root', - label: 'Ennemis', + label: translate.instant('campaignTree.enemies'), iconKey: 'skull', children: enemyChildren, meta: sortedEnemies.length ? String(sortedEnemies.length) : undefined, route: `/campaigns/${campaignId}/enemies`, createActions: [{ id: 'new-enemy', - label: 'Nouvel ennemi', + label: translate.instant('campaignTree.newEnemy'), route: `/campaigns/${campaignId}/enemies/create`, actionIcon: 'plus' }] @@ -300,7 +301,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T // Importer un PDF de campagne → arborescence (outil, comme tables & ateliers). const importNode: TreeItem = { id: 'import-pdf-root', - label: 'Importer un PDF', + label: translate.instant('campaignTree.importPdf'), iconKey: 'file-up', route: `/campaigns/${campaignId}/import` }; @@ -322,7 +323,8 @@ export function buildCampaignSidebarConfig( campaign: Campaign, allCampaigns: Campaign[], treeData: CampaignTreeData, - campaignId: string + campaignId: string, + translate: TranslateService ): SecondarySidebarConfig { const globalItems: GlobalItem[] = allCampaigns.map(c => ({ id: c.id!, name: c.name, route: `/campaigns/${c.id}` @@ -331,13 +333,13 @@ export function buildCampaignSidebarConfig( title: campaign.name, // Titre cliquable → accueil de la campagne (raccourci depuis n'importe quelle sous-page). titleRoute: `/campaigns/${campaignId}`, - items: buildCampaignTree(campaignId, treeData), - footerLabel: 'Toutes les campagnes', + items: buildCampaignTree(campaignId, treeData, translate), + footerLabel: translate.instant('campaignTree.allCampaigns'), createActions: [ - { id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` } + { id: 'create-arc', label: translate.instant('campaignTree.newArc'), variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` } ], globalItems, - globalBackLabel: 'Toutes les campagnes', + globalBackLabel: translate.instant('campaignTree.allCampaigns'), globalBackRoute: '/campaigns' }; } diff --git a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html index dde4cff..231838c 100644 --- a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html +++ b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html @@ -2,7 +2,7 @@
-

💡 Organisation : Votre campagne sera structurée en :

+

    -
  • Arcs - Les grandes phases narratives
  • -
  • Chapitres - Les segments d'un arc
  • -
  • Scènes - Les moments de jeu individuels
  • +
  • +
  • +
diff --git a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts index 801acd0..abf5760 100644 --- a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts +++ b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts @@ -3,6 +3,7 @@ import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; import { LoreService } from '../../../services/lore.service'; import { Lore } from '../../../services/lore.model'; import { GameSystemService } from '../../../services/game-system.service'; @@ -22,7 +23,7 @@ export interface CampaignCreatePayload { @Component({ selector: 'app-campaign-create', - imports: [ReactiveFormsModule, FormsModule, LucideAngularModule], + imports: [ReactiveFormsModule, FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './campaign-create.component.html', styleUrls: ['./campaign-create.component.scss'] }) diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index ae505b5..458facb 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -7,22 +7,22 @@

{{ campaign.name }}

{{ campaign.description }}

@@ -30,11 +30,11 @@
@@ -43,34 +43,34 @@ @if (editing) {
- +
- +
- +
- + @if (!creatingGameSystem) { } @if (creatingGameSystem) { @@ -79,7 +79,7 @@ type="text" [(ngModel)]="newGameSystemName" name="newGameSystemName" - placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)" + [placeholder]="'campaignDetail.gameSystemNamePlaceholder' | translate" (keydown.enter)="$event.preventDefault(); submitCreateGameSystem()" (keydown.escape)="cancelCreateGameSystem()" autofocus @@ -89,10 +89,10 @@ [disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight" (click)="submitCreateGameSystem()"> - Créer + {{ 'common.create' | translate }}
@@ -100,10 +100,10 @@
@@ -111,7 +111,7 @@ @if (!editing) {
-

Personnages

+

{{ 'campaignDetail.charactersTitle' | translate }}

@@ -18,7 +15,7 @@ @if (importing) { @@ -36,7 +33,7 @@ } @if (importCounts) {

- Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ + {{ 'campaignImport.foundSoFar' | translate:{ arcs: importCounts.arcs, chapters: importCounts.chapters, scenes: importCounts.scenes, npcs: importCounts.npcs } }}

} @@ -52,12 +49,7 @@

- À créer : {{ arcCount }} nouvel(s) arc(s), - {{ chapterCount }} chapitre(s), - {{ sceneCount }} scène(s)@if (npcs.length > 0) {, - {{ npcCount }} PNJ}. - Les éléments « déjà présent » (grisés) sont affichés pour situer les ajouts ; - ils ne seront pas recréés. Modifiez les nouveaux, puis créez. + @if (npcs.length > 0) {}

@@ -70,12 +62,12 @@ + [readonly]="arc.existing" [placeholder]="'campaignImport.arcNamePlaceholder' | translate" /> @if (arc.existing) { - déjà présent + {{ 'campaignImport.alreadyPresent' | translate }} } @if (!arc.existing) { - } @@ -84,16 +76,16 @@
@if (!arc.existing) {
- Type : + {{ 'campaignImport.typeLabel' | translate }} + (click)="setArcType(arc, 'LINEAR')">{{ 'campaignImport.typeLinear' | translate }} + (click)="setArcType(arc, 'HUB')">{{ 'campaignImport.typeHub' | translate }}
} @if (!arc.existing) { + rows="2" [placeholder]="'campaignImport.arcSynopsisPlaceholder' | translate"> } @for (chapter of arc.chapters; track chapter; let ci = $index) { @@ -104,12 +96,12 @@ + [readonly]="chapter.existing" [placeholder]="'campaignImport.chapterNamePlaceholder' | translate" /> @if (chapter.existing) { - déjà présent + {{ 'campaignImport.alreadyPresent' | translate }} } @if (!chapter.existing) { - } @@ -118,7 +110,7 @@
@if (!chapter.existing) { + rows="2" [placeholder]="'campaignImport.chapterSynopsisPlaceholder' | translate"> } @for (scene of chapter.scenes; track scene; let si = $index) { @@ -129,60 +121,60 @@
+ [placeholder]="'campaignImport.sceneNamePlaceholder' | translate" /> @if (!scene.existing) { + [name]="'scene-desc-' + ai + '-' + ci + '-' + si" [placeholder]="'campaignImport.synopsisOptionalPlaceholder' | translate" /> }
@if (scene.existing) { - déjà présent + {{ 'campaignImport.alreadyPresent' | translate }} } @if (!scene.existing) { } @if (!scene.existing) { - }
@if (scene.detailsOpen && !scene.existing) {
- + - + [placeholder]="'campaignImport.playerNarrationPlaceholder' | translate"> + + [placeholder]="'campaignImport.gmNotesPlaceholder' | translate"> - Pièces (lieu explorable) + {{ 'campaignImport.roomsLabel' | translate }}
@for (room of scene.rooms; track room; let ri = $index) {
+ [name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'campaignImport.roomPlaceholder' | translate" /> + [name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'common.description' | translate" /> + [name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'campaignImport.enemiesPlaceholder' | translate" /> -
}
@@ -190,32 +182,29 @@
}
} } } } @if (npcs.length > 0) {
-

PNJ et créatures détectés ({{ npcs.length }})

-

- Cochez ceux à créer dans la campagne (description issue du livre). - Ceux déjà présents dans la campagne sont grisés et ne seront pas recréés. -

+

{{ 'campaignImport.npcReviewTitle' | translate:{ n: npcs.length } }}

+

{{ 'campaignImport.npcReviewHint' | translate }}

    @for (npc of npcs; track npc.name) {
  • @@ -223,7 +212,7 @@ {{ npc.name }} @if (npc.existing) { - déjà présent + {{ 'campaignImport.alreadyPresent' | translate }} } @if (npc.description) { @@ -241,9 +230,9 @@
    - +
} diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts index d61f902..480d8af 100644 --- a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts @@ -6,6 +6,7 @@ import { LucideAngularModule, ArrowLeft, Upload, Plus, Trash2, ChevronDown, ChevronRight, Swords, BookOpen, MapPin, Check } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignImportService } from '../../../services/campaign-import.service'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; @@ -53,7 +54,7 @@ interface NpcNode { name: string; description: string; selected: boolean; existi */ @Component({ selector: 'app-campaign-import', - imports: [FormsModule, LucideAngularModule], + imports: [FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './campaign-import.component.html', styleUrls: ['./campaign-import.component.scss'] }) @@ -109,12 +110,13 @@ export class CampaignImportComponent implements OnInit { private randomTableService: RandomTableService, private enemyService: EnemyService, private campaignSidebar: CampaignSidebarService, - private pageTitle: PageTitleService + private pageTitle: PageTitleService, + private translate: TranslateService ) {} ngOnInit(): void { this.campaignId = this.route.snapshot.paramMap.get('campaignId')!; - this.pageTitle.set('Importer une campagne'); + this.pageTitle.set(this.translate.instant('campaignImport.pageTitle')); this.campaignSidebar.show(this.campaignId); // Pré-chargement de l'arborescence existante (pour fusionner à la revue). @@ -138,7 +140,7 @@ export class CampaignImportComponent implements OnInit { this.reviewing = false; this.importError = null; this.applyError = null; - this.importPhase = 'Extraction du texte…'; + this.importPhase = this.translate.instant('campaignImport.phaseExtracting'); this.importProgress = null; this.importStatus = null; this.importCounts = null; @@ -150,10 +152,10 @@ export class CampaignImportComponent implements OnInit { // Un morceau vient d'aboutir : le message d'attente est obsolète. this.importStatus = null; if (ev.total === 0) { - this.importPhase = 'Extraction du texte…'; + this.importPhase = this.translate.instant('campaignImport.phaseExtracting'); this.importProgress = null; } else { - this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`; + this.importPhase = this.translate.instant('campaignImport.phaseAnalyzing', { current: ev.current, total: ev.total }); this.importProgress = { current: ev.current, total: ev.total }; this.importCounts = { arcs: ev.arcCount, chapters: ev.chapterCount, @@ -168,7 +170,7 @@ export class CampaignImportComponent implements OnInit { this.importProgress = null; this.importStatus = null; if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) { - this.importError = "Aucune structure narrative détectée dans ce PDF."; + this.importError = this.translate.instant('campaignImport.errorNoStructure'); this.reviewing = false; } else { this.tree = this.buildMergedTree(ev.arcs ?? []); @@ -181,7 +183,9 @@ export class CampaignImportComponent implements OnInit { this.importing = false; this.importPhase = ''; this.importProgress = null; - this.importError = err?.message ? `Échec de l'import : ${err.message}` : "Échec de l'import du PDF."; + this.importError = err?.message + ? this.translate.instant('campaignImport.errorImportDetail', { message: err.message }) + : this.translate.instant('campaignImport.errorImport'); } }); } @@ -413,7 +417,7 @@ export class CampaignImportComponent implements OnInit { next: () => this.router.navigate(['/campaigns', this.campaignId]), error: () => { this.applying = false; - this.applyError = "Échec de la création. La campagne existe-t-elle toujours ?"; + this.applyError = this.translate.instant('campaignImport.errorApply'); } }); } diff --git a/web/src/app/campaigns/campaigns.component.html b/web/src/app/campaigns/campaigns.component.html index 9cd722e..1381fa3 100644 --- a/web/src/app/campaigns/campaigns.component.html +++ b/web/src/app/campaigns/campaigns.component.html @@ -2,8 +2,8 @@
-

Vos Campagnes

-

Rejoignez une campagne ou créez-en de nouvelles

+

{{ 'campaigns.heroTitle' | translate }}

+

{{ 'campaigns.heroSubtitle' | translate }}

@@ -11,14 +11,14 @@ @for (campaign of campaigns; track campaign) {
- En cours - {{ campaign.playerCount }} joueurs + {{ 'campaigns.statusInProgress' | translate }} + {{ 'campaigns.players' | translate:{ n: campaign.playerCount || 0 } }}

{{ campaign.name }}

{{ campaign.description }}

- ⚔️ {{ campaign.arcCount || 0 }} arcs - 📖 {{ campaign.chapterCount || 0 }} chapitres + ⚔️ {{ 'campaigns.arcs' | translate:{ n: campaign.arcCount || 0 } }} + 📖 {{ 'campaigns.chapters' | translate:{ n: campaign.chapterCount || 0 } }}
} @@ -27,13 +27,13 @@
-

Nouvelle Campagne

-

Créez une nouvelle aventure

+

{{ 'campaigns.newCampaign' | translate }}

+

{{ 'campaigns.newCampaignSubtitle' | translate }}

-

💡 Astuce : Organisez vos arcs et chapitres pour ne rien oublier de vos aventures

+

{{ 'campaigns.tip' | translate }}

diff --git a/web/src/app/campaigns/campaigns.component.ts b/web/src/app/campaigns/campaigns.component.ts index bafe5b1..992c040 100644 --- a/web/src/app/campaigns/campaigns.component.ts +++ b/web/src/app/campaigns/campaigns.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { LucideAngularModule, Map, Plus } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; import { CampaignService } from '../services/campaign.service'; import { LayoutService } from '../services/layout.service'; import { Campaign } from '../services/campaign.model'; @@ -9,7 +10,7 @@ import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campa @Component({ selector: 'app-campaigns', - imports: [LucideAngularModule, CampaignCreateComponent], + imports: [LucideAngularModule, CampaignCreateComponent, TranslatePipe], templateUrl: './campaigns.component.html', styleUrls: ['./campaigns.component.scss'] }) diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html index c445129..aee61b2 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html @@ -1,45 +1,45 @@
- +
- +
- +
- +
diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts index 837cd6a..6ae534b 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts @@ -4,6 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -20,7 +21,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; */ @Component({ selector: 'app-chapter-create', - imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent], + imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent, TranslatePipe], templateUrl: './chapter-create.component.html', styleUrls: ['./chapter-create.component.scss'] }) @@ -45,7 +46,8 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { private npcService: NpcService, private randomTableService: RandomTableService, private enemyService: EnemyService, - private layoutService: LayoutService + private layoutService: LayoutService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -70,7 +72,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { this.isHub = currentArc?.type === 'HUB'; this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0; - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html index cab487d..9c8305f 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html @@ -2,24 +2,24 @@ @@ -30,13 +30,10 @@ comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->

- {{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }} + {{ (parentArc?.type === 'HUB' ? 'chapterEdit.unlockTitleQuest' : 'chapterEdit.unlockTitleChapter') | translate }}

- Définition du scénario : toutes les conditions doivent être remplies (ET) pour que - {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} soit débloqué(e) dans une Partie. - Laissez vide pour qu'il soit disponible dès le départ. La progression et l'état des - faits se gèrent dans l'écran de la Partie, pas ici. + {{ (parentArc?.type === 'HUB' ? 'chapterEdit.unlockHintQuest' : 'chapterEdit.unlockHintChapter') | translate }}
- + - Portraits, ambiances, scenes marquantes du chapitre. + {{ 'chapterEdit.illustrationsHint' | translate }}
- + - Cartes regionales, plans de donjon, schemas utiles a la table. + {{ 'chapterEdit.mapsHint' | translate }}
- +
- +
- +
- + - Ces notes sont privées et ne seront pas exportées vers FoundryVTT. + {{ 'chapterEdit.gmNotesHint' | translate }}
- +
- +
@@ -131,7 +128,7 @@ @if (loreId) {
- + - Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent. + {{ 'chapterEdit.relatedPagesHint' | translate }}
} @@ -147,8 +144,7 @@ @if (!loreId) {
- 💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne - pour pouvoir lier ce chapitre à des pages du Lore. + {{ 'chapterEdit.noLoreHint' | translate }}
} @@ -163,7 +159,7 @@ entityType="chapter" [entityId]="chapterId" [isOpen]="chatOpen" - welcomeMessage="Je vois ce chapitre. Demande-moi d'étoffer ses objectifs, ses enjeux ou sa scène d'ouverture." + [welcomeMessage]="'chapterEdit.chatWelcome' | translate" [quickSuggestions]="chatQuickSuggestions" (close)="chatOpen = false"> diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts index c43117e..c872441 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts @@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -41,7 +42,8 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, - PrerequisiteEditorComponent + PrerequisiteEditorComponent, + TranslatePipe ], templateUrl: './chapter-edit.component.html', styleUrls: ['./chapter-edit.component.scss'] @@ -53,11 +55,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy { selectedIcon: string | null = null; chatOpen = false; - readonly chatQuickSuggestions = [ - 'Propose des objectifs clairs pour les joueurs dans ce chapitre', - 'Imagine 2 tensions narratives qui relancent l\'intérêt en milieu de chapitre', - 'Suggère une scène d\'ouverture marquante' - ]; + readonly chatQuickSuggestions: string[]; toggleChat(): void { this.chatOpen = !this.chatOpen; } @@ -98,7 +96,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy { private layoutService: LayoutService, private pageTitleService: PageTitleService, private confirmDialog: ConfirmDialogService, - private campaignFlagService: CampaignFlagService + private campaignFlagService: CampaignFlagService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -107,6 +106,11 @@ export class ChapterEditComponent implements OnInit, OnDestroy { playerObjectives: [''], narrativeStakes: [''] }); + this.chatQuickSuggestions = [ + this.translate.instant('chapterEdit.chatSuggestion1'), + this.translate.instant('chapterEdit.chatSuggestion2'), + this.translate.instant('chapterEdit.chatSuggestion3') + ]; } ngOnInit(): void { @@ -169,7 +173,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy { narrativeStakes: chapter.narrativeStakes ?? '' }); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } @@ -200,10 +204,10 @@ export class ChapterEditComponent implements OnInit, OnDestroy { delete(): void { this.confirmDialog.confirm({ - title: 'Supprimer le chapitre', - message: `Supprimer le chapitre "${this.chapter?.name}" ?`, - details: ['Cette action est irréversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('chapterEdit.deleteTitle'), + message: this.translate.instant('chapterEdit.deleteMessage', { name: this.chapter?.name }), + details: [this.translate.instant('chapterEdit.irreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html index 6476406..909cdc7 100644 --- a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html @@ -2,18 +2,18 @@ @if (scenes.length === 0) {
-

Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.

+

{{ 'chapterGraph.empty' | translate }}

} @@ -72,7 +72,7 @@ - 💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas. + {{ 'chapterGraph.hint' | translate }}
} diff --git a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts index 565f679..ceb0169 100644 --- a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/co import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -22,7 +23,7 @@ interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: nu */ @Component({ selector: 'app-chapter-graph', - imports: [RouterModule, LucideAngularModule], + imports: [RouterModule, LucideAngularModule, TranslatePipe], templateUrl: './chapter-graph.component.html', styleUrls: ['./chapter-graph.component.scss'] }) @@ -74,7 +75,8 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { private randomTableService: RandomTableService, private enemyService: EnemyService, private layoutService: LayoutService, - private pageTitleService: PageTitleService + private pageTitleService: PageTitleService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -96,7 +98,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { }).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => { this.chapter = chapter; this.scenes = scenes; - this.pageTitleService.set(`${chapter.name} — Carte`); + this.pageTitleService.set(this.translate.instant('chapterGraph.title', { name: chapter.name })); this.buildGraph(); const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({ @@ -104,11 +106,11 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { })); this.layoutService.show({ title: campaign.name, - items: buildCampaignTree(this.campaignId, treeData), - footerLabel: 'Toutes les campagnes', + items: buildCampaignTree(this.campaignId, treeData, this.translate), + footerLabel: this.translate.instant('chapterGraph.allCampaigns'), createActions: [], globalItems, - globalBackLabel: 'Toutes les campagnes', + globalBackLabel: this.translate.instant('chapterGraph.allCampaigns'), globalBackRoute: '/campaigns' }); }); diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html index 704eb8b..4307993 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html @@ -9,29 +9,29 @@ {{ chapter.name }}

- {{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }} + {{ (parentArc?.type === 'HUB' ? 'chapterView.questHub' : 'chapterView.chapter') | translate }} @if ((chapter.prerequisites?.length ?? 0) > 0) { + [title]="'chapterView.conditionalBadgeTitle' | translate"> - Conditionnel + {{ 'chapterView.conditional' | translate }} }

-
@@ -39,7 +39,7 @@ OU dès qu'un chapitre linéaire porte des conditions. --> @if (parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0) {
-

🔒 Conditions de déblocage

+

🔒 {{ 'chapterView.unlockConditions' | translate }}

@if ((chapter.prerequisites?.length ?? 0) > 0) {
    @for (p of chapter.prerequisites; track p) { @@ -47,17 +47,13 @@ }
- Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque - dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran - « Faits » de la Partie. + {{ (parentArc?.type === 'HUB' ? 'chapterView.unlockHintQuest' : 'chapterView.unlockHintChapter') | translate }} } @else {

- Aucune condition — cette quête est disponible dès le début dans chaque Partie. + {{ 'chapterView.noConditionQuest' | translate }}

-

- Cliquez sur Modifier pour ajouter des conditions - (« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »). +

}
@@ -71,33 +67,33 @@ @if ((chapter.mapImageIds?.length ?? 0) > 0) {
-

🗺️ Cartes & plans

+

🗺️ {{ 'chapterView.maps' | translate }}

}
-

📖 Synopsis

+

📖 {{ 'chapterView.synopsis' | translate }}

@if (chapter.description?.trim()) {

{{ chapter.description }}

} @else { -

Non renseigné

+

{{ 'chapterView.notProvided' | translate }}

}
-

🎯 Objectifs des joueurs

+

🎯 {{ 'chapterView.playerObjectives' | translate }}

@if (chapter.playerObjectives?.trim()) {

{{ chapter.playerObjectives }}

} @else { -

Non renseigné

+

{{ 'chapterView.notProvided' | translate }}

}
-

Enjeux narratifs

+

{{ 'chapterView.narrativeStakes' | translate }}

@if (chapter.narrativeStakes?.trim()) {

{{ chapter.narrativeStakes }}

} @else { -

Non renseigné

+

{{ 'chapterView.notProvided' | translate }}

}
@@ -105,14 +101,14 @@

🔒 - Notes du Maître de Jeu + {{ 'chapterView.gmNotes' | translate }}

{{ chapter.gmNotes }}

} @if (loreId && (chapter.relatedPageIds?.length ?? 0) > 0) {
-

🔗 Pages Lore associées

+

🔗 {{ 'chapterView.relatedPages' | translate }}

@for (relId of chapter.relatedPageIds; track relId) { { if (c.id) this.allChaptersById[c.id] = c; }) ); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } describePrerequisite(p: Prerequisite): string { switch (p.kind) { case 'QUEST_COMPLETED': - return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`; + return this.translate.instant('chapterView.prereqQuestCompleted', { + name: this.allChaptersById[p.questId]?.name ?? '?' + }); case 'SESSION_REACHED': - return `Session ${p.minSessionNumber} atteinte`; + return this.translate.instant('chapterView.prereqSessionReached', { n: p.minSessionNumber }); case 'FLAG_SET': - return `Fait : ${p.flagName}`; + return this.translate.instant('chapterView.prereqFlagSet', { flag: p.flagName }); } } titleOfRelated(pageId: string): string { - return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)'; + return this.availablePages.find(p => p.id === pageId)?.title + ?? this.translate.instant('chapterView.deletedPage'); } editMode(): void { @@ -143,15 +148,16 @@ export class ChapterViewComponent implements OnInit, OnDestroy { next: impact => { const details: string[] = []; if (impact.scenes > 0) { - details.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`); + const key = impact.scenes > 1 ? 'chapterView.deleteScenesPlural' : 'chapterView.deleteScenes'; + details.push(this.translate.instant(key, { n: impact.scenes })); } - details.push('Cette action est irréversible.'); + details.push(this.translate.instant('chapterView.irreversible')); this.confirmDialog.confirm({ - title: 'Supprimer le chapitre', - message: `Supprimer le chapitre "${chapter.name}" ?`, + title: this.translate.instant('chapterView.deleteChapterTitle'), + message: this.translate.instant('chapterView.deleteChapterMessage', { name: chapter.name }), details, - confirmLabel: 'Supprimer', + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.html b/web/src/app/campaigns/character/character-edit/character-edit.component.html index 63022ba..0492ee3 100644 --- a/web/src/app/campaigns/character/character-edit/character-edit.component.html +++ b/web/src/app/campaigns/character/character-edit/character-edit.component.html @@ -3,12 +3,12 @@

- {{ characterId ? 'Éditer la fiche' : 'Nouveau personnage' }} + {{ (characterId ? 'characterEdit.titleEdit' : 'characterEdit.titleNew') | translate }}

@if (characterId) { }
@@ -27,32 +27,32 @@
- +
- +
- +
@@ -73,9 +73,9 @@
- + @if (characterId) { }
@@ -98,7 +98,7 @@ entityType="character" [entityId]="characterId" [isOpen]="chatOpen" - welcomeMessage="Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels." + [welcomeMessage]="'characterEdit.chatWelcome' | translate" [quickSuggestions]="chatQuickSuggestions" (close)="chatOpen = false"> diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.ts b/web/src/app/campaigns/character/character-edit/character-edit.component.ts index ee974e1..489beea 100644 --- a/web/src/app/campaigns/character/character-edit/character-edit.component.ts +++ b/web/src/app/campaigns/character/character-edit/character-edit.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CharacterService } from '../../../services/character.service'; import { CampaignService } from '../../../services/campaign.service'; import { GameSystemService } from '../../../services/game-system.service'; @@ -26,7 +27,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-character-edit', - imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent], + imports: [FormsModule, LucideAngularModule, TranslatePipe, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent], templateUrl: './character-edit.component.html', styleUrls: ['./character-edit.component.scss'] }) @@ -38,11 +39,13 @@ export class CharacterEditComponent implements OnInit { readonly Sparkles = Sparkles; chatOpen = false; - readonly chatQuickSuggestions = [ - 'Propose une backstory coherente avec l\'univers', - 'Suggere 3 objectifs personnels pour ce personnage', - 'Aide-moi a equilibrer les stats de combat' - ]; + get chatQuickSuggestions(): string[] { + return [ + this.translate.instant('characterEdit.chatSuggestion1'), + this.translate.instant('characterEdit.chatSuggestion2'), + this.translate.instant('characterEdit.chatSuggestion3') + ]; + } toggleChat(): void { this.chatOpen = !this.chatOpen; } @@ -67,7 +70,8 @@ export class CharacterEditComponent implements OnInit { private campaignService: CampaignService, private gameSystemService: GameSystemService, private campaignSidebar: CampaignSidebarService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -144,10 +148,10 @@ export class CharacterEditComponent implements OnInit { deleteCharacter(): void { if (!this.characterId) return; this.confirmDialog.confirm({ - title: 'Supprimer la fiche ?', - message: `Supprimer la fiche de "${this.name}" ?`, - details: ['Cette action est irreversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('characterEdit.deleteTitle'), + message: this.translate.instant('characterEdit.deleteMessage', { name: this.name }), + details: [this.translate.instant('characterEdit.irreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok || !this.characterId) return; diff --git a/web/src/app/campaigns/character/character-view/character-view.component.html b/web/src/app/campaigns/character/character-view/character-view.component.html index 26dedfb..773a3a0 100644 --- a/web/src/app/campaigns/character/character-view/character-view.component.html +++ b/web/src/app/campaigns/character/character-view/character-view.component.html @@ -2,18 +2,18 @@
@if (characterId) { }
diff --git a/web/src/app/campaigns/character/character-view/character-view.component.ts b/web/src/app/campaigns/character/character-view/character-view.component.ts index 723edd3..dd83e38 100644 --- a/web/src/app/campaigns/character/character-view/character-view.component.ts +++ b/web/src/app/campaigns/character/character-view/character-view.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; import { CharacterService } from '../../../services/character.service'; import { CampaignService } from '../../../services/campaign.service'; import { GameSystemService } from '../../../services/game-system.service'; @@ -17,7 +18,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr */ @Component({ selector: 'app-character-view', - imports: [LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent], + imports: [LucideAngularModule, TranslatePipe, PersonaViewComponent, AiChatDrawerComponent], templateUrl: './character-view.component.html', styleUrls: ['./character-view.component.scss'] }) diff --git a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html index ee9a257..01a49db 100644 --- a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html +++ b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html @@ -3,12 +3,12 @@

- {{ enemyId ? 'Éditer l\'ennemi' : 'Nouvel ennemi' }} + {{ (enemyId ? 'enemyEdit.titleEdit' : 'enemyEdit.titleNew') | translate }}

@@ -16,36 +16,36 @@
- +
- +
- + @for (f of existingFolders; track f) { @@ -57,20 +57,20 @@
- +
- +
@@ -91,9 +91,9 @@
- + @if (enemyId) { }
diff --git a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts index 953f21b..b01c02d 100644 --- a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts +++ b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Save, ArrowLeft, Skull, Trash2 } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EnemyService } from '../../../services/enemy.service'; import { CampaignService } from '../../../services/campaign.service'; import { GameSystemService } from '../../../services/game-system.service'; @@ -19,7 +20,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-enemy-edit', - imports: [FormsModule, LucideAngularModule, DynamicFieldsFormComponent, SingleImagePickerComponent], + imports: [FormsModule, LucideAngularModule, TranslatePipe, DynamicFieldsFormComponent, SingleImagePickerComponent], templateUrl: './enemy-edit.component.html', styleUrls: ['./enemy-edit.component.scss'] }) @@ -52,7 +53,8 @@ export class EnemyEditComponent implements OnInit { private campaignService: CampaignService, private gameSystemService: GameSystemService, private campaignSidebar: CampaignSidebarService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -143,10 +145,10 @@ export class EnemyEditComponent implements OnInit { deleteEnemy(): void { if (!this.enemyId) return; this.confirmDialog.confirm({ - title: 'Supprimer la fiche ?', - message: `Supprimer la fiche de "${this.name}" ?`, - details: ['Cette action est irreversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('enemyEdit.deleteTitle'), + message: this.translate.instant('enemyEdit.deleteMessage', { name: this.name }), + details: [this.translate.instant('enemyEdit.irreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok || !this.enemyId) return; diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html index de78560..58bc7a2 100644 --- a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html @@ -1,25 +1,24 @@
-

Ennemis

+

{{ 'enemyList.title' | translate }}

- Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le - template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…). + {{ 'enemyList.hint' | translate }}

@if (total === 0) { -

Aucun ennemi pour l'instant.

+

{{ 'enemyList.empty' | translate }}

} @for (g of groups; track g.folder) { @if (g.folder) { @@ -29,16 +28,16 @@ {{ g.enemies.length }} } @else if (groups.length > 1) { -

Non classés

+

{{ 'enemyList.unclassified' | translate }}

} @for (e of g.enemies; track e.id) { diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts index dcd06b3..d9d6a05 100644 --- a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EnemyService } from '../../../services/enemy.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { Enemy } from '../../../services/enemy.model'; @@ -19,7 +20,7 @@ interface FolderGroup { */ @Component({ selector: 'app-enemy-list', - imports: [LucideAngularModule], + imports: [LucideAngularModule, TranslatePipe], templateUrl: './enemy-list.component.html', styleUrls: ['./enemy-list.component.scss'] }) @@ -40,7 +41,8 @@ export class EnemyListComponent implements OnInit { private router: Router, private service: EnemyService, private campaignSidebar: CampaignSidebarService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -91,9 +93,9 @@ export class EnemyListComponent implements OnInit { remove(e: Enemy, ev: Event): void { ev.stopPropagation(); this.confirmDialog.confirm({ - title: 'Supprimer l\'ennemi', - message: `Supprimer « ${e.name} » ?`, - confirmLabel: 'Supprimer', + title: this.translate.instant('enemyList.deleteTitle'), + message: this.translate.instant('enemyList.deleteMessage', { name: e.name }), + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html index 88deb4d..9ac6474 100644 --- a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html @@ -2,12 +2,12 @@
diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts index 5532117..1c5b5b1 100644 --- a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts @@ -3,6 +3,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { LucideAngularModule, ArrowLeft, Edit3 } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EnemyService } from '../../../services/enemy.service'; import { CampaignService } from '../../../services/campaign.service'; import { GameSystemService } from '../../../services/game-system.service'; @@ -17,7 +18,7 @@ import { PersonaViewComponent } from '../../../shared/persona-view/persona-view. */ @Component({ selector: 'app-enemy-view', - imports: [LucideAngularModule, PersonaViewComponent], + imports: [LucideAngularModule, TranslatePipe, PersonaViewComponent], templateUrl: './enemy-view.component.html', styleUrls: ['./enemy-view.component.scss'] }) @@ -39,7 +40,8 @@ export class EnemyViewComponent implements OnInit, OnDestroy { private service: EnemyService, private campaignService: CampaignService, private gameSystemService: GameSystemService, - private campaignSidebar: CampaignSidebarService + private campaignSidebar: CampaignSidebarService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -78,7 +80,7 @@ export class EnemyViewComponent implements OnInit, OnDestroy { /** Sous-titre de la fiche : niveau + dossier (« Niveau 8 · Démons »). */ get subtitle(): string { const parts: string[] = []; - if (this.enemy?.level) parts.push(`Niveau ${this.enemy.level}`); + if (this.enemy?.level) parts.push(this.translate.instant('enemyView.levelLong', { level: this.enemy.level })); if (this.enemy?.folder) parts.push(this.enemy.folder); return parts.join(' · '); } diff --git a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html index a93c082..6fb36fd 100644 --- a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html +++ b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html @@ -1,41 +1,41 @@
-

{{ catalogId ? 'Éditer le catalogue' : 'Nouveau catalogue d\'objets' }}

+

{{ (catalogId ? 'itemCatalogEdit.editTitle' : 'itemCatalogEdit.createTitle') | translate }}

@if (errorMessage) {
{{ errorMessage }}
}
- - + +
- - + +
-
Générer avec l'IA
-

Décris la boutique/le butin ; l'IA propose les objets (revois-les avant d'enregistrer). Contextualisé avec ta campagne.

+
{{ 'itemCatalogEdit.aiTitle' | translate }}
+

{{ 'itemCatalogEdit.aiHint' | translate }}

+ [placeholder]="'itemCatalogEdit.aiPlaceholder' | translate">
@if (aiError) { {{ aiError }} @@ -44,33 +44,33 @@
-

Objets

+

{{ 'itemCatalogEdit.itemsTitle' | translate }}

- Nom - Prix - Catégorie - Description + {{ 'common.name' | translate }} + {{ 'itemCatalogEdit.priceLabel' | translate }} + {{ 'itemCatalogEdit.categoryLabel' | translate }} + {{ 'common.description' | translate }}
@for (it of items; track it; let i = $index) {
- - - - -
} @if (items.length === 0) { -

Aucun objet — clique « Ajouter ».

+

{{ 'itemCatalogEdit.emptyHint' | translate }}

}
diff --git a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts index 554be62..ba84288 100644 --- a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts +++ b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts @@ -2,10 +2,12 @@ import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Sparkles } from 'lucide-angular'; import { ItemCatalogService } from '../../../services/item-catalog.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { ItemCatalog, CatalogItem, ItemCatalogCreate } from '../../../services/item-catalog.model'; +import { TranslatePipe } from '@ngx-translate/core'; /** * Création/édition d'un catalogue d'objets (boutique, butin…). @@ -14,7 +16,7 @@ import { ItemCatalog, CatalogItem, ItemCatalogCreate } from '../../../services/i */ @Component({ selector: 'app-item-catalog-edit', - imports: [FormsModule, LucideAngularModule], + imports: [FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './item-catalog-edit.component.html', styleUrls: ['./item-catalog-edit.component.scss'] }) @@ -43,7 +45,8 @@ export class ItemCatalogEditComponent implements OnInit { private route: ActivatedRoute, private router: Router, private service: ItemCatalogService, - private campaignSidebar: CampaignSidebarService + private campaignSidebar: CampaignSidebarService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -76,7 +79,7 @@ export class ItemCatalogEditComponent implements OnInit { generateWithAI(): void { if (!this.campaignId) return; - if (!this.aiPrompt.trim()) { this.aiError = 'Décris le catalogue à générer.'; return; } + if (!this.aiPrompt.trim()) { this.aiError = this.translate.instant('itemCatalogEdit.aiPromptRequired'); return; } this.generating = true; this.aiError = ''; this.service.generate(this.campaignId, this.aiPrompt.trim()).subscribe({ @@ -88,14 +91,14 @@ export class ItemCatalogEditComponent implements OnInit { }, error: (err) => { this.generating = false; - this.aiError = err?.error?.message || 'Échec de la génération IA. Réessaie ou reformule.'; + this.aiError = err?.error?.message || this.translate.instant('itemCatalogEdit.aiError'); } }); } save(): void { if (!this.campaignId) return; - if (!this.name.trim()) { this.errorMessage = 'Le nom est requis.'; return; } + if (!this.name.trim()) { this.errorMessage = this.translate.instant('itemCatalogEdit.nameRequired'); return; } this.saving = true; this.errorMessage = ''; @@ -141,7 +144,7 @@ export class ItemCatalogEditComponent implements OnInit { private fail(err: unknown): void { this.saving = false; - this.errorMessage = 'Échec de l\'enregistrement.'; + this.errorMessage = this.translate.instant('itemCatalogEdit.saveError'); console.error('ItemCatalog save failed', err); } diff --git a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html index 5ece329..f531e64 100644 --- a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html +++ b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html @@ -1,32 +1,31 @@
-

Catalogues d'objets

+

{{ 'itemCatalogList.title' | translate }}

- Boutiques, butins, trésors… à remplir à la main ou via l'IA. Consultables en session - quand les joueurs visitent une échoppe. + {{ 'itemCatalogList.hint' | translate }}

@if (catalogs.length === 0) { -

Aucun catalogue pour l'instant.

+

{{ 'itemCatalogList.empty' | translate }}

} @for (c of catalogs; track c) { diff --git a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts index d8fe10d..f5545aa 100644 --- a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts +++ b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts @@ -6,6 +6,7 @@ import { ItemCatalogService } from '../../../services/item-catalog.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { ItemCatalog } from '../../../services/item-catalog.model'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; /** * Liste des catalogues d'objets d'une campagne + création. @@ -13,7 +14,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-item-catalog-list', - imports: [LucideAngularModule], + imports: [LucideAngularModule, TranslatePipe], templateUrl: './item-catalog-list.component.html', styleUrls: ['./item-catalog-list.component.scss'] }) @@ -31,7 +32,8 @@ export class ItemCatalogListComponent implements OnInit { private router: Router, private service: ItemCatalogService, private campaignSidebar: CampaignSidebarService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -60,9 +62,9 @@ export class ItemCatalogListComponent implements OnInit { remove(c: ItemCatalog, ev: Event): void { ev.stopPropagation(); this.confirmDialog.confirm({ - title: 'Supprimer le catalogue', - message: `Supprimer « ${c.name} » ?`, - confirmLabel: 'Supprimer', + title: this.translate.instant('itemCatalogList.deleteTitle'), + message: this.translate.instant('itemCatalogList.deleteMessage', { name: c.name }), + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html index 497da7b..dbb8b52 100644 --- a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html +++ b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html @@ -2,11 +2,11 @@
@@ -17,7 +17,7 @@
@if (catalog.items.length === 0) {

- Aucun objet — édite le catalogue pour en ajouter. + {{ 'itemCatalogView.empty' | translate }}

} @for (g of groups; track g) { diff --git a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts index e467df6..6360a67 100644 --- a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts +++ b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts @@ -5,6 +5,7 @@ import { LucideAngularModule, ArrowLeft, Edit3, Package } from 'lucide-angular'; import { ItemCatalogService } from '../../../services/item-catalog.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { ItemCatalog, CatalogItem } from '../../../services/item-catalog.model'; +import { TranslatePipe } from '@ngx-translate/core'; interface ItemGroup { category: string; items: CatalogItem[]; } @@ -14,7 +15,7 @@ interface ItemGroup { category: string; items: CatalogItem[]; } */ @Component({ selector: 'app-item-catalog-view', - imports: [LucideAngularModule], + imports: [LucideAngularModule, TranslatePipe], templateUrl: './item-catalog-view.component.html', styleUrls: ['./item-catalog-view.component.scss'] }) diff --git a/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.html b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.html index 415c4f1..f99faf3 100644 --- a/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.html +++ b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.html @@ -13,7 +13,7 @@ @if (status !== 'created' && needsArc) {
-
} @if (sources.length === 0 && !uploading) {

- Ajoute un PDF source pour commencer à discuter avec. + {{ 'notebookDetail.sourcesEmpty' | translate }}

} @@ -76,32 +76,32 @@ @if (viewingArchive) { - Archive du {{ archiveLabel(viewingArchive) }} — lecture seule + {{ 'notebookDetail.archiveBanner' | translate:{ label: archiveLabel(viewingArchive) } }} } @else { @if (referencedArchiveIds.size > 0) { + [title]="'notebookDetail.refBadgeTitle' | translate"> - {{ referencedArchiveIds.size }} archive(s) en référence + {{ 'notebookDetail.refBadge' | translate:{ n: referencedArchiveIds.size } }} } }
@@ -109,12 +109,9 @@ @if (archivesOpen && !viewingArchive) {
@if (archives.length === 0) { -

Aucune conversation archivée pour l'instant — « Vider » archive le fil courant ici.

+

{{ 'notebookDetail.archivesEmpty' | translate }}

} @else { -

- Cochez une archive pour l'utiliser comme référence dans le chat ; - cliquez sur son nom pour la relire. -

+

} @for (a of archives; track a.archivedAt) {
@@ -123,9 +120,9 @@ class="nbd-archive-check" [checked]="isReferenced(a)" (change)="toggleReference(a)" - [title]="isReferenced(a) - ? 'Archive utilisée comme référence — décocher pour la retirer' - : 'Utiliser cette archive comme référence dans le chat'" /> + [title]="(isReferenced(a) + ? 'notebookDetail.archiveRefOnTitle' + : 'notebookDetail.archiveRefOffTitle') | translate" />
@if (m.role === 'assistant') {
@@ -155,9 +152,9 @@ } @else { @if (messages.length === 0) {

- Pose une question sur ta source, ou demande une adaptation pour ta campagne. + {{ 'notebookDetail.chatEmpty' | translate }} @if (!hasReadySource()) { -
(Ajoute d'abord une source indexée pour des réponses ancrées.)
+ }

} @@ -167,14 +164,14 @@ @if (m.role === 'assistant') { } - {{ m.role === 'user' ? 'Vous' : 'IA' }} + {{ (m.role === 'user' ? 'notebookDetail.roleUser' : 'notebookDetail.roleAi') | translate }}
@if (m.role === 'assistant') { @if (parsedOf(m); as p) { @if (sending && deepProgress && !p.text) {
- Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }} + {{ 'notebookDetail.deepProgress' | translate:{ current: deepProgress.current, total: deepProgress.total } }}
} @if (p.text) { @@ -192,7 +189,7 @@ } @if (sourcesLabel(m); as label) { -
+
📖 {{ label }}
} @@ -207,14 +204,14 @@ @if (!viewingArchive) {
diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts index 50f05bf..5e4279e 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts @@ -16,6 +16,7 @@ import { loadCampaignTreeData } from '../../campaign-tree.helper'; import { NotebookActionCardComponent } from '../notebook-action-card/notebook-action-card.component'; import { MarkdownPipe } from '../../../shared/markdown.pipe'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; /** * Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite). @@ -23,7 +24,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-notebook-detail', - imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent, MarkdownPipe], + imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent, MarkdownPipe, TranslatePipe], templateUrl: './notebook-detail.component.html', styleUrls: ['./notebook-detail.component.scss'] }) @@ -68,7 +69,8 @@ export class NotebookDetailComponent implements OnInit { private characterService: CharacterService, private npcService: NpcService, private enemyService: EnemyService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -208,7 +210,7 @@ export class NotebookDetailComponent implements OnInit { next: () => { this.uploading = false; this.reloadSources(); }, error: (err) => { this.uploading = false; - this.uploadError = err?.error?.message || 'Échec de l\'indexation. Réessaie ou vérifie le modèle d\'embedding.'; + this.uploadError = err?.error?.message || this.translate.instant('notebookDetail.uploadError'); this.reloadSources(); } }); @@ -232,10 +234,10 @@ export class NotebookDetailComponent implements OnInit { clearChat(): void { if (this.sending || this.messages.length === 0) return; this.confirmDialog.confirm({ - title: 'Vider la conversation', - message: 'Repartir d\'une conversation vierge ?', - details: ['Le fil actuel est archivé (pas supprimé) : il restera consultable via « Archives ».'], - confirmLabel: 'Vider', + title: this.translate.instant('notebookDetail.clearTitle'), + message: this.translate.instant('notebookDetail.clearMessage'), + details: [this.translate.instant('notebookDetail.clearDetail')], + confirmLabel: this.translate.instant('notebookDetail.clear'), variant: 'danger' }).then(ok => { if (!ok) return; @@ -287,10 +289,11 @@ export class NotebookDetailComponent implements OnInit { /** Libellé d'une archive : date du clear + nb d'échanges. */ archiveLabel(a: NotebookArchive): string { const date = new Date(a.archivedAt); + const locale = this.translate.getCurrentLang() === 'en' ? 'en-US' : 'fr-FR'; const when = isNaN(date.getTime()) ? a.archivedAt - : date.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' }); - return `${when} · ${a.messages.length} message(s)`; + : date.toLocaleString(locale, { dateStyle: 'short', timeStyle: 'short' }); + return this.translate.instant('notebookDetail.archiveLabel', { when, n: a.messages.length }); } // --- Chat --- diff --git a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html index b97d104..5e8524b 100644 --- a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html +++ b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html @@ -1,36 +1,35 @@
-

Ateliers d'adaptation

+

{{ 'notebookList.title' | translate }}

- Discute avec l'IA à partir d'un PDF source (recherche dans le document) pour adapter - son contenu à ta campagne. La source et la conversation sont conservées. + {{ 'notebookList.hint' | translate }}

-
@if (notebooks.length === 0) { -

Aucun atelier pour l'instant.

+

{{ 'notebookList.empty' | translate }}

} @for (nb of notebooks; track nb) { diff --git a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts index 52b523e..74ad8d4 100644 --- a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts +++ b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts @@ -7,6 +7,7 @@ import { NotebookService } from '../../../services/notebook.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { Notebook } from '../../../services/notebook.model'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; /** * Liste des ateliers (notebooks) d'une campagne + création. @@ -14,7 +15,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-notebook-list', - imports: [FormsModule, LucideAngularModule], + imports: [FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './notebook-list.component.html', styleUrls: ['./notebook-list.component.scss'] }) @@ -34,7 +35,8 @@ export class NotebookListComponent implements OnInit { private router: Router, private service: NotebookService, private campaignSidebar: CampaignSidebarService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -55,7 +57,7 @@ export class NotebookListComponent implements OnInit { create(): void { if (this.creating) return; this.creating = true; - this.service.create(this.campaignId, this.newName.trim() || 'Nouvel atelier').subscribe({ + this.service.create(this.campaignId, this.newName.trim() || this.translate.instant('notebookList.defaultName')).subscribe({ next: (nb) => this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]), error: () => this.creating = false }); @@ -68,9 +70,9 @@ export class NotebookListComponent implements OnInit { remove(nb: Notebook, ev: Event): void { ev.stopPropagation(); this.confirmDialog.confirm({ - title: 'Supprimer l\'atelier', - message: `Supprimer « ${nb.name} » et ses sources indexées ?`, - confirmLabel: 'Supprimer', + title: this.translate.instant('notebookList.deleteTitle'), + message: this.translate.instant('notebookList.deleteMessage', { name: nb.name }), + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html index 7197b3a..d93cda5 100644 --- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html @@ -3,12 +3,12 @@

- {{ npcId ? 'Éditer le PNJ' : 'Nouveau PNJ' }} + {{ (npcId ? 'npcEdit.titleEdit' : 'npcEdit.titleNew') | translate }}

@if (npcId) { }
@@ -27,25 +27,25 @@
- +
- + @for (f of existingFolders; track f) { @@ -56,20 +56,20 @@
- +
- +
@@ -90,23 +90,23 @@ @if (loreId) {
- + -

Reliez ce PNJ aux pages de l'univers (sa ville, sa faction, sa région…). Ces liens apparaissent dans le graphe du Lore.

+

{{ 'npcEdit.relatedPagesHint' | translate }}

}
- + @if (npcId) { }
@@ -129,7 +129,7 @@ entityType="npc" [entityId]="npcId" [isOpen]="chatOpen" - welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures." + [welcomeMessage]="'npcEdit.chatWelcome' | translate" [quickSuggestions]="chatQuickSuggestions" (close)="chatOpen = false"> diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts index e2b5dd5..3232890 100644 --- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { NpcService } from '../../../services/npc.service'; import { CampaignService } from '../../../services/campaign.service'; import { GameSystemService } from '../../../services/game-system.service'; @@ -24,7 +25,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-npc-edit', - imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent, LoreLinkPickerComponent], + imports: [FormsModule, LucideAngularModule, TranslatePipe, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent, LoreLinkPickerComponent], templateUrl: './npc-edit.component.html', styleUrls: ['./npc-edit.component.scss'] }) @@ -36,11 +37,13 @@ export class NpcEditComponent implements OnInit { readonly Sparkles = Sparkles; chatOpen = false; - readonly chatQuickSuggestions = [ - 'Propose une apparence et une posture marquantes', - 'Suggere 2 motivations et un secret pour ce PNJ', - 'Imagine 3 repliques signatures qui le caracterisent' - ]; + get chatQuickSuggestions(): string[] { + return [ + this.translate.instant('npcEdit.chatSuggestion1'), + this.translate.instant('npcEdit.chatSuggestion2'), + this.translate.instant('npcEdit.chatSuggestion3') + ]; + } toggleChat(): void { this.chatOpen = !this.chatOpen; } @@ -74,7 +77,8 @@ export class NpcEditComponent implements OnInit { private gameSystemService: GameSystemService, private pageService: PageService, private campaignSidebar: CampaignSidebarService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -174,10 +178,10 @@ export class NpcEditComponent implements OnInit { deleteNpc(): void { if (!this.npcId) return; this.confirmDialog.confirm({ - title: 'Supprimer la fiche ?', - message: `Supprimer la fiche de "${this.name}" ?`, - details: ['Cette action est irreversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('npcEdit.deleteTitle'), + message: this.translate.instant('npcEdit.deleteMessage', { name: this.name }), + details: [this.translate.instant('npcEdit.irreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok || !this.npcId) return; diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.html b/web/src/app/campaigns/npc/npc-list/npc-list.component.html index 04c5b10..dc10bd4 100644 --- a/web/src/app/campaigns/npc/npc-list/npc-list.component.html +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.html @@ -1,25 +1,24 @@
-

PNJ

+

{{ 'npcList.title' | translate }}

- Tous les personnages non-joueurs de la campagne, classés par dossier. - Le champ « Dossier » de chaque fiche pilote le classement. + {{ 'npcList.hint' | translate }}

@if (total === 0) { -

Aucun PNJ pour l'instant.

+

{{ 'npcList.empty' | translate }}

} @for (g of groups; track g.folder) { @if (g.folder) { @@ -29,13 +28,13 @@ {{ g.npcs.length }} } @else if (groups.length > 1) { -

Non classés

+

{{ 'npcList.unclassified' | translate }}

} @for (n of g.npcs; track n.id) { diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.ts b/web/src/app/campaigns/npc/npc-list/npc-list.component.ts index 6b236c4..7c14f4d 100644 --- a/web/src/app/campaigns/npc/npc-list/npc-list.component.ts +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { LucideAngularModule, ArrowLeft, Plus, Trash2, Drama, Folder } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { NpcService } from '../../../services/npc.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { Npc } from '../../../services/npc.model'; @@ -20,7 +21,7 @@ interface FolderGroup { */ @Component({ selector: 'app-npc-list', - imports: [LucideAngularModule], + imports: [LucideAngularModule, TranslatePipe], templateUrl: './npc-list.component.html', styleUrls: ['./npc-list.component.scss'] }) @@ -41,7 +42,8 @@ export class NpcListComponent implements OnInit { private router: Router, private service: NpcService, private campaignSidebar: CampaignSidebarService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -92,10 +94,10 @@ export class NpcListComponent implements OnInit { remove(n: Npc, ev: Event): void { ev.stopPropagation(); this.confirmDialog.confirm({ - title: 'Supprimer la fiche', - message: `Supprimer la fiche de « ${n.name} » ?`, - details: ['Cette action est irréversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('npcList.deleteTitle'), + message: this.translate.instant('npcList.deleteMessage', { name: n.name }), + details: [this.translate.instant('npcList.irreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.html b/web/src/app/campaigns/npc/npc-view/npc-view.component.html index 582901c..bdf9546 100644 --- a/web/src/app/campaigns/npc/npc-view/npc-view.component.html +++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.html @@ -2,18 +2,18 @@
@if (npcId) { }
@@ -24,7 +24,7 @@
-

Personnages joueurs

+

{{ 'playthroughDetail.charactersTitle' | translate }}

@if (characters.length === 0) { -

Aucun PJ pour cette partie.

+

{{ 'playthroughDetail.noCharacters' | translate }}

} @if (characters.length > 0) {
    @@ -64,16 +64,16 @@
-

Sessions

+

{{ 'playthroughDetail.sessionsTitle' | translate }}

@if (sessions.length === 0) { -

Aucune session encore. Lancez la première !

+

{{ 'playthroughDetail.noSessions' | translate }}

} @if (sessions.length > 0) {
    @for (s of sessions; track s) {
  • {{ s.name }} - {{ s.active ? 'En cours' : 'Terminée' }} + {{ (s.active ? 'playthroughDetail.statusActive' : 'playthroughDetail.statusEnded') | translate }}
  • }
diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts index 3f16153..d3a8a8e 100644 --- a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil, Plus } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -26,7 +27,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-playthrough-detail', - imports: [RouterModule, LucideAngularModule], + imports: [RouterModule, LucideAngularModule, TranslatePipe], templateUrl: './playthrough-detail.component.html', styleUrls: ['./playthrough-detail.component.scss'] }) @@ -62,7 +63,8 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy { private sessionService: SessionService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -92,7 +94,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy { this.characters = characters; this.activeOnThis = activeOnThis; this.pageTitleService.set(`${playthrough.name} — ${campaign.name}`); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } @@ -127,18 +129,18 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy { this.playthroughService.deletionImpact(this.playthroughId).subscribe({ next: impact => { const parts: string[] = []; - if (impact.sessions > 0) parts.push(`${impact.sessions} session(s)`); - if (impact.characters > 0) parts.push(`${impact.characters} PJ`); - if (impact.flags > 0) parts.push(`${impact.flags} fait(s)`); - if (impact.progressions > 0) parts.push(`${impact.progressions} progression(s) de quête`); + if (impact.sessions > 0) parts.push(this.translate.instant('playthroughDetail.impactSessions', { n: impact.sessions })); + if (impact.characters > 0) parts.push(this.translate.instant('playthroughDetail.impactCharacters', { n: impact.characters })); + if (impact.flags > 0) parts.push(this.translate.instant('playthroughDetail.impactFlags', { n: impact.flags })); + if (impact.progressions > 0) parts.push(this.translate.instant('playthroughDetail.impactProgressions', { n: impact.progressions })); const details: string[] = []; - if (parts.length) details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`); - details.push('Cette action est irréversible.'); + if (parts.length) details.push(this.translate.instant('playthroughDetail.deleteCascade', { parts: parts.join(', ') })); + details.push(this.translate.instant('playthroughDetail.irreversible')); this.confirmDialog.confirm({ - title: 'Supprimer la Partie', - message: `Supprimer "${this.playthrough?.name}" ?`, + title: this.translate.instant('playthroughDetail.deleteTitle'), + message: this.translate.instant('playthroughDetail.deleteMessage', { name: this.playthrough?.name }), details, - confirmLabel: 'Supprimer', + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html index 874f28c..c2fee58 100644 --- a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html @@ -3,13 +3,12 @@ diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts index 28ccae4..287b029 100644 --- a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -21,7 +22,7 @@ import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-fl */ @Component({ selector: 'app-playthrough-flags-page', - imports: [RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent], + imports: [RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent, TranslatePipe], templateUrl: './playthrough-flags-page.component.html', styleUrls: ['./playthrough-flags-page.component.scss'] }) @@ -42,7 +43,8 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy { private enemyService: EnemyService, private playthroughService: PlaythroughService, private layoutService: LayoutService, - private pageTitleService: PageTitleService + private pageTitleService: PageTitleService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -65,8 +67,8 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy { playthrough: this.playthroughService.getById(this.playthroughId) }).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => { this.playthrough = playthrough; - this.pageTitleService.set(`${playthrough.name} — Faits`); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.pageTitleService.set(this.translate.instant('playthroughFlagsPage.pageTitle', { name: playthrough.name })); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } diff --git a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html index 9e10079..f448ac9 100644 --- a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html +++ b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html @@ -2,52 +2,52 @@
-

{{ tableId ? 'Éditer la table' : 'Nouvelle table aléatoire' }}

+

{{ (tableId ? 'randomTableEdit.titleEdit' : 'randomTableEdit.titleNew') | translate }}

@if (errorMessage) {
{{ errorMessage }}
}
- - + +
- - + +
- + - {{ formulaValid ? 'Formule valide' : 'Format attendu : NdM (ex. 1d20, 2d6, d100)' }} + {{ (formulaValid ? 'randomTableEdit.formulaValid' : 'randomTableEdit.formulaExpected') | translate }}
- Générer avec l'IA + {{ 'randomTableEdit.aiTitle' | translate }}
-

Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.

+

{{ 'randomTableEdit.aiHint' | translate }}

+ [placeholder]="'randomTableEdit.aiPromptPlaceholder' | translate">
@if (aiError) { {{ aiError }} @@ -56,22 +56,22 @@
-

Entrées

+

{{ 'randomTableEdit.entriesTitle' | translate }}

-
- Min - Max - Résultat - Détail + {{ 'randomTableEdit.colMin' | translate }} + {{ 'randomTableEdit.colMax' | translate }} + {{ 'randomTableEdit.colResult' | translate }} + {{ 'randomTableEdit.colDetail' | translate }}
@@ -79,15 +79,15 @@
- - -
} @if (entries.length === 0) { -

Aucune entrée — clique « Ajouter ».

+

{{ 'randomTableEdit.emptyHint' | translate }}

}
diff --git a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts index 5177fd1..55957cd 100644 --- a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts +++ b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Wand2, Sparkles } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { RandomTableService } from '../../../services/random-table.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { RandomTable, RandomTableEntry, RandomTableCreate } from '../../../services/random-table.model'; @@ -15,7 +16,7 @@ import { DiceUtils } from '../../../shared/dice.utils'; */ @Component({ selector: 'app-random-table-edit', - imports: [FormsModule, LucideAngularModule], + imports: [FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './random-table-edit.component.html', styleUrls: ['./random-table-edit.component.scss'] }) @@ -47,7 +48,8 @@ export class RandomTableEditComponent implements OnInit { private route: ActivatedRoute, private router: Router, private service: RandomTableService, - private campaignSidebar: CampaignSidebarService + private campaignSidebar: CampaignSidebarService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -105,8 +107,8 @@ export class RandomTableEditComponent implements OnInit { /** Génère la table via l'IA et préremplit le formulaire (l'utilisateur révise avant d'enregistrer). */ generateWithAI(): void { if (!this.campaignId) return; - if (!this.aiPrompt.trim()) { this.aiError = 'Décris la table à générer.'; return; } - if (!this.formulaValid) { this.aiError = 'Choisis d\'abord une formule de dé valide.'; return; } + if (!this.aiPrompt.trim()) { this.aiError = this.translate.instant('randomTableEdit.aiErrorPrompt'); return; } + if (!this.formulaValid) { this.aiError = this.translate.instant('randomTableEdit.aiErrorFormula'); return; } this.generating = true; this.aiError = ''; this.service.generate(this.campaignId, this.aiPrompt.trim(), this.diceFormula.trim()).subscribe({ @@ -118,15 +120,15 @@ export class RandomTableEditComponent implements OnInit { }, error: (err) => { this.generating = false; - this.aiError = err?.error?.message || 'Échec de la génération IA. Réessaie ou reformule.'; + this.aiError = err?.error?.message || this.translate.instant('randomTableEdit.aiErrorGenerate'); } }); } save(): void { if (!this.campaignId) return; - if (!this.name.trim()) { this.errorMessage = 'Le nom est requis.'; return; } - if (!this.formulaValid) { this.errorMessage = 'Formule de dé invalide (ex. 1d20, 2d6, d100).'; return; } + if (!this.name.trim()) { this.errorMessage = this.translate.instant('randomTableEdit.errorNameRequired'); return; } + if (!this.formulaValid) { this.errorMessage = this.translate.instant('randomTableEdit.errorFormulaInvalid'); return; } this.saving = true; this.errorMessage = ''; @@ -174,7 +176,7 @@ export class RandomTableEditComponent implements OnInit { private fail(err: unknown): void { this.saving = false; - this.errorMessage = 'Échec de l\'enregistrement.'; + this.errorMessage = this.translate.instant('randomTableEdit.errorSaveFailed'); console.error('RandomTable save failed', err); } diff --git a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html index c64b861..42d1161 100644 --- a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html +++ b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html @@ -3,12 +3,12 @@
@@ -16,13 +16,13 @@ @if (table.description) {

{{ table.description }}

} - Dé : {{ table.diceFormula }} + {{ 'randomTableView.die' | translate }} {{ table.diceFormula }}
@if (lastRoll) {
@@ -35,7 +35,7 @@ {{ matched.label }} } @if (!matched) { - Aucune entrée pour ce résultat + {{ 'randomTableView.noMatch' | translate }} }
} @@ -47,13 +47,13 @@
@if (table.entries.length === 0) {
- Aucune entrée — édite la table pour en ajouter. + {{ 'randomTableView.empty' | translate }}
} @if (table.entries.length > 0) { - + @for (e of table.entries; track e) { diff --git a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts index 23e4641..cd5ff8d 100644 --- a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts +++ b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; import { RandomTableService } from '../../../services/random-table.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { RandomTable, RandomTableEntry } from '../../../services/random-table.model'; @@ -14,7 +15,7 @@ import { DiceUtils, DiceRoll } from '../../../shared/dice.utils'; */ @Component({ selector: 'app-random-table-view', - imports: [LucideAngularModule], + imports: [LucideAngularModule, TranslatePipe], templateUrl: './random-table-view.component.html', styleUrls: ['./random-table-view.component.scss'] }) diff --git a/web/src/app/campaigns/scene/scene-create/scene-create.component.html b/web/src/app/campaigns/scene/scene-create/scene-create.component.html index 0ca0150..0c6bd67 100644 --- a/web/src/app/campaigns/scene/scene-create/scene-create.component.html +++ b/web/src/app/campaigns/scene/scene-create/scene-create.component.html @@ -1,45 +1,45 @@
- +
- +
- +
- +
diff --git a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts index 344fae2..51c6aab 100644 --- a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts +++ b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts @@ -4,6 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -20,7 +21,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; */ @Component({ selector: 'app-scene-create', - imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent], + imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent, TranslatePipe], templateUrl: './scene-create.component.html', styleUrls: ['./scene-create.component.scss'] }) @@ -44,7 +45,8 @@ export class SceneCreateComponent implements OnInit, OnDestroy { private npcService: NpcService, private randomTableService: RandomTableService, private enemyService: EnemyService, - private layoutService: LayoutService + private layoutService: LayoutService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -69,7 +71,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy { this.chapterName = currentChapter?.name ?? ''; this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0; - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html index 2c0ae40..cd558f4 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html @@ -2,24 +2,24 @@ @@ -28,119 +28,118 @@
- + - Portraits des PNJ, ambiance visuelle, scenes evocatrices... + {{ 'sceneEdit.illustrationsHint' | translate }}
- + - Plans du lieu, cartes tactiques, schemas utilisables a la table. + {{ 'sceneEdit.mapsHint' | translate }}
- +
- +
- +
- +
- - + +
- - + +
- +
- +
- Ce texte peut être lu directement à vos joueurs. + {{ 'sceneEdit.narrationHint' | translate }}
- +
- Ces notes sont privées et visibles uniquement par le MJ. + {{ 'sceneEdit.gmNotesHint' | translate }}
- +
- + @if (siblingScenes.length === 0) {
- 💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches. - Créez d'abord d'autres scènes, puis revenez ici pour les connecter. + {{ 'sceneEdit.branchesNoSibling' | translate }}
} @@ -150,18 +149,18 @@ @for (branch of branches; track $index; let i = $index) {
- + + [placeholder]="'sceneEdit.branchLabelPlaceholder' | translate" />
- + + [placeholder]="'sceneEdit.branchConditionPlaceholder' | translate" />
} - Chaque branche représente une "sortie" possible depuis cette scène selon l'action des joueurs. - Les cibles sont limitées aux scènes du même chapitre. + {{ 'sceneEdit.branchesHint' | translate }}
} - +
- - + +
- + - Référencez des fiches de votre bestiaire — cliquez sur un chip pour ouvrir la fiche. + {{ 'sceneEdit.bestiaryEnemiesHint' | translate }}
- +
@@ -226,7 +224,7 @@ @if (loreId) { - +
- Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page. + {{ 'sceneEdit.loreHint' | translate }}
@@ -244,17 +242,15 @@ @if (!loreId) {
- 💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne - pour pouvoir épingler des pages du Lore à cette scène. + {{ 'sceneEdit.noLoreHint' | translate }}
} - + - Si cette scène représente un lieu à parcourir pièce par pièce, ajoutez-les ici. - La scène bascule alors en mode « donjon » côté affichage. + {{ 'sceneEdit.dungeonHint' | translate }} diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts index a17a04f..7cbdbda 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts @@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; @@ -33,7 +34,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-scene-edit', - imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent], + imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent, TranslatePipe], templateUrl: './scene-edit.component.html', styleUrls: ['./scene-edit.component.scss'] }) @@ -45,11 +46,13 @@ export class SceneEditComponent implements OnInit, OnDestroy { /** État drawer chat IA (b5.7 — intégration Campagne). */ chatOpen = false; - readonly chatQuickSuggestions = [ - 'Propose une ambiance sensorielle immersive pour cette scène', - 'Suggère une narration d\'ouverture à lire aux joueurs', - 'Imagine 2 choix avec conséquences marquantes' - ]; + get chatQuickSuggestions(): string[] { + return [ + this.translate.instant('sceneEdit.chatSuggestion1'), + this.translate.instant('sceneEdit.chatSuggestion2'), + this.translate.instant('sceneEdit.chatSuggestion3') + ]; + } toggleChat(): void { this.chatOpen = !this.chatOpen; } @@ -91,7 +94,8 @@ export class SceneEditComponent implements OnInit, OnDestroy { private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -174,7 +178,7 @@ export class SceneEditComponent implements OnInit, OnDestroy { enemies: scene.enemies ?? '' }); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } @@ -208,10 +212,10 @@ export class SceneEditComponent implements OnInit, OnDestroy { delete(): void { this.confirmDialog.confirm({ - title: 'Supprimer la scène', - message: `Supprimer la scène "${this.scene?.name}" ?`, - details: ['Cette action est irréversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('sceneEdit.deleteTitle'), + message: this.translate.instant('sceneEdit.deleteMessage', { name: this.scene?.name }), + details: [this.translate.instant('sceneEdit.deleteIrreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.html b/web/src/app/campaigns/scene/scene-view/scene-view.component.html index a375835..8d20b79 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.html +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.html @@ -8,16 +8,16 @@ } {{ scene.name }} -

Scène

+

{{ 'sceneView.subtitle' | translate }}

-
@@ -30,17 +30,17 @@ @if ((scene.mapImageIds?.length ?? 0) > 0) {
-

🗺️ Cartes & plans

+

🗺️ {{ 'sceneView.mapsSectionTitle' | translate }}

}
-

📝 Description

+

📝 {{ 'sceneView.descriptionSectionTitle' | translate }}

@if (scene.description?.trim()) {

{{ scene.description }}

} @else { -

Non renseigné

+

{{ 'sceneView.empty' | translate }}

}
@@ -48,13 +48,13 @@
@if (scene.location?.trim()) {
-

📍 Lieu

+

📍 {{ 'sceneView.locationSectionTitle' | translate }}

{{ scene.location }}

} @if (scene.timing?.trim()) {
-

Moment

+

{{ 'sceneView.timingSectionTitle' | translate }}

{{ scene.timing }}

} @@ -62,21 +62,21 @@ } @if (scene.atmosphere?.trim()) {
-

🌫️ Ambiance et atmosphère

+

🌫️ {{ 'sceneView.atmosphereSectionTitle' | translate }}

{{ scene.atmosphere }}

} @if (scene.playerNarration?.trim()) {
-

📖 Narration pour les joueurs

+

📖 {{ 'sceneView.narrationSectionTitle' | translate }}

{{ scene.playerNarration }}

} @if (scene.choicesConsequences?.trim()) {
-

🔀 Choix et conséquences

+

🔀 {{ 'sceneView.choicesSectionTitle' | translate }}

{{ scene.choicesConsequences }}

} @@ -84,13 +84,13 @@ @if (scene.combatDifficulty?.trim() || scene.enemies?.trim() || linkedEnemies.length > 0) { @if (scene.combatDifficulty?.trim()) {
-

⚔️ Difficulté estimée

+

⚔️ {{ 'sceneView.combatDifficultySectionTitle' | translate }}

{{ scene.combatDifficulty }}

} @if (scene.enemies?.trim() || linkedEnemies.length > 0) {
-

🐲 Ennemis et créatures

+

🐲 {{ 'sceneView.enemiesSectionTitle' | translate }}

@if (linkedEnemies.length > 0) {
@for (e of linkedEnemies; track e.id) { @@ -112,7 +112,7 @@

🔒 - Notes et secrets du MJ + {{ 'sceneView.gmNotesSectionTitle' | translate }}

{{ scene.gmSecretNotes }}

@@ -120,7 +120,7 @@ @if (loreId && (scene.relatedPageIds?.length ?? 0) > 0) {
-

🔗 Pages Lore associées

+

🔗 {{ 'sceneView.loreSectionTitle' | translate }}

@for (relId of scene.relatedPageIds; track relId) { @if ((scene.rooms?.length ?? 0) > 0) {
-

🏰 Pièces du lieu

+

🏰 {{ 'sceneView.roomsSectionTitle' | translate }}

@for (r of scene.rooms; track r; let i = $index) {
@@ -143,7 +143,7 @@ {{ r.name }} @if (r.floor !== null && r.floor !== undefined) { - Étage {{ r.floor }} + {{ 'sceneView.roomFloor' | translate:{ floor: r.floor } }} } @@ -153,7 +153,7 @@
@if (r.enemies?.trim() || roomLinkedEnemies(r).length > 0) {
- ⚔️ Ennemis + {{ 'sceneView.roomEnemies' | translate }} @if (roomLinkedEnemies(r).length > 0) {
@for (e of roomLinkedEnemies(r); track e.id) { @@ -171,32 +171,32 @@ } @if (r.loot?.trim()) {
- 💰 Loot + {{ 'sceneView.roomLoot' | translate }}

{{ r.loot }}

} @if (r.traps?.trim()) {
- ⚠️ Pièges + {{ 'sceneView.roomTraps' | translate }}

{{ r.traps }}

} @if (r.gmNotes?.trim()) {
- 🔒 Notes MJ + {{ 'sceneView.roomGmNotes' | translate }}

{{ r.gmNotes }}

}
@if ((r.branches?.length ?? 0) > 0) {
- → Sorties + {{ 'sceneView.roomExits' | translate }}
    @for (b of r.branches; track b) {
  • {{ b.label }} → {{ roomNameById(scene, b.targetRoomId) }} @if (b.condition?.trim()) { - (si : {{ b.condition }}) + {{ 'sceneView.roomExitCondition' | translate:{ condition: b.condition } }} }
  • } diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts index d38b69b..a93f27a 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { resolveCampaignIcon } from '../../campaign-icons'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; @@ -26,7 +27,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-scene-view', - imports: [RouterModule, LucideAngularModule, ImageGalleryComponent], + imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe], templateUrl: './scene-view.component.html', styleUrls: ['./scene-view.component.scss'] }) @@ -57,7 +58,8 @@ export class SceneViewComponent implements OnInit, OnDestroy { private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -98,12 +100,12 @@ export class SceneViewComponent implements OnInit, OnDestroy { this.availableEnemies = treeData.enemies ?? []; this.pageTitleService.set(scene.name); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } titleOfRelated(pageId: string): string { - return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)'; + return this.availablePages.find(p => p.id === pageId)?.title ?? this.translate.instant('sceneView.deletedPage'); } /** Fiches du bestiaire liées à la rencontre (IDs orphelins ignorés). */ @@ -128,7 +130,7 @@ export class SceneViewComponent implements OnInit, OnDestroy { /** Résout le nom d'une pièce cible (pour afficher les sorties inter-pièces). */ roomNameById(scene: Scene | null, roomId: string): string { - return scene?.rooms?.find(r => r.id === roomId)?.name ?? '(pièce supprimée)'; + return scene?.rooms?.find(r => r.id === roomId)?.name ?? this.translate.instant('sceneView.deletedRoom'); } editMode(): void { @@ -143,10 +145,10 @@ export class SceneViewComponent implements OnInit, OnDestroy { if (!this.scene) return; const scene = this.scene; this.confirmDialog.confirm({ - title: 'Supprimer la scène', - message: `Supprimer la scène "${scene.name}" ?`, - details: ['Cette action est irréversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('sceneView.deleteTitle'), + message: this.translate.instant('sceneView.deleteMessage', { name: scene.name }), + details: [this.translate.instant('sceneView.deleteIrreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html index e5572f0..3f6cef7 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html @@ -3,38 +3,35 @@

    - {{ id ? 'Éditer le système' : 'Nouveau système de JDR' }} + {{ (id ? 'gameSystemEdit.editTitle' : 'gameSystemEdit.createTitle') | translate }}

    - - + +
    - - + +
    - - + +
    -

    Règles du système

    -

    - Une section = un thème. L'IA injectera automatiquement les sections pertinentes - selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions). -

    +

    {{ 'gameSystemEdit.rulesTitle' | translate }}

    +

    {{ 'gameSystemEdit.rulesHint' | translate }}

    @@ -42,10 +39,10 @@ @if (!importing) { - L'IA propose un découpage en sections — vous révisez avant d'enregistrer. + {{ 'gameSystemEdit.importHint' | translate }} }
    @@ -65,7 +62,7 @@ } @if (importFound.length) {

    - Sections trouvées : {{ importFound.join(' · ') }} + {{ 'gameSystemEdit.sectionsFound' | translate:{ titles: importFound.join(' · ') } }}

    }
    @@ -90,9 +87,9 @@ class="section-title-input" [(ngModel)]="section.title" [name]="'title-' + i" - placeholder="Nom de la section (ex: Combat)" + [placeholder]="'gameSystemEdit.sectionTitlePlaceholder' | translate" /> -
@@ -102,7 +99,7 @@ [(ngModel)]="section.content" [name]="'content-' + i" rows="6" - placeholder="Décrivez les règles de cette section..." + [placeholder]="'gameSystemEdit.sectionContentPlaceholder' | translate" > }
@@ -110,13 +107,13 @@ @if (sections.length === 0) {
- Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous. + {{ 'gameSystemEdit.noSections' | translate }}
}
- Ajouter une section : + {{ 'gameSystemEdit.addSection' | translate }} @for (name of suggestedSections; track name) {
-

Fiches de personnages

-

- Definissez la structure des fiches PJ et PNJ pour ce systeme. Les champs - universels (nom, portrait, header) sont automatiques — ne rajoutez ici - que les champs specifiques au systeme (Histoire, PV, Stats…). -

+

{{ 'gameSystemEdit.charactersTitle' | translate }}

+

{{ 'gameSystemEdit.charactersHint' | translate }}

@@ -172,9 +165,9 @@
- +
diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts index 77924ee..dc48374 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight, Upload } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { GameSystemService } from '../../services/game-system.service'; import { TemplateField } from '../../services/template.model'; import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component'; @@ -44,7 +45,7 @@ const ENEMY_FIELD_SUGGESTIONS = ['Stats', 'Attaques', 'Capacités', 'Faiblesses' @Component({ selector: 'app-game-system-edit', - imports: [FormsModule, LucideAngularModule, TemplateFieldsEditorComponent], + imports: [FormsModule, LucideAngularModule, TranslatePipe, TemplateFieldsEditorComponent], templateUrl: './game-system-edit.component.html', styleUrls: ['./game-system-edit.component.scss'] }) @@ -95,7 +96,8 @@ export class GameSystemEditComponent implements OnInit { constructor( private route: ActivatedRoute, private router: Router, - private service: GameSystemService + private service: GameSystemService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -148,7 +150,7 @@ export class GameSystemEditComponent implements OnInit { this.importing = true; this.importNote = null; this.importError = null; - this.importPhase = 'Extraction du texte…'; + this.importPhase = this.translate.instant('gameSystemEdit.importExtracting'); this.importProgress = null; this.importFound = []; this.importStatus = null; @@ -160,10 +162,10 @@ export class GameSystemEditComponent implements OnInit { this.importStatus = null; if (ev.total === 0) { // Phase d'extraction (total encore inconnu). - this.importPhase = 'Extraction du texte…'; + this.importPhase = this.translate.instant('gameSystemEdit.importExtracting'); this.importProgress = null; } else { - this.importPhase = `Analyse des règles… (${ev.current}/${ev.total})`; + this.importPhase = this.translate.instant('gameSystemEdit.importAnalyzing', { current: ev.current, total: ev.total }); this.importProgress = { current: ev.current, total: ev.total }; for (const t of ev.newSectionTitles) { if (!this.importFound.includes(t)) this.importFound.push(t); @@ -178,8 +180,8 @@ export class GameSystemEditComponent implements OnInit { error: (err: Error) => { this.resetImportProgress(); this.importError = err?.message - ? `Échec de l'import : ${err.message}` - : "Échec de l'import du PDF."; + ? this.translate.instant('gameSystemEdit.importFailedReason', { reason: err.message }) + : this.translate.instant('gameSystemEdit.importFailed'); } }); } @@ -188,13 +190,17 @@ export class GameSystemEditComponent implements OnInit { this.resetImportProgress(); const added = this.mergeImportedSections(sections); if (added === 0) { - this.importError = "Aucune règle exploitable n'a été extraite de ce PDF " - + "(scan sans OCR, ou contenu non reconnu)."; + this.importError = this.translate.instant('gameSystemEdit.importNoRules'); return; } - const ocr = ocrPageCount > 0 ? ` (dont ${ocrPageCount} page(s) via OCR)` : ''; - this.importNote = `${added} section(s) proposée(s) depuis ${pageCount} page(s)${ocr}. ` - + `Relisez et ajustez ci-dessous avant d'enregistrer.`; + const ocr = ocrPageCount > 0 + ? this.translate.instant('gameSystemEdit.importOcrSuffix', { count: ocrPageCount }) + : ''; + this.importNote = this.translate.instant('gameSystemEdit.importNote', { + added, + pages: pageCount, + ocr + }); } private resetImportProgress(): void { diff --git a/web/src/app/game-systems/game-systems.component.html b/web/src/app/game-systems/game-systems.component.html index ba85e5d..a9f88f8 100644 --- a/web/src/app/game-systems/game-systems.component.html +++ b/web/src/app/game-systems/game-systems.component.html @@ -2,8 +2,8 @@
-

Systèmes de JDR

-

Les règles que l'IA connaîtra pour vos campagnes

+

{{ 'gameSystems.heroTitle' | translate }}

+

{{ 'gameSystems.heroSubtitle' | translate }}

@@ -13,18 +13,18 @@

{{ gs.name }}

-
-

{{ gs.description || '(Pas de description)' }}

+

{{ gs.description || ('gameSystems.noDescription' | translate) }}

@@ -34,12 +34,12 @@
-

Nouveau système

-

Saisir les règles d'un JDR (markdown)

+

{{ 'gameSystems.newSystem' | translate }}

+

{{ 'gameSystems.newSystemSubtitle' | translate }}

-

💡 Astuce : organisez vos règles par sections avec des titres ## Combat, ## Classes, ## Lore… Le système injectera les sections pertinentes selon ce que l'IA doit générer.

+

diff --git a/web/src/app/game-systems/game-systems.component.ts b/web/src/app/game-systems/game-systems.component.ts index 65428c6..cb4a883 100644 --- a/web/src/app/game-systems/game-systems.component.ts +++ b/web/src/app/game-systems/game-systems.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { GameSystemService } from '../services/game-system.service'; import { LayoutService } from '../services/layout.service'; import { GameSystem } from '../services/game-system.model'; @@ -9,7 +10,7 @@ import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.se @Component({ selector: 'app-game-systems', - imports: [LucideAngularModule], + imports: [LucideAngularModule, TranslatePipe], templateUrl: './game-systems.component.html', styleUrls: ['./game-systems.component.scss'] }) @@ -25,7 +26,8 @@ export class GameSystemsComponent implements OnInit { private router: Router, private gameSystemService: GameSystemService, private confirmDialog: ConfirmDialogService, - private layoutService: LayoutService + private layoutService: LayoutService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -54,10 +56,10 @@ export class GameSystemsComponent implements OnInit { event.stopPropagation(); if (!system.id) return; this.confirmDialog.confirm({ - title: 'Supprimer le système', - message: `Supprimer le système "${system.name}" ?`, - details: ['Les campagnes qui l\'utilisent ne seront plus associées à aucun système.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('gameSystems.deleteTitle'), + message: this.translate.instant('gameSystems.deleteMessage', { name: system.name }), + details: [this.translate.instant('gameSystems.deleteDetail')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok || !system.id) return; diff --git a/web/src/app/lore/folder-view/folder-view.component.html b/web/src/app/lore/folder-view/folder-view.component.html index 870b442..bf79ec4 100644 --- a/web/src/app/lore/folder-view/folder-view.component.html +++ b/web/src/app/lore/folder-view/folder-view.component.html @@ -1,7 +1,7 @@ @if (node) {
-
- -
-

Sous-dossiers

+

{{ 'folderView.subfolders' | translate }}

@if (subfolders.length > 0) { @@ -59,17 +59,17 @@ } @if (subfolders.length === 0) {
-

Aucun sous-dossier.

+

{{ 'folderView.noSubfolders' | translate }}

}
-

Pages

+

{{ 'folderView.pages' | translate }}

@if (pages.length > 0) { @@ -84,7 +84,7 @@ } @if (pages.length === 0) {
-

Aucune page dans ce dossier.

+

{{ 'folderView.noPages' | translate }}

}
diff --git a/web/src/app/lore/folder-view/folder-view.component.ts b/web/src/app/lore/folder-view/folder-view.component.ts index 7c2aaf3..8ba10a1 100644 --- a/web/src/app/lore/folder-view/folder-view.component.ts +++ b/web/src/app/lore/folder-view/folder-view.component.ts @@ -3,6 +3,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; import { PageService } from '../../services/page.service'; @@ -24,7 +25,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog */ @Component({ selector: 'app-folder-view', - imports: [LucideAngularModule], + imports: [LucideAngularModule, TranslatePipe], templateUrl: './folder-view.component.html', styleUrls: ['./folder-view.component.scss'] }) @@ -53,7 +54,8 @@ export class FolderViewComponent implements OnInit, OnDestroy { private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -146,20 +148,24 @@ export class FolderViewComponent implements OnInit, OnDestroy { this.loreService.getLoreNodeDeletionImpact(this.folderId).subscribe({ next: impact => { const parts: string[] = []; - if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`); - if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`); + if (impact.folders > 0) parts.push(this.translate.instant( + impact.folders > 1 ? 'folderView.impact.subfoldersPlural' : 'folderView.impact.subfolders', + { n: impact.folders })); + if (impact.pages > 0) parts.push(this.translate.instant( + impact.pages > 1 ? 'folderView.impact.pagesPlural' : 'folderView.impact.pages', + { n: impact.pages })); const details: string[] = []; if (parts.length) { - details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`); + details.push(this.translate.instant('folderView.impact.alsoDeletes', { items: parts.join(', ') })); } - details.push('Cette action est irréversible.'); + details.push(this.translate.instant('folderView.impact.irreversible')); this.confirmDialog.confirm({ - title: 'Supprimer le dossier', - message: `Supprimer le dossier "${node.name}" ?`, + title: this.translate.instant('folderView.deleteConfirmTitle'), + message: this.translate.instant('folderView.deleteConfirmMessage', { name: node.name }), details, - confirmLabel: 'Supprimer', + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/lore/lore-create/lore-create.component.html b/web/src/app/lore/lore-create/lore-create.component.html index fd8716e..2b7a32c 100644 --- a/web/src/app/lore/lore-create/lore-create.component.html +++ b/web/src/app/lore/lore-create/lore-create.component.html @@ -2,7 +2,7 @@ @@ -28,19 +28,19 @@ @if (editing) {
- +
- +
@@ -49,10 +49,10 @@ @if (!editing) {
-

Dossiers

+

{{ 'loreDetail.folders' | translate }}

- - + +
- + @if (templates.length) { } @else {

- Aucun template défini pour ce Lore. - Créer un template d'abord. + {{ 'pageCreate.noTemplates' | translate }} + {{ 'pageCreate.createTemplate' | translate }} {{ 'pageCreate.firstSuffix' | translate }}

} @@ -60,30 +60,27 @@
- + @if (nodes.length) { -

La page sera créée dans ce dossier

+

{{ 'pageCreate.nodeHint' | translate }}

} @else {

- Aucun dossier dans ce Lore. - Créer un dossier d'abord. + {{ 'pageCreate.noNodes' | translate }} + {{ 'pageCreate.createNode' | translate }} {{ 'pageCreate.firstSuffix' | translate }}

}
-
- 💡 Option 1 : Créer la page vide, puis remplir les champs manuellement.
- 💡 Option 2 : Créer avec l'IA pour dialoguer avec un assistant qui pré-remplira les champs. -
+
@if (wizardError) { @@ -92,13 +89,13 @@
- + - +
diff --git a/web/src/app/lore/page-create/page-create.component.ts b/web/src/app/lore/page-create/page-create.component.ts index af1eb34..8313385 100644 --- a/web/src/app/lore/page-create/page-create.component.ts +++ b/web/src/app/lore/page-create/page-create.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { LucideAngularModule, FileText, Sparkles, Plus } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; import { PageService } from '../../services/page.service'; @@ -26,7 +27,7 @@ import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-d */ @Component({ selector: 'app-page-create', - imports: [ReactiveFormsModule, RouterModule, LucideAngularModule, AiChatDrawerComponent], + imports: [ReactiveFormsModule, RouterModule, LucideAngularModule, TranslatePipe, AiChatDrawerComponent], templateUrl: './page-create.component.html', styleUrls: ['./page-create.component.scss'] }) @@ -53,13 +54,9 @@ export class PageCreateComponent implements OnInit, OnDestroy { /** Erreur de parsing du bloc — affichée sous le drawer. */ wizardError: string | null = null; /** Action primaire du wizard : applique les valeurs extraites et crée la page. */ - readonly wizardPrimaryAction: ChatPrimaryAction = { label: 'Appliquer et créer la page' }; + readonly wizardPrimaryAction: ChatPrimaryAction; /** Suggestions rapides orientées "affiner le résultat" (mode wizard). */ - readonly wizardSuggestions: string[] = [ - 'Rends la description plus courte', - 'Ajoute un trait distinctif marquant', - 'Donne un ton plus sombre' - ]; + readonly wizardSuggestions: string[]; constructor( private fb: FormBuilder, @@ -69,16 +66,23 @@ export class PageCreateComponent implements OnInit, OnDestroy { private templateService: TemplateService, private pageService: PageService, private layoutService: LayoutService, - private pageTitleService: PageTitleService + private pageTitleService: PageTitleService, + private translate: TranslateService ) { this.form = this.fb.group({ title: ['', Validators.required], nodeId: ['', Validators.required] }); + this.wizardPrimaryAction = { label: this.translate.instant('pageCreate.wizardPrimaryAction') }; + this.wizardSuggestions = [ + this.translate.instant('pageCreate.wizardSuggestion1'), + this.translate.instant('pageCreate.wizardSuggestion2'), + this.translate.instant('pageCreate.wizardSuggestion3') + ]; } ngOnInit(): void { - this.pageTitleService.set('Nouvelle page'); + this.pageTitleService.set(this.translate.instant('pageCreate.pageTitle')); this.loreId = this.route.snapshot.paramMap.get('loreId')!; this.preselectedNodeId = this.route.snapshot.paramMap.get('nodeId'); @@ -248,12 +252,12 @@ export class PageCreateComponent implements OnInit, OnDestroy { */ applyWizardAndCreate(): void { if (!this.canSubmit || !this.lastWizardReply) { - this.wizardError = "L'assistant n'a pas encore répondu. Décrivez d'abord votre idée."; + this.wizardError = this.translate.instant('pageCreate.errorNoReply'); return; } const values = this.extractValuesBlock(this.lastWizardReply); if (!values) { - this.wizardError = "Impossible d'extraire les valeurs. Demandez à l'assistant de proposer à nouveau."; + this.wizardError = this.translate.instant('pageCreate.errorNoValues'); return; } this.wizardError = null; @@ -271,10 +275,10 @@ export class PageCreateComponent implements OnInit, OnDestroy { const updated = { ...created, values }; this.pageService.update(created.id!, updated).subscribe({ next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id, 'edit']), - error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.' + error: () => this.wizardError = this.translate.instant('pageCreate.errorApplyValues') }); }, - error: () => this.wizardError = 'Erreur lors de la création de la page.' + error: () => this.wizardError = this.translate.instant('pageCreate.errorCreate') }); } @@ -313,8 +317,8 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués. /** Welcome message contextualisé au template choisi. */ get wizardWelcome(): string { const tpl = this.selectedTemplate; - if (!tpl) return 'Décrivez ce que vous souhaitez créer.'; - return `Super, on va créer une page "${tpl.name}" ! Décrivez-la-moi en quelques mots — contexte, rôle, traits marquants — et je proposerai des valeurs pour chaque champ.`; + if (!tpl) return this.translate.instant('pageCreate.wizardWelcomeEmpty'); + return this.translate.instant('pageCreate.wizardWelcome', { name: tpl.name }); } /** diff --git a/web/src/app/lore/page-edit/page-edit.component.html b/web/src/app/lore/page-edit/page-edit.component.html index b1eb2d1..574ac67 100644 --- a/web/src/app/lore/page-edit/page-edit.component.html +++ b/web/src/app/lore/page-edit/page-edit.component.html @@ -4,48 +4,48 @@ @if (aiError) { }
- +
- + -

Déplacez cette page dans un autre dossier

+

{{ 'pageEdit.folderHint' | translate }}

@if (template?.fields?.length) { -

Champs

+

{{ 'pageEdit.fields' | translate }}

@for (field of template!.fields; track field) { @if (field.type === 'TEXT') { @@ -55,7 +55,7 @@ [(ngModel)]="values[field.name]" [name]="'value_' + field.name" rows="4" - [placeholder]="'Valeur pour ' + field.name + '...'"> + [placeholder]="'pageEdit.valuePlaceholder' | translate:{ field: field.name }">
} @@ -87,7 +87,7 @@ } @if (!(field.labels ?? []).length) { -

Aucun libellé défini dans le template pour ce champ.

+

{{ 'pageEdit.noLabels' | translate }}

} @@ -122,7 +122,7 @@
@@ -132,28 +132,28 @@
JetRésultat
{{ 'randomTableView.colRoll' | translate }}{{ 'randomTableView.colResult' | translate }}
} @else { -

Aucune colonne définie dans le template pour ce tableau.

+

{{ 'pageEdit.noColumns' | translate }}

}
} } } -

Tags

+

{{ 'pageEdit.tags' | translate }}

+ [placeholder]="'pageEdit.tagsPlaceholder' | translate"> -

Mots-clés libres pour classer et retrouver cette page

+

{{ 'pageEdit.tagsHint' | translate }}

-

Pages liées

+

{{ 'pageEdit.relatedPages' | translate }}

-

Cliquez sur un lien pour ouvrir la page associée

+

{{ 'pageEdit.relatedPagesHint' | translate }}

-

Notes privées

+

{{ 'pageEdit.privateNotes' | translate }}

-

Visibles uniquement par vous. Utiles pour préparer vos sessions.

+

{{ 'pageEdit.privateNotesHint' | translate }}

diff --git a/web/src/app/lore/page-edit/page-edit.component.ts b/web/src/app/lore/page-edit/page-edit.component.ts index 9ec92c8..371f631 100644 --- a/web/src/app/lore/page-edit/page-edit.component.ts +++ b/web/src/app/lore/page-edit/page-edit.component.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule, Sparkles, Plus, Trash2 } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; import { PageService } from '../../services/page.service'; @@ -36,7 +37,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog */ @Component({ selector: 'app-page-edit', - imports: [FormsModule, RouterLink, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent], + imports: [FormsModule, RouterLink, LucideAngularModule, TranslatePipe, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent], templateUrl: './page-edit.component.html', styleUrls: ['./page-edit.component.scss'] }) @@ -81,13 +82,9 @@ export class PageEditComponent implements OnInit, OnDestroy { /** Phase b5 — drawer chat IA (conversationnel). */ chatOpen = false; /** Action primaire dans le chat : déclenche le one-shot b4 (remplissage automatique). */ - readonly chatPrimaryAction: ChatPrimaryAction = { label: 'Remplir automatiquement tous les champs' }; + readonly chatPrimaryAction: ChatPrimaryAction; /** Suggestions rapides hardcodées (MVP). */ - readonly chatQuickSuggestions: string[] = [ - "Étoffe l'histoire de cette page", - 'Suggère des liens avec d\'autres pages du Lore', - 'Propose une intrigue secondaire' - ]; + readonly chatQuickSuggestions: string[]; constructor( private route: ActivatedRoute, @@ -97,8 +94,16 @@ export class PageEditComponent implements OnInit, OnDestroy { private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService - ) {} + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) { + this.chatPrimaryAction = { label: this.translate.instant('pageEdit.chatPrimaryAction') }; + this.chatQuickSuggestions = [ + this.translate.instant('pageEdit.chatSuggestion1'), + this.translate.instant('pageEdit.chatSuggestion2'), + this.translate.instant('pageEdit.chatSuggestion3') + ]; + } ngOnInit(): void { this.loreId = this.route.snapshot.paramMap.get('loreId')!; @@ -267,8 +272,8 @@ export class PageEditComponent implements OnInit, OnDestroy { error: (err) => { this.aiLoading = false; this.aiError = err?.status === 502 - ? "L'assistant IA est injoignable. V\u00e9rifiez que le service Brain tourne." - : "\u00c9chec de la g\u00e9n\u00e9ration IA. R\u00e9essayez dans un instant."; + ? this.translate.instant('pageEdit.aiUnreachable') + : this.translate.instant('pageEdit.aiFailed'); } }); } @@ -293,9 +298,9 @@ export class PageEditComponent implements OnInit, OnDestroy { delete(): void { if (!this.page) return; this.confirmDialog.confirm({ - title: 'Supprimer la page', - message: `Supprimer la page "${this.page.title}" ?`, - confirmLabel: 'Supprimer', + title: this.translate.instant('pageEdit.deleteTitle'), + message: this.translate.instant('pageEdit.deleteMessage', { title: this.page.title }), + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok || !this.page) return; diff --git a/web/src/app/lore/page-view/page-view.component.html b/web/src/app/lore/page-view/page-view.component.html index c7e16fa..042b6fb 100644 --- a/web/src/app/lore/page-view/page-view.component.html +++ b/web/src/app/lore/page-view/page-view.component.html @@ -4,16 +4,16 @@

{{ page.title }}

-

{{ template?.name || 'Page' }}

+

{{ template?.name || ('pageView.pageFallback' | translate) }}

-
@@ -27,7 +27,7 @@ @if (valueOf(field.name)) {

{{ valueOf(field.name) }}

} @else { -

Non renseigné

+

{{ 'pageView.notFilled' | translate }}

}
} @@ -53,7 +53,7 @@ }
} @else { -

Non renseigné

+

{{ 'pageView.notFilled' | translate }}

}
} @@ -80,7 +80,7 @@ } @else { -

Non renseigné

+

{{ 'pageView.notFilled' | translate }}

}
} @@ -89,7 +89,7 @@ @if ((page.tags?.length ?? 0) > 0) {
-

Tags

+

{{ 'pageView.tags' | translate }}

@for (tag of page.tags; track tag) { {{ tag }} @@ -100,7 +100,7 @@ @if ((page.relatedPageIds?.length ?? 0) > 0) {
-

Pages liées

+

{{ 'pageView.relatedPages' | translate }}

diff --git a/web/src/app/lore/page-view/page-view.component.ts b/web/src/app/lore/page-view/page-view.component.ts index 3910f19..8d54e4f 100644 --- a/web/src/app/lore/page-view/page-view.component.ts +++ b/web/src/app/lore/page-view/page-view.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin } from 'rxjs'; import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; import { PageService } from '../../services/page.service'; @@ -28,7 +29,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog */ @Component({ selector: 'app-page-view', - imports: [RouterModule, LucideAngularModule, BreadcrumbComponent, ImageGalleryComponent], + imports: [RouterModule, LucideAngularModule, TranslatePipe, BreadcrumbComponent, ImageGalleryComponent], templateUrl: './page-view.component.html', styleUrls: ['./page-view.component.scss'] }) @@ -52,7 +53,8 @@ export class PageViewComponent implements OnInit, OnDestroy { private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -131,7 +133,7 @@ export class PageViewComponent implements OnInit, OnDestroy { /** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */ titleOfRelated(pageId: string): string { - return this.allPages.find(p => p.id === pageId)?.title ?? '(page supprimée)'; + return this.allPages.find(p => p.id === pageId)?.title ?? this.translate.instant('pageView.deletedPage'); } editMode(): void { @@ -146,10 +148,10 @@ export class PageViewComponent implements OnInit, OnDestroy { if (!this.page) return; const page = this.page; this.confirmDialog.confirm({ - title: 'Supprimer la page', - message: `Supprimer la page "${page.title}" ?`, - details: ['Cette action est irréversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('pageView.deleteTitle'), + message: this.translate.instant('pageView.deleteMessage', { title: page.title }), + details: [this.translate.instant('pageView.deleteIrreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/lore/template-create/template-create.component.html b/web/src/app/lore/template-create/template-create.component.html index 3c2285c..daba2c5 100644 --- a/web/src/app/lore/template-create/template-create.component.html +++ b/web/src/app/lore/template-create/template-create.component.html @@ -1,8 +1,8 @@
@@ -11,32 +11,32 @@
- - + +
- - + +
- + @if (nodes.length) { -

Les pages créées avec ce template seront placées dans ce dossier

+

{{ 'templateCreate.defaultNodeHint' | translate }}

} @else {

- Aucun dossier dans ce Lore. + {{ 'templateCreate.noNodes' | translate }} Créer un dossier d'abord. + (click)="saveDraft()">{{ 'templateCreate.createNode' | translate }} {{ 'templateCreate.firstSuffix' | translate }}

} @@ -47,7 +47,7 @@
- +
    @for (f of fields; track f; let i = $index; let first = $first; let last = $last) { @@ -56,13 +56,13 @@
@@ -78,11 +78,11 @@ [ngModel]="f.type" [ngModelOptions]="{ standalone: true }" (ngModelChange)="setFieldType(i, $event)" - title="Type du champ"> - - - - + [title]="'templateCreate.fieldTypeTitle' | translate"> + + + + @if (f.type === 'IMAGE') { } - @@ -111,22 +111,20 @@ [ngModel]="lbl" [ngModelOptions]="{ standalone: true }" (ngModelChange)="updateLabel(f, li, $event)" - [placeholder]="f.type === 'TABLE' ? 'Colonne' : 'Libellé'" - [attr.aria-label]="(f.type === 'TABLE' ? 'Colonne ' : 'Libellé ') + (li + 1)" /> - } @if (!(f.labels ?? []).length) { - {{ f.type === 'TABLE' - ? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)' - : 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }} + {{ (f.type === 'TABLE' ? 'templateCreate.tableLabelsHint' : 'templateCreate.kvLabelsHint') | translate }} } @@ -139,31 +137,31 @@ type="text" [(ngModel)]="newFieldName" [ngModelOptions]="{ standalone: true }" - placeholder="+ Ajouter un champ" + [placeholder]="'templateCreate.addFieldPlaceholder' | translate" (keydown.enter)="$event.preventDefault(); addField()" /> -
-

Texte = libre + utilisable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).

+

{{ 'templateCreate.fieldsHelp' | translate }}

- - + +
diff --git a/web/src/app/lore/template-create/template-create.component.ts b/web/src/app/lore/template-create/template-create.component.ts index 38e0fc2..3d46c93 100644 --- a/web/src/app/lore/template-create/template-create.component.ts +++ b/web/src/app/lore/template-create/template-create.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; import { PageService } from '../../services/page.service'; @@ -20,7 +21,7 @@ import { popReturnTo } from '../return-stack.helper'; */ @Component({ selector: 'app-template-create', - imports: [FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule], + imports: [FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule, TranslatePipe], templateUrl: './template-create.component.html', styleUrls: ['./template-create.component.scss'] }) diff --git a/web/src/app/lore/template-edit/template-edit.component.html b/web/src/app/lore/template-edit/template-edit.component.html index 62dc604..6b8e7a5 100644 --- a/web/src/app/lore/template-edit/template-edit.component.html +++ b/web/src/app/lore/template-edit/template-edit.component.html @@ -3,38 +3,38 @@
- +
- + -

Les pages créées avec ce template seront placées dans ce dossier par défaut

+

{{ 'templateEdit.defaultNodeHint' | translate }}

- +
- +
    @for (f of fields; track f; let i = $index; let first = $first; let last = $last) {
  • @@ -42,13 +42,13 @@
@@ -66,11 +66,11 @@ [ngModel]="f.type" [ngModelOptions]="{ standalone: true }" (ngModelChange)="setFieldType(i, $event)" - title="Type du champ"> - - - - + [title]="'templateEdit.fieldTypeTitle' | translate"> + + + + @if (f.type === 'IMAGE') { } - @@ -99,22 +99,20 @@ [ngModel]="lbl" [ngModelOptions]="{ standalone: true }" (ngModelChange)="updateLabel(f, li, $event)" - [placeholder]="f.type === 'TABLE' ? 'Colonne' : 'Libellé'" - [attr.aria-label]="(f.type === 'TABLE' ? 'Colonne ' : 'Libellé ') + (li + 1)" /> - } @if (!(f.labels ?? []).length) { - {{ f.type === 'TABLE' - ? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)' - : 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }} + {{ (f.type === 'TABLE' ? 'templateEdit.tableLabelsHint' : 'templateEdit.kvLabelsHint') | translate }} } @@ -126,23 +124,23 @@ type="text" [(ngModel)]="newFieldName" [ngModelOptions]="{ standalone: true }" - placeholder="+ Ajouter un champ" + [placeholder]="'templateEdit.addFieldPlaceholder' | translate" (keydown.enter)="$event.preventDefault(); addField()" /> -
-

Texte = libre + generable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).

+

{{ 'templateEdit.fieldsHelp' | translate }}

diff --git a/web/src/app/lore/template-edit/template-edit.component.ts b/web/src/app/lore/template-edit/template-edit.component.ts index 06faa42..a8efe47 100644 --- a/web/src/app/lore/template-edit/template-edit.component.ts +++ b/web/src/app/lore/template-edit/template-edit.component.ts @@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, Subject } from 'rxjs'; import { switchMap, takeUntil } from 'rxjs/operators'; import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; import { PageService } from '../../services/page.service'; @@ -21,7 +22,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog */ @Component({ selector: 'app-template-edit', - imports: [FormsModule, ReactiveFormsModule, LucideAngularModule], + imports: [FormsModule, ReactiveFormsModule, LucideAngularModule, TranslatePipe], templateUrl: './template-edit.component.html', styleUrls: ['./template-edit.component.scss'] }) @@ -77,7 +78,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy { private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -196,9 +198,9 @@ export class TemplateEditComponent implements OnInit, OnDestroy { delete(): void { this.confirmDialog.confirm({ - title: 'Supprimer le template', - message: `Supprimer le template "${this.template?.name}" ?`, - confirmLabel: 'Supprimer', + title: this.translate.instant('templateEdit.deleteTitle'), + message: this.translate.instant('templateEdit.deleteMessage', { name: this.template?.name }), + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/services/ai-chat.service.ts b/web/src/app/services/ai-chat.service.ts index c5c1143..7601dc6 100644 --- a/web/src/app/services/ai-chat.service.ts +++ b/web/src/app/services/ai-chat.service.ts @@ -1,5 +1,6 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; /** * Un message d'une conversation IA (vue front). @@ -45,6 +46,7 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'n @Injectable({ providedIn: 'root' }) export class AiChatService { + private readonly translate = inject(TranslateService); private readonly loreEndpoint = '/api/ai/chat/stream'; private readonly campaignEndpoint = '/api/ai/chat/stream-campaign'; private readonly sessionEndpoint = '/api/ai/chat/stream-session'; @@ -237,9 +239,9 @@ export class AiChatService { private safeParseMessage(json: string): string { try { const obj = JSON.parse(json) as { message?: string }; - return obj.message ?? 'Erreur inconnue côté serveur.'; + return obj.message ?? this.translate.instant('services.unknownServerError'); } catch { - return json || 'Erreur inconnue côté serveur.'; + return json || this.translate.instant('services.unknownServerError'); } } } diff --git a/web/src/app/services/campaign-import.service.ts b/web/src/app/services/campaign-import.service.ts index d6f8888..f7571f5 100644 --- a/web/src/app/services/campaign-import.service.ts +++ b/web/src/app/services/campaign-import.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; import { CampaignImportApplyResult, CampaignImportProposal, @@ -16,7 +17,7 @@ import { */ @Injectable({ providedIn: 'root' }) export class CampaignImportService { - constructor(private http: HttpClient) {} + constructor(private http: HttpClient, private translate: TranslateService) {} importStructureStream(campaignId: string, file: File): Observable { return new Observable((subscriber) => { @@ -70,7 +71,7 @@ export class CampaignImportService { const dispatch = () => { const name = currentEvent ?? 'message'; if (name === 'error') { - let message = 'Échec de l\'import.'; + let message = this.translate.instant('services.importFailed'); try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ } terminated = true; subscriber.error(new Error(message)); @@ -120,7 +121,7 @@ export class CampaignImportService { if (currentEvent !== null || currentData !== '') dispatch(); if (!terminated) { subscriber.error(new Error( - 'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.')); + this.translate.instant('services.importInterrupted'))); } } catch (err) { if (!terminated) subscriber.error(err); diff --git a/web/src/app/services/campaign-sidebar.service.ts b/web/src/app/services/campaign-sidebar.service.ts index 30c1aff..52fca2a 100644 --- a/web/src/app/services/campaign-sidebar.service.ts +++ b/web/src/app/services/campaign-sidebar.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; import { forkJoin, Subscription } from 'rxjs'; import { CampaignService } from './campaign.service'; import { CharacterService } from './character.service'; @@ -30,7 +31,8 @@ export class CampaignSidebarService { private npcService: NpcService, private randomTableService: RandomTableService, private enemyService: EnemyService, - private layoutService: LayoutService + private layoutService: LayoutService, + private translate: TranslateService ) {} /** @@ -51,7 +53,7 @@ export class CampaignSidebarService { this.enemyService ) }).subscribe(({ campaign, allCampaigns, treeData }) => { - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId, this.translate)); }); } } diff --git a/web/src/app/services/game-system.service.ts b/web/src/app/services/game-system.service.ts index 77a50ee..05f0862 100644 --- a/web/src/app/services/game-system.service.ts +++ b/web/src/app/services/game-system.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model'; /** @@ -10,7 +11,7 @@ import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEve export class GameSystemService { private apiUrl = '/api/game-systems'; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient, private translate: TranslateService) {} getAll(): Observable { return this.http.get(this.apiUrl); @@ -101,7 +102,7 @@ export class GameSystemService { const dispatch = () => { const name = currentEvent ?? 'message'; if (name === 'error') { - let message = 'Échec de l\'import.'; + let message = this.translate.instant('services.importFailed'); try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ } terminated = true; subscriber.error(new Error(message)); @@ -151,7 +152,7 @@ export class GameSystemService { if (currentEvent !== null || currentData !== '') dispatch(); if (!terminated) { subscriber.error(new Error( - 'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.')); + this.translate.instant('services.importInterrupted'))); } } catch (err) { if (!terminated) subscriber.error(err); diff --git a/web/src/app/services/language.service.ts b/web/src/app/services/language.service.ts new file mode 100644 index 0000000..6b73b46 --- /dev/null +++ b/web/src/app/services/language.service.ts @@ -0,0 +1,101 @@ +import { Injectable, inject } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Langue affichée par l'interface. + * + * Source de vérité = ngx-translate (TranslateService). Ce service ajoute : + * - la liste des langues proposées dans le sélecteur (LANGUAGES) ; + * - la persistance du choix dans localStorage (par appareil) ; + * - la résolution de la langue initiale au démarrage (localStorage → langue + * du navigateur → repli français). + * + * Le changement est appliqué à chaud : translate.use() recharge le bon JSON et + * rafraîchit tous les pipes `translate` — aucun rechargement de page. + */ +export interface AppLanguage { + /** Code ISO court utilisé par les fichiers i18n (fr.json, en.json). */ + code: string; + /** Clé i18n du libellé natif affiché dans le sélecteur. */ + labelKey: string; + /** Drapeau emoji pour un repère visuel léger. */ + flag: string; +} + +const STORAGE_KEY = 'loremind.lang'; + +@Injectable({ providedIn: 'root' }) +export class LanguageService { + private readonly translate = inject(TranslateService); + + /** Langues proposées dans l'application. Ajouter une langue = une ligne ici + un JSON. */ + readonly languages: readonly AppLanguage[] = [ + { code: 'fr', labelKey: 'language.fr', flag: '🇫🇷' }, + { code: 'en', labelKey: 'language.en', flag: '🇬🇧' }, + ]; + + readonly defaultLang = 'fr'; + + /** + * Appelé une fois au démarrage (APP_INITIALIZER) : enregistre les langues + * connues, fixe le repli et applique la langue résolue avant le premier rendu. + */ + init(): Promise { + const codes = this.languages.map((l) => l.code); + this.translate.addLangs(codes); + this.translate.setFallbackLang(this.defaultLang); + + const lang = this.resolveInitialLang(); + // use() renvoie un Observable qui émet quand le JSON est chargé : on + // l'attend pour éviter un flash de clés non traduites au boot. + return new Promise((resolve) => { + this.translate.use(lang).subscribe({ + next: () => resolve(true), + error: () => resolve(false), + }); + }); + } + + /** Code de la langue active. */ + get current(): string { + return this.translate.getCurrentLang() || this.defaultLang; + } + + /** Change la langue à chaud et mémorise le choix. */ + use(code: string): void { + if (!this.languages.some((l) => l.code === code)) { + return; + } + this.translate.use(code); + this.persist(code); + } + + private resolveInitialLang(): string { + const stored = this.read(); + if (stored && this.languages.some((l) => l.code === stored)) { + return stored; + } + const browser = this.translate.getBrowserLang(); + if (browser && this.languages.some((l) => l.code === browser)) { + return browser; + } + return this.defaultLang; + } + + private read(): string | null { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + } + + private persist(code: string): void { + try { + localStorage.setItem(STORAGE_KEY, code); + } catch { + // localStorage indisponible (mode privé strict) : la langue reste valable + // pour la session courante, simplement non mémorisée. + } + } +} diff --git a/web/src/app/services/notebook.service.ts b/web/src/app/services/notebook.service.ts index fadd5c5..5272886 100644 --- a/web/src/app/services/notebook.service.ts +++ b/web/src/app/services/notebook.service.ts @@ -1,6 +1,7 @@ import { Injectable, NgZone } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model'; /** @@ -11,7 +12,7 @@ import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChat export class NotebookService { private readonly apiUrl = '/api/notebooks'; - constructor(private http: HttpClient, private zone: NgZone) {} + constructor(private http: HttpClient, private zone: NgZone, private translate: TranslateService) {} listByCampaign(campaignId: string): Observable { return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); @@ -143,7 +144,7 @@ export class NotebookService { this.zone.run(() => subscriber.complete()); } catch (err) { if ((err as Error).name !== 'AbortError') { - emit({ type: 'error', message: (err as Error).message || 'Erreur réseau' }); + emit({ type: 'error', message: (err as Error).message || this.translate.instant('services.networkError') }); } this.zone.run(() => subscriber.complete()); } diff --git a/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.html b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.html index c2fe3f3..b261e0c 100644 --- a/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.html +++ b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.html @@ -5,9 +5,9 @@ @if (messages.length === 0 && !currentAssistantText && !error) {
-

Pose une question à l'IA pendant la partie.

+

{{ 'sessionAiChatPanel.welcome' | translate }}

- Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal. + {{ 'sessionAiChatPanel.welcomeSub' | translate }}

} @@ -20,10 +20,10 @@ type="button" class="msg-action" [disabled]="!canSaveToJournal" - [title]="canSaveToJournal ? 'Ajouter cette réponse au journal' : 'Session terminée'" + [title]="(canSaveToJournal ? 'sessionAiChatPanel.saveReplyTitle' : 'sessionAiChatPanel.sessionEnded') | translate" (click)="onSaveToJournal(m.content)"> - Au journal + {{ 'sessionAiChatPanel.toJournal' | translate }} } @@ -47,7 +47,7 @@ [(ngModel)]="input" name="aiChatInput" rows="2" - [placeholder]="isStreaming ? 'L’IA répond…' : 'Demande une idée, un rebondissement, une description…'" + [placeholder]="(isStreaming ? 'sessionAiChatPanel.replying' : 'sessionAiChatPanel.inputPlaceholder') | translate" [disabled]="isStreaming" (keydown.control.enter)="send()"> @@ -56,7 +56,7 @@ class="btn-link" [disabled]="messages.length === 0 && !currentAssistantText" (click)="clearConversation()" - title="Effacer la conversation"> + [title]="'sessionAiChatPanel.clearConversation' | translate"> @@ -67,7 +67,7 @@ [disabled]="!input.trim()" (click)="send()"> - Envoyer + {{ 'sessionAiChatPanel.send' | translate }} } @@ -77,7 +77,7 @@ class="btn-secondary btn-send" (click)="cancelStream()"> - Stop + {{ 'sessionAiChatPanel.stop' | translate }} } diff --git a/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.ts b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.ts index 3e378fb..518fdcd 100644 --- a/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.ts +++ b/web/src/app/sessions/session-ai-chat-panel/session-ai-chat-panel.component.ts @@ -4,6 +4,7 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { LucideAngularModule, Send, Sparkles, Trash2, BookmarkPlus, Square } from 'lucide-angular'; @@ -23,7 +24,7 @@ import { AiChatService, ChatMessage } from '../../services/ai-chat.service'; */ @Component({ selector: 'app-session-ai-chat-panel', - imports: [FormsModule, LucideAngularModule], + imports: [FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './session-ai-chat-panel.component.html', styleUrls: ['./session-ai-chat-panel.component.scss'] }) @@ -50,7 +51,7 @@ export class SessionAiChatPanelComponent implements OnChanges, OnDestroy { private streamSub: Subscription | null = null; - constructor(private aiChat: AiChatService) {} + constructor(private aiChat: AiChatService, private translate: TranslateService) {} ngOnChanges(changes: SimpleChanges): void { // Reset complet si on change de session (changement d'instance jouée). @@ -87,8 +88,8 @@ export class SessionAiChatPanelComponent implements OnChanges, OnDestroy { } }, error: (err: unknown) => { - const message = err instanceof Error ? err.message : 'Erreur inconnue'; - this.error = `Erreur IA : ${message}`; + const message = err instanceof Error ? err.message : this.translate.instant('sessionAiChatPanel.unknownError'); + this.error = this.translate.instant('sessionAiChatPanel.aiError', { message }); this.isStreaming = false; this.streamSub = null; }, @@ -115,7 +116,7 @@ export class SessionAiChatPanelComponent implements OnChanges, OnDestroy { } // On garde ce qui a déjà été streamé : utile si l'IA partait dans le mur. if (this.currentAssistantText.trim()) { - this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText + ' [interrompu]' }]; + this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText + ' ' + this.translate.instant('sessionAiChatPanel.interrupted') }]; } this.currentAssistantText = ''; this.isStreaming = false; diff --git a/web/src/app/sessions/session-detail/session-detail.component.html b/web/src/app/sessions/session-detail/session-detail.component.html index f41b62c..385b861 100644 --- a/web/src/app/sessions/session-detail/session-detail.component.html +++ b/web/src/app/sessions/session-detail/session-detail.component.html @@ -2,7 +2,7 @@
- Retour à la campagne + {{ 'sessionDetail.backToCampaign' | translate }}
@@ -12,7 +12,7 @@ {{ session.name }} -
@@ -25,22 +25,22 @@ (keydown.enter)="saveRename()" (keydown.escape)="cancelRename()" autofocus /> - -
}
- {{ session.active ? 'En cours' : 'Terminée' }} + {{ (session.active ? 'sessionDetail.statusActive' : 'sessionDetail.statusEnded') | translate }} - Démarrée le {{ session.startedAt | date:'dd/MM/yyyy HH:mm' }} + {{ 'sessionDetail.startedOn' | translate:{ date: (session.startedAt | date:'dd/MM/yyyy HH:mm') } }} @if (session.endedAt) { - Terminée le {{ session.endedAt | date:'dd/MM/yyyy HH:mm' }} + {{ 'sessionDetail.endedOn' | translate:{ date: (session.endedAt | date:'dd/MM/yyyy HH:mm') } }} }
@@ -49,12 +49,12 @@ @if (session.active) { }
@@ -74,7 +74,7 @@ [style.--type-color]="entryTypeMeta[type].color" (click)="newEntryType = type"> - {{ entryTypeMeta[type].label }} + {{ typeLabel(type) }} } @@ -82,29 +82,29 @@ [(ngModel)]="newEntryContent" name="newEntryContent" rows="3" - [placeholder]="'Ajouter une ' + entryTypeMeta[newEntryType].label.toLowerCase() + '…'" + [placeholder]="newEntryPlaceholder()" (keydown.control.enter)="submitNewEntry()">
- Ctrl + Entrée pour ajouter + {{ 'sessionDetail.ctrlEnterHint' | translate }}
}
-

Journal de session

+

{{ 'sessionDetail.journalTitle' | translate }}

@if (entries.length === 0) {
-

Aucune entrée pour le moment.

+

{{ 'sessionDetail.noEntries' | translate }}

@if (session.active) {

- Saisis une note, un évènement ou un jet ci-dessus pour commencer le journal. + {{ 'sessionDetail.noEntriesHint' | translate }}

}
@@ -121,13 +121,13 @@ @if (editingEntryId !== entry.id) {
- {{ entryTypeMeta[entry.type].label }} + {{ typeLabel(entry.type) }} {{ entry.occurredAt | date:'HH:mm — dd/MM/yyyy' }}
- -
@@ -145,7 +145,7 @@ [style.--type-color]="entryTypeMeta[type].color" (click)="editEntryType = type"> - {{ entryTypeMeta[type].label }} + {{ typeLabel(type) }} }
@@ -158,14 +158,14 @@
} diff --git a/web/src/app/sessions/session-detail/session-detail.component.ts b/web/src/app/sessions/session-detail/session-detail.component.ts index 9d6dd66..d074a25 100644 --- a/web/src/app/sessions/session-detail/session-detail.component.ts +++ b/web/src/app/sessions/session-detail/session-detail.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { LucideAngularModule, LucideIconData, Dices, ArrowLeft, Square, Trash2, Pencil, Check, @@ -29,7 +30,7 @@ import { DiceRollResult } from '../session-dice-panel/session-dice-panel.compone */ @Component({ selector: 'app-session-detail', - imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink, SessionReferencePanelComponent], + imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, RouterLink, SessionReferencePanelComponent], templateUrl: './session-detail.component.html', styleUrls: ['./session-detail.component.scss'] }) @@ -80,9 +81,22 @@ export class SessionDetailComponent implements OnInit, OnDestroy { private entryService: SessionEntryService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} + /** Libellé traduit d'un type d'entrée (le modèle partagé reste en FR pour la donnée). */ + typeLabel(type: EntryType): string { + return this.translate.instant('sessionDetail.entryType.' + type); + } + + /** Placeholder de la zone de saisie, dépendant du type sélectionné. */ + newEntryPlaceholder(): string { + return this.translate.instant('sessionDetail.addEntryPlaceholder', { + type: this.typeLabel(this.newEntryType).toLowerCase() + }); + } + ngOnInit(): void { this.layoutService.hide(); this.route.paramMap.pipe( @@ -144,10 +158,10 @@ export class SessionDetailComponent implements OnInit, OnDestroy { if (!this.session || !this.session.active) return; const session = this.session; this.confirmDialog.confirm({ - title: 'Terminer la session ?', - message: `Marquer la session "${session.name}" comme terminée ?`, - details: ['Tu pourras toujours consulter son contenu après.'], - confirmLabel: 'Terminer', + title: this.translate.instant('sessionDetail.endConfirm.title'), + message: this.translate.instant('sessionDetail.endConfirm.message', { name: session.name }), + details: [this.translate.instant('sessionDetail.endConfirm.detail')], + confirmLabel: this.translate.instant('sessionDetail.endConfirm.confirmLabel'), variant: 'warning' }).then(ok => { if (!ok) return; @@ -162,17 +176,20 @@ export class SessionDetailComponent implements OnInit, OnDestroy { if (!this.session) return; const session = this.session; const entryCount = this.entries.length; + const entriesDetail = entryCount === 0 + ? this.translate.instant('sessionDetail.deleteConfirm.noEntries') + : entryCount === 1 + ? this.translate.instant('sessionDetail.deleteConfirm.entriesOne') + : this.translate.instant('sessionDetail.deleteConfirm.entriesMany', { n: entryCount }); const details = [ - entryCount > 0 - ? `${entryCount} entrée${entryCount > 1 ? 's' : ''} de journal sera également supprimée.` - : 'Aucune entrée de journal pour cette session.', - 'Cette action est irréversible.' + entriesDetail, + this.translate.instant('sessionDetail.deleteConfirm.irreversible') ]; this.confirmDialog.confirm({ - title: 'Supprimer la session ?', - message: `Supprimer définitivement la session "${session.name}" ?`, + title: this.translate.instant('sessionDetail.deleteConfirm.title'), + message: this.translate.instant('sessionDetail.deleteConfirm.message', { name: session.name }), details, - confirmLabel: 'Supprimer', + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; @@ -284,9 +301,9 @@ export class SessionDetailComponent implements OnInit, OnDestroy { if (!this.session) return; const session = this.session; this.confirmDialog.confirm({ - title: 'Supprimer cette entrée ?', - message: 'Cette entrée du journal sera définitivement supprimée.', - confirmLabel: 'Supprimer', + title: this.translate.instant('sessionDetail.deleteEntryConfirm.title'), + message: this.translate.instant('sessionDetail.deleteEntryConfirm.message'), + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/sessions/session-dice-panel/session-dice-panel.component.html b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.html index e3ef072..f234039 100644 --- a/web/src/app/sessions/session-dice-panel/session-dice-panel.component.html +++ b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.html @@ -15,26 +15,26 @@
@if (history.length > 0) {
- Derniers jets -
@@ -51,7 +51,7 @@ @@ -63,7 +63,7 @@ @if (history.length === 0) {

- Choisis un dé et lance. + {{ 'sessionDicePanel.placeholder' | translate }}

} diff --git a/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts index d549252..21c0794 100644 --- a/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts +++ b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { TranslatePipe } from '@ngx-translate/core'; import { LucideAngularModule, Dices, BookmarkPlus, Trash2 } from 'lucide-angular'; /** Faces de dés supportées par le roller. */ @@ -26,7 +27,7 @@ export interface DiceRollResult { */ @Component({ selector: 'app-session-dice-panel', - imports: [FormsModule, LucideAngularModule], + imports: [FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './session-dice-panel.component.html', styleUrls: ['./session-dice-panel.component.scss'] }) diff --git a/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.html b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.html index 12989a9..dc9ab15 100644 --- a/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.html +++ b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.html @@ -1,6 +1,6 @@
@if (loading) { -

Chargement…

+

{{ 'common.loading' | translate }}

} @@ -8,7 +8,7 @@
@if (catalogs.length === 0) {

- Aucun catalogue d'objets dans cette campagne. + {{ 'sessionItemCatalogsPanel.empty' | translate }}

} @for (c of catalogs; track c) { @@ -25,7 +25,7 @@ @if (selected) {

{{ selected.name }}

@for (g of groups; track g) { @@ -45,8 +45,8 @@
{{ it.description }}
}
} diff --git a/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts index 899bdb4..349c47f 100644 --- a/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts +++ b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts @@ -1,5 +1,6 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { LucideAngularModule, Package, BookmarkPlus, ChevronLeft } from 'lucide-angular'; import { catchError, of } from 'rxjs'; import { ItemCatalogService } from '../../services/item-catalog.service'; @@ -13,7 +14,7 @@ interface ItemGroup { category: string; items: CatalogItem[]; } */ @Component({ selector: 'app-session-item-catalogs-panel', - imports: [LucideAngularModule], + imports: [LucideAngularModule, TranslatePipe], templateUrl: './session-item-catalogs-panel.component.html', styleUrls: ['./session-item-catalogs-panel.component.scss'] }) diff --git a/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.html b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.html index f13820c..3d2b6a9 100644 --- a/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.html +++ b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.html @@ -1,6 +1,6 @@
@if (loading) { -

Chargement…

+

{{ 'common.loading' | translate }}

} @@ -8,7 +8,7 @@
@if (tables.length === 0) {

- Aucune table aléatoire dans cette campagne. Crée-en une depuis la sidebar. + {{ 'sessionRandomTablesPanel.empty' | translate }}

} @for (t of tables; track t) { @@ -25,12 +25,12 @@ @if (selected) {

{{ selected.name }}

@if (lastRoll) {
@@ -44,7 +44,7 @@ {{ matched.label }} } @if (!matched) { - aucune entrée + {{ 'sessionRandomTablesPanel.noEntry' | translate }} }
@if (matched?.detail) { @@ -52,12 +52,12 @@ }
diff --git a/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.ts b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.ts index 7671538..d1f162e 100644 --- a/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.ts +++ b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.ts @@ -1,5 +1,6 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { LucideAngularModule, Dices, BookmarkPlus, Sparkles, ChevronLeft } from 'lucide-angular'; import { catchError, of } from 'rxjs'; import { RandomTableService } from '../../services/random-table.service'; @@ -18,7 +19,7 @@ import { DiceRollResult } from '../session-dice-panel/session-dice-panel.compone */ @Component({ selector: 'app-session-random-tables-panel', - imports: [LucideAngularModule], + imports: [LucideAngularModule, TranslatePipe], templateUrl: './session-random-tables-panel.component.html', styleUrls: ['./session-random-tables-panel.component.scss'] }) @@ -41,7 +42,7 @@ export class SessionRandomTablesPanelComponent implements OnInit { matched: RandomTableEntry | null = null; improvising = false; - constructor(private service: RandomTableService) {} + constructor(private service: RandomTableService, private translate: TranslateService) {} ngOnInit(): void { if (!this.campaignId) return; @@ -79,7 +80,7 @@ export class SessionRandomTablesPanelComponent implements OnInit { /** Consigne le tirage au journal (entrée DICE_ROLL via la sortie `rolled`). */ addToJournal(): void { if (!this.canAddToJournal || !this.selected || !this.lastRoll) return; - const label = this.matched?.label ?? 'aucun résultat'; + const label = this.matched?.label ?? this.translate.instant('sessionRandomTablesPanel.noResult'); const result: DiceRollResult = { notation: this.selected.diceFormula, rolls: this.lastRoll.rolls, diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html index 314bf64..574d750 100644 --- a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html @@ -6,42 +6,42 @@ [class.ref-tab--active]="activeTab === 'ai'" (click)="selectTab('ai')"> - IA + {{ 'sessionReferencePanel.tabAi' | translate }} @@ -87,7 +87,7 @@ @if (activeTab === 'characters') {
@if (loadingChars) { -

Chargement…

+

{{ 'common.loading' | translate }}

} @if (!loadingChars) {
@@ -95,7 +95,7 @@

- Personnages joueurs + {{ 'sessionReferencePanel.playerCharacters' | translate }}

@for (c of characters; track c) {
@@ -139,12 +139,12 @@ @if (activeTab === 'scenes') {
@if (loadingTree) { -

Chargement…

+

{{ 'common.loading' | translate }}

} @if (!loadingTree && treeData) { @if (treeData.arcs.length === 0) {

- Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici. + {{ 'sessionReferencePanel.emptyScenes' | translate }}

} @for (arc of treeData.arcs; track arc) { diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts index e73b723..6d34746 100644 --- a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts @@ -1,5 +1,6 @@ import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular'; import { catchError, of } from 'rxjs'; import { CampaignService } from '../../services/campaign.service'; @@ -31,7 +32,7 @@ type TabId = 'dice' | 'tables' | 'objects' | 'characters' | 'scenes' | 'ai'; */ @Component({ selector: 'app-session-reference-panel', - imports: [LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent], + imports: [LucideAngularModule, TranslatePipe, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent], templateUrl: './session-reference-panel.component.html', styleUrls: ['./session-reference-panel.component.scss'] }) diff --git a/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.html b/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.html index ebaeb8c..817aa9e 100644 --- a/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.html +++ b/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.html @@ -15,7 +15,7 @@ @if (models.length > 0) {
- +
    @for (m of models; track m) {
  • @@ -23,7 +23,7 @@
  • @@ -37,27 +37,26 @@
diff --git a/web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.ts b/web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.ts index faa3d3f..7c68e8c 100644 --- a/web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.ts +++ b/web/src/app/shared/playthrough-flags-manager/playthrough-flags-manager.component.ts @@ -2,6 +2,7 @@ import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/cor import { FormsModule } from '@angular/forms'; import { LucideAngularModule } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; import { CampaignFlagService } from '../../services/campaign-flag.service'; import { PlaythroughFlagService } from '../../services/playthrough-flag.service'; import { forkJoin } from 'rxjs'; @@ -15,7 +16,7 @@ import { forkJoin } from 'rxjs'; */ @Component({ selector: 'app-playthrough-flags-manager', - imports: [FormsModule, LucideAngularModule], + imports: [FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './playthrough-flags-manager.component.html', styleUrls: ['./playthrough-flags-manager.component.scss'] }) diff --git a/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.html b/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.html index c432e18..df1b762 100644 --- a/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.html +++ b/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.html @@ -2,7 +2,7 @@ @if (prerequisites.length === 0) {

- Aucune condition — la quête est immédiatement disponible. + {{ 'prerequisiteEditor.empty' | translate }}

} @@ -13,14 +13,14 @@ @switch (p.kind) { @case ('QUEST_COMPLETED') { - Quête terminée : + {{ 'prerequisiteEditor.questCompletedLabel' | translate }} @case ('FLAG_SET') { - Quand le fait est vrai : + {{ 'prerequisiteEditor.flagSetLabel' | translate }} } } - @@ -65,20 +65,20 @@
@if (addMenuOpen) {
} diff --git a/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts b/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts index bb9194e..6cfdd67 100644 --- a/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts +++ b/web/src/app/shared/prerequisite-editor/prerequisite-editor.component.ts @@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; import { Chapter, Prerequisite } from '../../services/campaign.model'; /** @@ -13,7 +14,7 @@ import { Chapter, Prerequisite } from '../../services/campaign.model'; */ @Component({ selector: 'app-prerequisite-editor', - imports: [FormsModule, LucideAngularModule], + imports: [FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './prerequisite-editor.component.html', styleUrls: ['./prerequisite-editor.component.scss'] }) diff --git a/web/src/app/shared/quest-status-badge/quest-status-badge.component.ts b/web/src/app/shared/quest-status-badge/quest-status-badge.component.ts index 4d18d60..90ca34a 100644 --- a/web/src/app/shared/quest-status-badge/quest-status-badge.component.ts +++ b/web/src/app/shared/quest-status-badge/quest-status-badge.component.ts @@ -1,6 +1,7 @@ import { Component, Input } from '@angular/core'; import { LucideAngularModule, Lock, Circle, Play, CheckCircle2, LucideIconData } from 'lucide-angular'; +import { TranslateService } from '@ngx-translate/core'; import { QuestStatus } from '../../services/campaign.model'; /** @@ -19,6 +20,8 @@ export class QuestStatusBadgeComponent { /** Variante visuelle compacte (sans label) — utile pour les listes denses. */ @Input() compact = false; + constructor(private translate: TranslateService) {} + get icon(): LucideIconData { switch (this.status) { case 'LOCKED': return Lock; @@ -31,11 +34,11 @@ export class QuestStatusBadgeComponent { get label(): string { switch (this.status) { - case 'LOCKED': return 'Verrouillée'; - case 'IN_PROGRESS': return 'En cours'; - case 'COMPLETED': return 'Terminée'; + case 'LOCKED': return this.translate.instant('questStatusBadge.locked'); + case 'IN_PROGRESS': return this.translate.instant('questStatusBadge.inProgress'); + case 'COMPLETED': return this.translate.instant('questStatusBadge.completed'); case 'AVAILABLE': - default: return 'Disponible'; + default: return this.translate.instant('questStatusBadge.available'); } } diff --git a/web/src/app/shared/rooms-editor/rooms-editor.component.html b/web/src/app/shared/rooms-editor/rooms-editor.component.html index 13fca2b..07ca171 100644 --- a/web/src/app/shared/rooms-editor/rooms-editor.component.html +++ b/web/src/app/shared/rooms-editor/rooms-editor.component.html @@ -2,8 +2,7 @@ @if (rooms.length === 0) {

- Aucune pièce. La scène se comporte comme un beat narratif classique. - Ajoutez une première pièce pour la convertir en lieu explorable (donjon, crypte…). + {{ 'roomsEditor.empty' | translate }}

} @@ -21,16 +20,16 @@ [ngModel]="r.name" (ngModelChange)="patch(r, { name: $event })" (click)="$event.stopPropagation()" - placeholder="Nom de la pièce" /> + [placeholder]="'roomsEditor.roomNamePlaceholder' | translate" /> + [placeholder]="'roomsEditor.floorPlaceholder' | translate" [title]="'roomsEditor.floorTitle' | translate" />
- - - + +
@@ -39,14 +38,14 @@ @if (isExpanded(r.id)) {
- + + [placeholder]="'roomsEditor.descriptionPlaceholder' | translate">
- +
- + + [placeholder]="'roomsEditor.enemiesPlaceholder' | translate">
- + + [placeholder]="'roomsEditor.lootPlaceholder' | translate">
- + + [placeholder]="'roomsEditor.trapsPlaceholder' | translate">
- + + [placeholder]="'roomsEditor.gmNotesPlaceholder' | translate">
@if ((r.branches?.length ?? 0) > 0) {
    @@ -99,14 +98,14 @@ + [placeholder]="'roomsEditor.branchLabelPlaceholder' | translate" /> + [placeholder]="'roomsEditor.branchConditionPlaceholder' | translate" /> @@ -127,7 +126,7 @@ (click)="addBranch(r)" [disabled]="otherRooms(r).length === 0"> - Ajouter une sortie + {{ 'roomsEditor.addExit' | translate }}
@@ -139,7 +138,7 @@
diff --git a/web/src/app/shared/rooms-editor/rooms-editor.component.ts b/web/src/app/shared/rooms-editor/rooms-editor.component.ts index 0c99d54..bf8c1f1 100644 --- a/web/src/app/shared/rooms-editor/rooms-editor.component.ts +++ b/web/src/app/shared/rooms-editor/rooms-editor.component.ts @@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Plus, Trash2, ChevronDown, ChevronUp, GitBranch, X } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Room, RoomBranch } from '../../services/campaign.model'; import { Enemy } from '../../services/enemy.model'; import { EnemyLinkPickerComponent } from '../enemy-link-picker/enemy-link-picker.component'; @@ -17,7 +18,7 @@ import { EnemyLinkPickerComponent } from '../enemy-link-picker/enemy-link-picker */ @Component({ selector: 'app-rooms-editor', - imports: [FormsModule, LucideAngularModule, EnemyLinkPickerComponent], + imports: [FormsModule, LucideAngularModule, EnemyLinkPickerComponent, TranslatePipe], templateUrl: './rooms-editor.component.html', styleUrls: ['./rooms-editor.component.scss'] }) @@ -30,6 +31,8 @@ export class RoomsEditorComponent { @Input() campaignId = ''; @Output() roomsChange = new EventEmitter(); + constructor(private translate: TranslateService) {} + readonly Plus = Plus; readonly Trash2 = Trash2; readonly ChevronDown = ChevronDown; @@ -57,7 +60,7 @@ export class RoomsEditorComponent { : 0; const newRoom: Room = { id: this.newId(), - name: `Salle ${this.rooms.length + 1}`, + name: this.translate.instant('roomsEditor.defaultRoomName', { n: this.rooms.length + 1 }), order: nextOrder, branches: [] }; @@ -89,7 +92,7 @@ export class RoomsEditorComponent { addBranch(room: Room): void { const others = this.rooms.filter(r => r.id !== room.id); const branch: RoomBranch = { - label: 'Porte', + label: this.translate.instant('roomsEditor.defaultBranchLabel'), targetRoomId: others[0]?.id ?? '', condition: '' }; diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html index 4fd56db..6e24b2c 100644 --- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html +++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html @@ -12,7 +12,7 @@ type="button" class="sidebar-title sidebar-title--link" [class.active]="isTitleActive()" - title="Retour à l'accueil de la campagne" + [title]="'secondarySidebar.backToCampaignHome' | translate" (click)="clickTitle()"> {{ title }} @@ -149,6 +149,6 @@ @if (!isCollapsed) {
+ [title]="'secondarySidebar.resizeHandle' | translate">
} diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts index d8f0ac5..b53bd8a 100644 --- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts +++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts @@ -1,13 +1,14 @@ import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, Home, LucideIconData } from 'lucide-angular'; import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService } from '../../services/layout.service'; import { resolveIcon } from '../../lore/lore-icons'; @Component({ selector: 'app-secondary-sidebar', - imports: [CommonModule, LucideAngularModule], + imports: [CommonModule, LucideAngularModule, TranslatePipe], templateUrl: './secondary-sidebar.component.html', styleUrls: ['./secondary-sidebar.component.scss'] }) diff --git a/web/src/app/shared/single-image-picker/single-image-picker.component.html b/web/src/app/shared/single-image-picker/single-image-picker.component.html index a2d518c..c2f15e2 100644 --- a/web/src/app/shared/single-image-picker/single-image-picker.component.html +++ b/web/src/app/shared/single-image-picker/single-image-picker.component.html @@ -2,7 +2,7 @@
@if (imageId) { - } @else { diff --git a/web/src/app/shared/single-image-picker/single-image-picker.component.ts b/web/src/app/shared/single-image-picker/single-image-picker.component.ts index 9eadfb1..40d65d9 100644 --- a/web/src/app/shared/single-image-picker/single-image-picker.component.ts +++ b/web/src/app/shared/single-image-picker/single-image-picker.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; import { ImageService } from '../../services/image.service'; import { Image } from '../../services/image.model'; import { ImageUploaderComponent } from '../image-uploader/image-uploader.component'; @@ -21,7 +22,7 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone */ @Component({ selector: 'app-single-image-picker', - imports: [LucideAngularModule, ImageUploaderComponent], + imports: [LucideAngularModule, ImageUploaderComponent, TranslatePipe], templateUrl: './single-image-picker.component.html', styleUrls: ['./single-image-picker.component.scss'] }) diff --git a/web/src/app/shared/template-fields-editor/template-fields-editor.component.html b/web/src/app/shared/template-fields-editor/template-fields-editor.component.html index 426a76e..85511d0 100644 --- a/web/src/app/shared/template-fields-editor/template-fields-editor.component.html +++ b/web/src/app/shared/template-fields-editor/template-fields-editor.component.html @@ -1,6 +1,6 @@
-

{{ label }}

+

{{ label || ('templateFieldsEditor.defaultLabel' | translate) }}

@if (hint) {

{{ hint }}

} @@ -11,10 +11,10 @@
- -
@@ -24,7 +24,7 @@ [(ngModel)]="f.name" [name]="'name-' + i" (ngModelChange)="onFieldChanged()" - placeholder="Nom du champ (ex: Histoire, PV...)" + [placeholder]="'templateFieldsEditor.fieldNamePlaceholder' | translate" /> -
@@ -82,7 +82,7 @@
} @@ -91,13 +91,13 @@ @if (fields.length === 0) {
- Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous. + {{ 'templateFieldsEditor.empty' | translate }}
}
- Ajouter : + {{ 'templateFieldsEditor.addPrefix' | translate }} @for (s of suggestions; track s) {
diff --git a/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts b/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts index dea4d40..8bc939c 100644 --- a/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts +++ b/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts @@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash, ListOrdered, X } from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TemplateField, FieldType, ImageLayout } from '../../services/template.model'; /** @@ -16,7 +17,7 @@ import { TemplateField, FieldType, ImageLayout } from '../../services/template.m */ @Component({ selector: 'app-template-fields-editor', - imports: [FormsModule, LucideAngularModule], + imports: [FormsModule, LucideAngularModule, TranslatePipe], templateUrl: './template-fields-editor.component.html', styleUrls: ['./template-fields-editor.component.scss'] }) @@ -37,27 +38,33 @@ export class TemplateFieldsEditorComponent { /** Suggestions de noms de champs (chips ajout rapide). */ @Input() suggestions: string[] = []; - /** Label de la section (ex: "Champs de la fiche PJ"). */ - @Input() label = 'Champs du template'; + /** Label de la section (ex: "Champs de la fiche PJ"). Si vide, un libellé par défaut traduit est affiché. */ + @Input() label = ''; /** Hint affichee sous le label. */ @Input() hint?: string; @Output() fieldsChange = new EventEmitter(); - readonly typeOptions: { value: FieldType; label: string }[] = [ - { value: 'TEXT', label: 'Texte' }, - { value: 'NUMBER', label: 'Nombre' }, - { value: 'IMAGE', label: 'Image(s)' }, - { value: 'KEY_VALUE_LIST', label: 'Liste cle/valeur' } - ]; + constructor(private translate: TranslateService) {} - readonly layoutOptions: { value: ImageLayout; label: string }[] = [ - { value: 'GALLERY', label: 'Galerie' }, - { value: 'HERO', label: 'Bandeau' }, - { value: 'MASONRY', label: 'Mosaique' }, - { value: 'CAROUSEL', label: 'Carrousel' } - ]; + get typeOptions(): { value: FieldType; label: string }[] { + return [ + { value: 'TEXT', label: this.translate.instant('templateFieldsEditor.typeText') }, + { value: 'NUMBER', label: this.translate.instant('templateFieldsEditor.typeNumber') }, + { value: 'IMAGE', label: this.translate.instant('templateFieldsEditor.typeImage') }, + { value: 'KEY_VALUE_LIST', label: this.translate.instant('templateFieldsEditor.typeKeyValue') } + ]; + } + + get layoutOptions(): { value: ImageLayout; label: string }[] { + return [ + { value: 'GALLERY', label: this.translate.instant('templateFieldsEditor.layoutGallery') }, + { value: 'HERO', label: this.translate.instant('templateFieldsEditor.layoutHero') }, + { value: 'MASONRY', label: this.translate.instant('templateFieldsEditor.layoutMasonry') }, + { value: 'CAROUSEL', label: this.translate.instant('templateFieldsEditor.layoutCarousel') } + ]; + } isDuplicate(field: TemplateField, index: number): boolean { if (!field.name?.trim()) return false; diff --git a/web/src/app/shared/update-banner/update-banner.component.html b/web/src/app/shared/update-banner/update-banner.component.html index 7ef11a1..0e7e23d 100644 --- a/web/src/app/shared/update-banner/update-banner.component.html +++ b/web/src/app/shared/update-banner/update-banner.component.html @@ -6,22 +6,18 @@ > -

THE DIGITAL CODEX

+

{{ 'sidebar.subtitle' | translate }}

@@ -42,35 +42,35 @@ }
-

OUTILS

+

{{ 'sidebar.toolsLabel' | translate }}

@if (!config.demoMode) { } @if (!config.demoMode) { }
diff --git a/web/src/app/sidebar/sidebar.component.ts b/web/src/app/sidebar/sidebar.component.ts index d97b6db..77c7bf6 100644 --- a/web/src/app/sidebar/sidebar.component.ts +++ b/web/src/app/sidebar/sidebar.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { AsyncPipe } from '@angular/common'; import { Router } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular'; import { LayoutService } from '../services/layout.service'; import { GlobalSearchService } from '../services/global-search.service'; @@ -12,7 +13,7 @@ import packageJson from '../../../package.json'; @Component({ selector: 'app-sidebar', - imports: [AsyncPipe, LucideAngularModule], + imports: [AsyncPipe, LucideAngularModule, TranslatePipe], templateUrl: './sidebar.component.html', styleUrls: ['./sidebar.component.scss'] }) diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json new file mode 100644 index 0000000..6db2e87 --- /dev/null +++ b/web/src/assets/i18n/en.json @@ -0,0 +1,1491 @@ +{ + "language": { + "label": "Language", + "fr": "French", + "en": "English" + }, + "common": { + "back": "Back", + "save": "Save", + "saving": "Saving...", + "loading": "Loading...", + "refresh": "Refresh", + "download": "Download", + "cancel": "Cancel", + "delete": "Delete", + "deleting": "Deleting...", + "edit": "Edit", + "create": "Create", + "creating": "Creating...", + "add": "Add", + "remove": "Remove", + "close": "Close", + "confirm": "Confirm", + "yes": "Yes", + "no": "No", + "search": "Search", + "name": "Name", + "description": "Description", + "titleField": "Title", + "type": "Type", + "actions": "Actions", + "open": "Open", + "view": "View", + "duplicate": "Duplicate", + "rename": "Rename", + "import": "Import", + "export": "Export", + "generate": "Generate", + "generating": "Generating...", + "validate": "Validate", + "none": "None", + "optional": "(optional)", + "required": "(required)", + "error": "Error", + "success": "Success", + "warning": "Warning", + "previous": "Previous", + "next": "Next", + "select": "Select" + }, + "settings": { + "title": "Settings", + "language": { + "title": "Interface language", + "hint": "Choose the application display language. The change is applied instantly and remembered on this device." + }, + "ai": { + "title": "AI engine", + "hint": "Choose the language-model provider used by the chat and page generation.", + "provider": "Provider", + "providerOllama": "Ollama (local)", + "providerOnemin": "1min.ai (cloud)", + "providerOpenrouter": "OpenRouter (cloud)", + "providerMistral": "Mistral (cloud)", + "providerGemini": "Gemini (cloud)" + }, + "ollama": { + "title": "Ollama configuration", + "url": "Ollama server URL", + "model": "Model", + "noModels": "No model detected. Make sure Ollama is running and the URL is correct." + }, + "onemin": { + "title": "1min.ai configuration" + }, + "openrouter": { + "title": "OpenRouter configuration", + "freeOnly": "Free only", + "hint": "List loaded automatically from OpenRouter. To stay free: a model marked · free or the openrouter/free router (picks a free model on its own). For imports, prefer a free model with a large context (the \"ctx\" value)." + }, + "mistral": { + "title": "Mistral configuration", + "hint": "Free key on console.mistral.ai (\"Experiment\" tier, no card). For imports, prefer mistral-large-latest (128k context, faithful and good in French) or mistral-small-latest (faster)." + }, + "gemini": { + "title": "Gemini configuration", + "hint": "Free key on aistudio.google.com (no card). Ideal for imports: gemini-2.0-flash has a ~1M context → a whole book fits in 1-2 calls (no lost chunks). Consider raising the import chunk size to take advantage of it." + }, + "apiKey": { + "label": "API key", + "configured": "✓ configured", + "placeholderSet": "Key configured (leave empty to keep it)", + "placeholderOnemin": "Enter your API key", + "placeholderOpenrouter": "Enter your API key (sk-or-...)", + "placeholderMistral": "Enter your Mistral API key", + "placeholderGemini": "Enter your Gemini API key", + "clear": "Clear the saved key" + }, + "model": "Model", + "provider": "Provider", + "embeddings": { + "title": "Embeddings (RAG workshop)", + "hint": "Model used to index the workshops' PDF sources and search them. It is a separate model from the chat model above.", + "provider": "Embeddings provider", + "providerOllama": "Ollama (local, free)", + "providerMistral": "Mistral (cloud, EU)", + "ollamaModel": "Ollama embedding model", + "ollamaModelHint": "Recommended: nomic-embed-text (lightweight). Free and unlimited (local).", + "autoPull": "Automatically install the model at startup if missing", + "topK": "Excerpts retrieved per question (RAG coverage)", + "topKHint": "Higher = the AI sees more passages per question (better for broad questions like \"list the…\"), but a longer prompt. Default 8. With a large-context model (Gemini), you can go up to 50-150 for near \"whole document\" coverage. For exhaustive questions, prefer the workshop's \"Deep analysis\" button.", + "mistralKey": "Mistral API key", + "mistralKeyHint": "This is the same key as for the Mistral chat (a single shared key). Subject to the free tier's rate limit.", + "mistralModel": "Mistral embedding model" + }, + "context": { + "title": "Context window", + "tokens": "Tokens allocated to the model", + "max": "/ {{max}} max", + "hintKnown": "The {{model}} model accepts up to {{max}} tokens. The higher the value, the more history and context the AI can hold — at the cost of VRAM and latency.", + "hintUnknown": "Unable to determine the model's max window (Ollama unreachable or unknown model). Slider capped at {{max}} as a safeguard." + }, + "pdf": { + "title": "PDF import", + "chunkTokens": "Import chunk size (tokens)", + "chunkTokensHint": "When importing a PDF (rules or campaign), the text is split into chunks of this size before being analyzed by the AI. Bigger = fewer chunks, so faster and less fragmentation/duplication — but the chunk must fit within the model's window.
Reference: ~10,000 for Ollama (16k context); up to ~100,000 for a large-context model (e.g. GPT-5 mini, 400k) to process a whole book in one pass. Caution: a big chunk = longer generation (see the timeout below).", + "timeout": "AI call timeout (seconds)", + "timeoutHint": "Maximum wait time for a model response. If a heavy import fails with \"timeout\", increase this value (e.g. 600) or reduce the chunk size above. Default: 300 s." + }, + "messages": { + "saveSuccess": "Settings saved.", + "saveError": "Failed to save settings.", + "loadError": "Unable to load settings." + } + }, + "lore": { + "heroTitle": "Your Worlds", + "heroSubtitle": "Select an existing lore or create a new world", + "cardDate": "2h ago", + "statPages": "{{n}} pages", + "statFolders": "{{n}} folders", + "newLore": "New Lore", + "newLoreSubtitle": "Create a new world", + "tip": "💡 Tip: Use templates to structure your world consistently" + }, + "folderView": { + "breadcrumb": "Breadcrumb", + "summary": "{{folders}} subfolder(s) · {{pages}} page(s)", + "editTitle": "Edit folder", + "deleteTitle": "Delete the folder and all its contents", + "subfolders": "Subfolders", + "newSubfolder": "New subfolder", + "noSubfolders": "No subfolders.", + "pages": "Pages", + "newPage": "New page", + "noPages": "No pages in this folder.", + "deleteConfirmTitle": "Delete folder", + "deleteConfirmMessage": "Delete the folder \"{{name}}\"?", + "impact": { + "subfolders": "{{n}} subfolder", + "subfoldersPlural": "{{n}} subfolders", + "pages": "{{n}} page", + "pagesPlural": "{{n}} pages", + "alsoDeletes": "This action will also delete: {{items}}.", + "irreversible": "This action is irreversible." + } + }, + "loreCreate": { + "title": "Create a new Lore", + "nameLabel": "World name *", + "namePlaceholder": "e.g. Realm of Shadows, Cyberpunk 2157...", + "descriptionPlaceholder": "Briefly describe your world, its mood, its genre...", + "infoIntro": "💡 Tip: Your lore will be created with a few default templates:", + "infoNpc": "NPC - For your characters", + "infoPlace": "Place - For your cities and regions", + "infoFaction": "Faction - For your organizations", + "infoItem": "Item - For your artifacts", + "infoFooter": "You'll be able to create your own templates afterwards!", + "submit": "Create lore" + }, + "loreDetail": { + "graph": "Graph", + "graphTitle": "View the graph of pages and their links (NPCs included)", + "editTitle": "Edit Lore", + "deleteTitle": "Delete Lore", + "folders": "Folders", + "newFolder": "New folder", + "noFolders": "No folders yet.", + "createFirstFolder": "Create your first folder", + "deleteConfirmTitle": "Delete Lore", + "deleteConfirmMessage": "Permanently delete the Lore \"{{name}}\"?", + "impact": { + "folders": "{{n}} folder", + "foldersPlural": "{{n}} folders", + "pages": "{{n}} page", + "pagesPlural": "{{n}} pages", + "templates": "{{n}} template", + "templatesPlural": "{{n}} templates", + "alsoDeletes": "This action will also delete: {{items}}.", + "detachedCampaigns": "{{n}} campaign will be kept but will lose its link to this world.", + "detachedCampaignsPlural": "{{n}} campaigns will be kept but will lose their link to this world.", + "irreversible": "This action is irreversible." + } + }, + "loreGraph": { + "back": "Back to Lore", + "title": "{{name}} — Graph", + "subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{edges}} link(s). Click a node to open it, drag it to rearrange.", + "legendPage": "Lore page", + "legendNpc": "NPC", + "legendLink": "NPC → page link", + "empty": "No pages in this Lore yet — the graph will fill in as you go.", + "hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet." + }, + "loreNodeCreate": { + "title": "Create a new folder", + "subtitle": "Folders let you organize your pages by category", + "nameLabel": "Folder name *", + "namePlaceholder": "e.g. Characters, Creatures...", + "parentLabel": "Parent folder", + "rootOption": "— Lore root —", + "parentHint": "Leave empty to create a folder at the lore root", + "iconLabel": "Icon", + "descriptionPlaceholder": "Describe the type of content this folder will contain...", + "submit": "Create folder" + }, + "loreNodeEdit": { + "title": "Edit folder", + "summary": "{{folders}} subfolder(s) · {{pages}} page(s)", + "nameLabel": "Folder name *", + "parentLabel": "Parent folder", + "rootOption": "— Lore root —", + "parentHint": "You cannot select a subfolder of the current folder (cycles are forbidden)", + "iconLabel": "Icon" + }, + "sidebar": { + "subtitle": "THE DIGITAL CODEX", + "tabLore": "Lore", + "tabCampaign": "Campaign", + "toolsLabel": "TOOLS", + "globalSearch": "Global search", + "gameSystems": "RPG systems", + "vttExport": "VTT export", + "settings": "Settings", + "updateAvailable": "Update available", + "updateBadge": "NEW", + "version": "Version {{version}}" + }, + "secondarySidebar": { + "backToCampaignHome": "Back to campaign home", + "resizeHandle": "Drag to resize" + }, + "breadcrumb": { + "ariaLabel": "Breadcrumb" + }, + "globalSearch": { + "placeholder": "Search in LoreMind...", + "noResults": "No results", + "minChars": "Type at least 2 characters", + "hintNavigate": "navigate", + "hintSelect": "select", + "hintClose": "close", + "countSingular": "{{count}} result", + "countPlural": "{{count}} results", + "enemyLevel": "Lvl. {{level}}", + "tags": { + "page": "Page", + "node": "Folder", + "template": "Template", + "lore": "Lore", + "campaign": "Campaign", + "npc": "NPC", + "character": "PC", + "randomTable": "Random table", + "itemCatalog": "Item catalog", + "enemy": "Enemy" + } + }, + "updateBanner": { + "message": "A new version of LoreMind is available ({{version}}). Reload to enjoy the latest improvements.", + "reload": "Reload", + "dismissTitle": "Dismiss (will reappear on next startup)", + "dismissAria": "Dismiss" + }, + "confirmDialog": { + "defaultTitle": "Confirmation" + }, + "aiChatDrawer": { + "title": "AI Assistant", + "resizeAria": "Resize the panel", + "resizeTitle": "Drag to resize", + "conversations": "Conversations", + "newConversation": "New conversation", + "noConversation": "No conversation", + "hideList": "Hide list", + "showList": "Show list", + "shrink": "Shrink", + "shrinkAria": "Shrink the panel", + "expand": "Expand", + "expandAria": "Expand the panel", + "gaugeTitle": "System: {{system}} · History: {{history}} · Current: {{current}} tokens", + "gaugeTitleMax": "System: {{system}} · History: {{history}} · Current: {{current}} / {{max}} tokens", + "gaugeLabel": "Context: {{total}} tokens", + "gaugeLabelMax": "Context: {{total}} / {{max}} tokens", + "quickSuggestions": "Quick suggestions:", + "inputPlaceholder": "Ask a question...", + "send": "Send", + "welcome": "Hello! I can help you develop this page. What would you like to create?", + "loadConversationError": "Unable to load the conversation.", + "deleteConversationTitle": "Delete conversation", + "deleteConversationMessage": "Delete the conversation \"{{title}}\"?", + "missingContextError": "Missing context to create a conversation.", + "createConversationError": "Unable to create the conversation.", + "unknownError": "Unknown error." + }, + "imageGallery": { + "empty": "No illustration", + "illustrationAlt": "Illustration", + "mainIllustrationAlt": "Main illustration", + "mapAlt": "Map", + "removeImage": "Remove this image", + "removeMap": "Remove this map", + "lightboxAria": "Full-screen image", + "lightboxAlt": "Enlarged image" + }, + "imageUploader": { + "addImage": "Add an image", + "uploading": "Uploading", + "uploadingHint": "Uploading...", + "dropTitle": "Drop an image here", + "dropHint": "or click to choose a file (JPEG, PNG, WebP, GIF, max 10 MB)", + "unsupportedFormat": "Unsupported format (JPEG, PNG, WebP, GIF only).", + "tooLarge": "File too large (max {{max}} MB).", + "serverRejected": "File rejected by the server (too large).", + "uploadFailed": "Upload failed. Check that the backend and MinIO are running." + }, + "singleImagePicker": { + "removeImage": "Remove the image" + }, + "chipsInput": { + "placeholder": "Add...", + "removeTag": "Remove" + }, + "dynamicFieldsForm": { + "textPlaceholder": "Enter {{name}}…", + "noLabels": "No labels defined in the template for this field.", + "noFields": "No fields defined in this system's template. Edit the GameSystem to add fields." + }, + "loreLinkPicker": { + "searchPlaceholder": "Search for a page to link...", + "openInNewTab": "Open in a new tab", + "removeLink": "Remove link", + "noMatch": "No matching page" + }, + "enemyLinkPicker": { + "searchPlaceholder": "Search for an enemy from the bestiary...", + "openInNewTab": "Open the sheet in a new tab", + "removeLink": "Remove link", + "noMatch": "No matching enemy" + }, + "personaView": { + "empty": "This sheet is still empty." + }, + "playthroughFlagsManager": { + "empty": "No fact referenced. Add a \"When a fact is true\" condition on at least one quest for it to appear here.", + "true": "true", + "false": "false" + }, + "prerequisiteEditor": { + "empty": "No condition — the quest is immediately available.", + "questCompletedLabel": "Quest completed:", + "noOtherQuest": "(no other quest)", + "sessionReachedLabel": "From session:", + "flagSetLabel": "When the fact is true:", + "flagNamePlaceholder": "fact_name", + "removePrerequisite": "Remove this prerequisite", + "addCondition": "Add a condition", + "menuAfterQuest": "After another quest", + "menuFromSession": "From a session", + "menuWhenFlag": "When a fact is true" + }, + "questStatusBadge": { + "locked": "Locked", + "inProgress": "In progress", + "completed": "Completed", + "available": "Available" + }, + "roomsEditor": { + "empty": "No room. The scene behaves like a classic narrative beat. Add a first room to turn it into an explorable location (dungeon, crypt…).", + "roomNamePlaceholder": "Room name", + "floorPlaceholder": "Fl.", + "floorTitle": "Floor (0 = ground floor)", + "moveUp": "Move up", + "moveDown": "Move down", + "removeRoom": "Delete room", + "descriptionLabel": "Description (read/summarized to players)", + "descriptionPlaceholder": "Atmosphere, what the PCs see when entering…", + "bestiaryLabel": "Bestiary enemies", + "enemiesLabel": "Enemies / creatures / boss (free text)", + "enemiesPlaceholder": "2 goblins, 1 ogre boss (HP 60, AC 14)…", + "lootLabel": "Loot / treasure", + "lootPlaceholder": "50 gp, healing potion, silver key…", + "trapsLabel": "Traps / hazards", + "trapsPlaceholder": "Trapdoor (DC 15 Perception), poisoned darts…", + "gmNotesLabel": "GM notes (private)", + "gmNotesPlaceholder": "The boss knows the PCs by name, a clue…", + "exitsLabel": "Exits / doors to other rooms", + "branchLabelPlaceholder": "North door", + "noOtherRoom": "(no other room)", + "branchConditionPlaceholder": "Condition (optional)", + "addExit": "Add an exit", + "addRoom": "Add a room", + "defaultRoomName": "Room {{n}}", + "defaultBranchLabel": "Door" + }, + "templateFieldsEditor": { + "moveUp": "Move up", + "moveDown": "Move down", + "fieldNamePlaceholder": "Field name (e.g. Background, HP...)", + "removeField": "Delete this field", + "labelsHeader": "Labels (fixed keys for all sheets)", + "labelPlaceholder": "E.g. STR, DEX...", + "removeLabel": "Remove this label", + "addLabel": "Add a label", + "empty": "No field yet — add some with the buttons below.", + "addPrefix": "Add:", + "typeText": "Text", + "typeNumber": "Number", + "typeImage": "Image(s)", + "typeKeyValue": "Key/value list", + "layoutGallery": "Gallery", + "layoutHero": "Banner", + "layoutMasonry": "Mosaic", + "layoutCarousel": "Carousel", + "defaultLabel": "Template fields" + }, + "arcCreate": { + "title": "Create a new story arc", + "nameLabel": "Arc name *", + "namePlaceholder": "e.g. The Shadow of the North", + "structureLabel": "Arc structure", + "linearTitle": "Linear", + "linearDesc": "Chapters played in order — classic sequential storytelling.", + "hubTitle": "Hub", + "hubDesc": "Parallel quests unlocked by conditions — sandbox style (e.g. Phandalin / Icespire Peak).", + "descriptionPlaceholder": "Describe the main story arc...", + "iconLabel": "Icon", + "submit": "Create arc" + }, + "arcEdit": { + "fallbackTitle": "Arc", + "subtitle": "Story arc", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to discuss this arc", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Moods, portraits, evocative visuals for the arc. JPEG, PNG, WebP or GIF, 10 MB max.", + "mapsLabel": "Maps & plans", + "mapsHint": "Regional maps and plans to help players locate the action.", + "nameLabel": "Arc title *", + "namePlaceholder": "e.g. The Shadow of the North", + "synopsisLabel": "Arc synopsis", + "synopsisPlaceholder": "Describe the main story of this arc...", + "structureLabel": "Arc structure", + "linearTitle": "Linear", + "linearDesc": "Chapters played in order — sequential storytelling.", + "hubTitle": "Hub", + "hubDesc": "Parallel quests unlocked by conditions (sandbox).", + "iconLabel": "Icon", + "themesLabel": "Main themes", + "themesPlaceholder": "What themes does this arc explore? (betrayal, redemption...)", + "stakesLabel": "Overall stakes", + "stakesPlaceholder": "What are the major stakes of this arc for the characters?", + "gmNotesLabel": "GM notes and planning", + "gmNotesPlaceholder": "Your notes on the arc's direction, planned twists, key reveals...", + "gmNotesHint": "These notes are private and will not be exported to FoundryVTT.", + "rewardsLabel": "Rewards and progression", + "rewardsPlaceholder": "What rewards will the players gain? Items, levels, knowledge, contacts...", + "resolutionLabel": "Planned resolution", + "resolutionPlaceholder": "How should this arc end? What are the possible outcomes?", + "relatedPagesLabel": "Linked Lore pages", + "relatedPagesHint": "Link this arc to NPCs, places or Lore elements. Click a chip to open the linked page.", + "noLoreHint": "💡 This campaign is not linked to any universe. Link it to a Lore in the campaign screen to be able to link this arc to Lore pages (NPCs, places, etc.).", + "chatWelcome": "I can see this arc. Ask me to enrich its themes, stakes or resolution.", + "chatSuggestion1": "Suggest 3 major themes for this arc", + "chatSuggestion2": "Imagine stakes that put pressure on the players", + "chatSuggestion3": "Suggest a two-act resolution", + "deleteTitle": "Delete arc", + "deleteMessage": "Delete the arc \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "arcView": { + "subtitleHub": "Hub arc (non-linear quests)", + "subtitleLinear": "Story arc", + "deleteTitle": "Delete the arc and all its content", + "mapsTitle": "Maps & plans", + "hubQuestsTitle": "Hub quests (scenario)", + "hubQuestsEmpty": "No quests for this hub. Create one to get started.", + "unlockConditions": "{{n}} unlock condition(s)", + "synopsisTitle": "Synopsis", + "themesTitle": "Main themes", + "stakesTitle": "Overall stakes", + "rewardsTitle": "Rewards and progression", + "resolutionTitle": "Planned resolution", + "gmNotesTitle": "GM notes and planning", + "relatedPagesTitle": "Linked Lore pages", + "notProvided": "Not provided", + "prereqQuestCompleted": "Quest “{{name}}” completed", + "prereqSessionReached": "Session {{n}} reached", + "prereqFlagSet": "Fact: {{flag}}", + "deletedPage": "(deleted page)", + "chaptersSingular": "{{n}} chapter", + "chaptersPlural": "{{n}} chapters", + "scenesSingular": "{{n}} scene", + "scenesPlural": "{{n}} scenes", + "deleteCascade": "This action will also delete: {{parts}}.", + "irreversible": "This action is irreversible.", + "deleteConfirmTitle": "Delete arc", + "deleteConfirmMessage": "Delete the arc \"{{name}}\"?" + }, + "campaigns": { + "heroTitle": "Your Campaigns", + "heroSubtitle": "Join a campaign or create new ones", + "statusInProgress": "In progress", + "players": "{{n}} players", + "arcs": "{{n}} arcs", + "chapters": "{{n}} chapters", + "newCampaign": "New Campaign", + "newCampaignSubtitle": "Create a new adventure", + "tip": "💡 Tip: Organize your arcs and chapters so you never forget anything in your adventures" + }, + "campaignCreate": { + "title": "Create a new Campaign", + "nameLabel": "Campaign name *", + "namePlaceholder": "e.g. The Shadow of the North, The Forgotten Heirs...", + "descriptionLabel": "Description / Pitch", + "descriptionPlaceholder": "Summarize the main plot of your campaign...", + "playerCountLabel": "Number of players", + "loreLabel": "Linked universe", + "noLoreOption": "— No universe (standalone campaign) —", + "loreHint": "Optional. If linked, you'll be able to connect arcs, chapters and scenes to Lore pages. Leave empty for a one-shot or if you'll create the Lore later.", + "gameSystemLabel": "RPG system", + "noGameSystemOption": "— None (generic campaign) —", + "createGameSystemOption": "+ Create a new system…", + "gameSystemNamePlaceholder": "Name of the new system (e.g. D&D 5e, Nimble, Homebrew)", + "gameSystemCreateHint": "Quick creation — you'll be able to add rules, PC/NPC sheet templates and the rest from the \"Systems\" section later.", + "gameSystemHint": "Optional. If set, the AI will inject the system's rules (classes, combat, lore...) into its suggestions to respect the RPG's mechanics.", + "gameSystemWarning": "⚠️ The chosen game system also determines the template for PC and NPC sheets. Changing it later will make the fields of existing sheets invisible (the data stays stored but will only show again when reverting to the previous system). Choose carefully from the start if possible.", + "orgIntro": "💡 Organization: Your campaign will be structured into:", + "orgArcs": "Arcs - The major narrative phases", + "orgChapters": "Chapters - The segments of an arc", + "orgScenes": "Scenes - The individual moments of play", + "submit": "Create the campaign" + }, + "campaignDetail": { + "players": "{{n}} players", + "openLinkedLore": "Open the linked universe", + "loreMissingTitle": "The linked universe cannot be found", + "loreMissing": "Universe not found", + "loreLabel": "Linked universe", + "noLoreOption": "— No universe (standalone campaign) —", + "gameSystemLabel": "RPG system", + "noGameSystemOption": "— None (generic) —", + "createGameSystemOption": "+ Create a new system…", + "gameSystemNamePlaceholder": "Name of the new system (e.g. D&D 5e, Nimble, Homebrew)", + "charactersTitle": "Characters", + "npcTitle": "Non-player characters", + "newNpc": "New NPC", + "noNpc": "No NPCs yet.", + "createFirstNpc": "Create your first NPC", + "arcsTitle": "Narrative arcs", + "newArc": "New arc", + "chapters": "{{n}} chapters", + "noArc": "No narrative arcs yet.", + "createFirstArc": "Create your first arc", + "playthroughsTitle": "My playthroughs", + "newPlaythrough": "New playthrough", + "noPlaythrough": "No playthroughs. Create one to start a session with a table.", + "defaultPlaythroughName": "New playthrough {{n}}", + "gameSystemChange": { + "title": "Change the game system?", + "message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.", + "detailSheets": "{{n}} existing sheet(s) are linked to the current system's template.", + "detailFields": "Their fields will no longer display with the new system.", + "detailStored": "The data stays stored: reverting to the previous system will make them visible again.", + "confirm": "Change anyway" + }, + "delete": { + "title": "Delete the campaign?", + "message": "Permanently delete the campaign \"{{name}}\"?", + "arcs": "{{n}} arc", + "arcsPlural": "{{n}} arcs", + "chapters": "{{n}} chapter", + "chaptersPlural": "{{n}} chapters", + "scenes": "{{n}} scene", + "scenesPlural": "{{n}} scenes", + "playthroughs": "{{n}} playthrough (with its PCs, sessions, facts)", + "playthroughsPlural": "{{n}} playthroughs (with their PCs, sessions, facts)", + "alsoDeletes": "Will also be deleted: {{items}}.", + "irreversible": "This action is irreversible." + } + }, + "campaignImport": { + "pageTitle": "Import a campaign", + "back": "Back to the campaign", + "title": "Import a campaign PDF", + "subtitle": "The AI proposes an arc → chapter → scene tree. You review and adjust it before creating anything.", + "importing": "Import in progress…", + "choosePdf": "Choose a campaign PDF", + "phaseExtracting": "Extracting text…", + "phaseAnalyzing": "Analyzing the campaign… ({{current}}/{{total}})", + "foundSoFar": "Found so far: {{arcs}} arc(s) · {{chapters}} chapter(s) · {{scenes}} scene(s) · {{npcs}} NPCs", + "errorNoStructure": "No narrative structure detected in this PDF.", + "errorImport": "PDF import failed.", + "errorImportDetail": "Import failed: {{message}}", + "errorApply": "Creation failed. Does the campaign still exist?", + "summaryToCreate": "To create: {{arcs}} new arc(s), {{chapters}} chapter(s), {{scenes}} scene(s)", + "summaryNpcs": ", {{npcs}} NPCs", + "summaryHint": ". Items marked \"already present\" (greyed out) are shown to place the additions in context; they will not be recreated. Edit the new ones, then create.", + "alreadyPresent": "already present", + "arcNamePlaceholder": "Arc name", + "removeArc": "Remove the arc", + "typeLabel": "Type:", + "typeLinear": "Linear", + "typeHub": "Hub (quests)", + "arcSynopsisPlaceholder": "Arc synopsis (optional)", + "chapterNamePlaceholder": "Chapter name", + "removeChapter": "Remove the chapter", + "chapterSynopsisPlaceholder": "Chapter synopsis (optional)", + "sceneNamePlaceholder": "Scene name", + "synopsisOptionalPlaceholder": "Synopsis (optional)", + "detailsTitle": "Player narration, GM notes, rooms", + "details": "Details", + "roomsCount": "· {{n}} room(s)", + "removeScene": "Remove the scene", + "playerNarrationLabel": "Read to the players", + "playerNarrationPlaceholder": "Boxed text read to the players (optional)", + "gmNotesLabel": "GM notes", + "gmNotesPlaceholder": "Secrets, development, consequences (optional)", + "roomsLabel": "Rooms (explorable location)", + "roomPlaceholder": "Room", + "enemiesPlaceholder": "Enemies", + "lootPlaceholder": "Loot", + "removeRoom": "Remove the room", + "room": "Room", + "scene": "Scene", + "chapter": "Chapter", + "addArc": "Add an arc", + "npcReviewTitle": "Detected NPCs and creatures ({{n}})", + "npcReviewHint": "Check the ones to create in the campaign (description taken from the book). Those already present in the campaign are greyed out and will not be recreated.", + "creating": "Creating…", + "createInCampaign": "Create in the campaign" + }, + "chapterCreate": { + "titleQuest": "Create a new quest", + "titleChapter": "Create a new chapter", + "hub": "Hub", + "arc": "Arc", + "nameQuestLabel": "Quest name *", + "nameChapterLabel": "Chapter name *", + "namePlaceholderQuest": "e.g. Rescue the missing merchant", + "namePlaceholderChapter": "e.g. Chapter 1: The Disappearances", + "descPlaceholderQuest": "Describe this quest...", + "descPlaceholderChapter": "Describe this chapter...", + "icon": "Icon", + "createQuest": "Create quest", + "createChapter": "Create chapter" + }, + "chapterEdit": { + "chapter": "Chapter", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to discuss this chapter", + "unlockTitleQuest": "🗺️ Quest unlock conditions", + "unlockTitleChapter": "🔒 Chapter unlock conditions", + "unlockHintQuest": "Scenario definition: all conditions must be met (AND) for the quest to unlock in a Playthrough. Leave empty to make it available from the start. Progression and fact states are managed in the Playthrough screen, not here.", + "unlockHintChapter": "Scenario definition: all conditions must be met (AND) for this chapter to unlock in a Playthrough. Leave empty to make it available from the start. Progression and fact states are managed in the Playthrough screen, not here.", + "illustrations": "Illustrations", + "illustrationsHint": "Portraits, moods and standout scenes of the chapter.", + "maps": "Maps & plans", + "mapsHint": "Regional maps, dungeon layouts and diagrams useful at the table.", + "nameLabel": "Chapter title *", + "namePlaceholder": "e.g. Chapter 1: The Disappearances", + "synopsisLabel": "Chapter synopsis", + "synopsisPlaceholder": "Briefly describe what happens in this chapter...", + "icon": "Icon", + "gmNotesLabel": "Game Master notes", + "gmNotesPlaceholder": "Your private notes on how the chapter unfolds, key events, twists...", + "gmNotesHint": "These notes are private and will not be exported to FoundryVTT.", + "playerObjectivesLabel": "Player objectives", + "playerObjectivesPlaceholder": "What must the players accomplish in this chapter?", + "narrativeStakesLabel": "Narrative stakes", + "narrativeStakesPlaceholder": "What are the dramatic stakes?", + "relatedPages": "Linked Lore pages", + "relatedPagesHint": "Link this chapter to NPCs, places or Lore elements that appear in it.", + "noLoreHint": "💡 This campaign is not linked to any universe. Link it to a Lore in the campaign screen to be able to link this chapter to Lore pages.", + "chatWelcome": "I can see this chapter. Ask me to flesh out its objectives, its stakes or its opening scene.", + "chatSuggestion1": "Suggest clear objectives for the players in this chapter", + "chatSuggestion2": "Imagine 2 narrative tensions that renew interest midway through the chapter", + "chatSuggestion3": "Suggest a striking opening scene", + "deleteTitle": "Delete chapter", + "deleteMessage": "Delete the chapter \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "chapterGraph": { + "chapter": "Chapter", + "title": "{{name}} — Map", + "subtitle": "Flowchart of scenes and their narrative branches", + "back": "Back to chapter", + "empty": "This chapter has no scenes. Create some to see the map appear.", + "hint": "💡 Click a scene to open it, or drag it to rearrange the map. Scenes not connected to the entry point (scene of order 1) appear at the bottom.", + "allCampaigns": "All campaigns" + }, + "chapterView": { + "chapter": "Chapter", + "questHub": "Quest (Hub)", + "conditional": "Conditional", + "conditionalBadgeTitle": "This chapter has unlock conditions: it unlocks in a Playthrough when they are met.", + "map": "Chapter map", + "mapTitle": "View the flowchart of scenes and their branches", + "deleteTitle": "Delete the chapter and its scenes", + "unlockConditions": "Unlock conditions", + "unlockHintQuest": "During a playthrough, the quest unlocks as soon as all these conditions are met. Toggling facts is done in the Playthrough's \"Facts\" screen.", + "unlockHintChapter": "During a playthrough, this chapter unlocks as soon as all these conditions are met. Toggling facts is done in the Playthrough's \"Facts\" screen.", + "noConditionQuest": "No conditions — this quest is available from the start in every Playthrough.", + "noConditionEdit": "Click Edit to add conditions (\"previous quest completed\", \"from session N onward\", \"when a fact is true\").", + "maps": "Maps & plans", + "synopsis": "Synopsis", + "notProvided": "Not provided", + "playerObjectives": "Player objectives", + "narrativeStakes": "Narrative stakes", + "gmNotes": "Game Master notes", + "relatedPages": "Linked Lore pages", + "prereqQuestCompleted": "Quest \"{{name}}\" completed", + "prereqSessionReached": "Session {{n}} reached", + "prereqFlagSet": "Fact: {{flag}}", + "deletedPage": "(deleted page)", + "deleteScenes": "This action will also delete: {{n}} scene.", + "deleteScenesPlural": "This action will also delete: {{n}} scenes.", + "irreversible": "This action is irreversible.", + "deleteChapterTitle": "Delete chapter", + "deleteChapterMessage": "Delete the chapter \"{{name}}\"?" + }, + "pageCreate": { + "title": "Create a new Page", + "subtitle": "Create a page from an existing template", + "pageTitle": "New page", + "pageTitleLabel": "Page title *", + "pageTitlePlaceholder": "E.g. Master Eldrin, The Silver City...", + "templateLabel": "Template *", + "createTemplate": "Create a template", + "createTemplateTitle": "Create a new template for this Lore", + "createTemplateHint": "You'll come back here automatically, and your input will be kept.", + "noTemplates": "No template defined for this Lore.", + "firstSuffix": "first.", + "nodeLabel": "Destination folder *", + "nodePlaceholder": "Select a folder", + "nodeHint": "The page will be created in this folder", + "noNodes": "No folder in this Lore.", + "createNode": "Create a folder", + "infoBox": "💡 Option 1: Create the page empty, then fill in the fields manually.
💡 Option 2: Create with AI to chat with an assistant that will pre-fill the fields.", + "aiTitle": "Open the AI assistant to pre-fill the fields", + "createWithAi": "Create with AI", + "createPage": "Create the page", + "wizardPrimaryAction": "Apply and create the page", + "wizardSuggestion1": "Make the description shorter", + "wizardSuggestion2": "Add a striking distinctive trait", + "wizardSuggestion3": "Give it a darker tone", + "wizardWelcomeEmpty": "Describe what you'd like to create.", + "wizardWelcome": "Great, let's create a \"{{name}}\" page! Describe it to me in a few words — context, role, notable traits — and I'll suggest values for each field.", + "errorNoReply": "The assistant hasn't replied yet. Describe your idea first.", + "errorNoValues": "Unable to extract the values. Ask the assistant to suggest them again.", + "errorApplyValues": "Page created, but the values could not be applied.", + "errorCreate": "Error while creating the page." + }, + "pageEdit": { + "pageFallback": "Page", + "aiTitle": "Open the AI Assistant (chat or auto-fill)", + "aiAssistant": "AI Assistant", + "generating": "Generating…", + "folder": "Folder", + "folderHint": "Move this page to another folder", + "fields": "Fields", + "valuePlaceholder": "Value for {{field}}...", + "noLabels": "No label defined in the template for this field.", + "deleteRow": "Delete row", + "deleteRowN": "Delete row {{n}}", + "addRow": "Add a row", + "noColumns": "No column defined in the template for this table.", + "tags": "Tags", + "tagsPlaceholder": "Add a tag (Enter to confirm)...", + "tagsHint": "Free keywords to classify and find this page", + "relatedPages": "Linked pages", + "relatedPagesHint": "Click a link to open the associated page", + "privateNotes": "Private notes", + "privateNotesPlaceholder": "Personal notes (not exported to FoundryVTT)", + "privateNotesHint": "Visible only to you. Useful to prepare your sessions.", + "chatPrimaryAction": "Auto-fill all fields", + "chatSuggestion1": "Flesh out this page's story", + "chatSuggestion2": "Suggest links with other pages of the Lore", + "chatSuggestion3": "Suggest a secondary plot", + "aiUnreachable": "The AI assistant is unreachable. Check that the Brain service is running.", + "aiFailed": "AI generation failed. Try again in a moment.", + "deleteTitle": "Delete the page", + "deleteMessage": "Delete the page \"{{title}}\"?" + }, + "pageView": { + "pageFallback": "Page", + "deleteTitle": "Delete the page", + "notFilled": "Not filled in", + "tags": "Tags", + "relatedPages": "Linked pages", + "privateNotes": "Private notes", + "deletedPage": "(deleted page)", + "deleteMessage": "Delete the page \"{{title}}\"?", + "deleteIrreversible": "This action is irreversible." + }, + "templateCreate": { + "title": "Create a new Template", + "subtitle": "Define a custom blueprint to create consistent pages", + "nameLabel": "Template name *", + "namePlaceholder": "E.g. Inn, Artifact, Monster...", + "descriptionPlaceholder": "What is this template for?", + "defaultNodeLabel": "Default folder *", + "nodePlaceholder": "Select a folder", + "defaultNodeHint": "Pages created with this template will be placed in this folder", + "noNodes": "No folder in this Lore.", + "createNode": "Create a folder", + "firstSuffix": "first.", + "fieldsLabel": "Template fields *", + "moveUp": "Move up", + "moveDown": "Move down", + "fieldTypeTitle": "Field type", + "typeText": "Text", + "typeImage": "Image", + "typeKeyValue": "Key/value list", + "typeTable": "Table", + "layoutTitle": "Image layout", + "layoutGallery": "Grid", + "layoutHero": "Hero", + "layoutMasonry": "Masonry", + "layoutCarousel": "Carousel", + "column": "Column", + "label": "Label", + "columnN": "Column {{n}}", + "labelN": "Label {{n}}", + "tableLabelsHint": "Add the table columns (e.g. Item, Price, Description…)", + "kvLabelsHint": "Add the row labels (e.g. STR, DEX, CON…)", + "addFieldPlaceholder": "+ Add a field", + "addFieldTitle": "Add the field", + "fieldsHelp": "Text = free + usable by the AI. Image = gallery. Key/value list = label/value pairs (stats). Table = fixed columns + rows added freely (shop, inventory…).", + "createTemplate": "Create the template" + }, + "templateEdit": { + "subtitle": "Template", + "defaultNodeLabel": "Default folder", + "noneOption": "-- None --", + "defaultNodeHint": "Pages created with this template will be placed in this folder by default", + "fieldsLabel": "Template fields", + "moveUp": "Move up", + "moveDown": "Move down", + "fieldTypeTitle": "Field type", + "typeText": "Text", + "typeImage": "Image", + "typeKeyValue": "Key/value list", + "typeTable": "Table", + "layoutTitle": "Image layout", + "layoutGallery": "Grid", + "layoutHero": "Hero", + "layoutMasonry": "Masonry", + "layoutCarousel": "Carousel", + "column": "Column", + "label": "Label", + "columnN": "Column {{n}}", + "labelN": "Label {{n}}", + "tableLabelsHint": "Add the table columns (e.g. Item, Price, Description…)", + "kvLabelsHint": "Add the row labels (e.g. STR, DEX, CON…)", + "addFieldPlaceholder": "+ Add a field", + "addFieldTitle": "Add the field", + "fieldsHelp": "Text = free + AI-generatable. Image = gallery. Key/value list = label/value pairs (stats). Table = fixed columns + rows added freely (shop, inventory…).", + "deleteTitle": "Delete the template", + "deleteMessage": "Delete the template \"{{name}}\"?" + }, + "sceneCreate": { + "title": "Create a new scene", + "chapterRef": "Chapter: {{name}}", + "nameLabel": "Scene name *", + "namePlaceholder": "E.g.: Arrival at the village", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Describe the scene, the key events, the NPCs present...", + "iconLabel": "Icon", + "createButton": "Create scene", + "createError": "Error while creating the scene" + }, + "sceneEdit": { + "defaultTitle": "Scene", + "subtitle": "Scene", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to discuss this scene", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "NPC portraits, visual mood, evocative scenes...", + "mapsLabel": "Maps & plans", + "mapsHint": "Location plans, tactical maps, diagrams usable at the table.", + "nameLabel": "Scene title *", + "namePlaceholder": "E.g.: Arrival at the village", + "descriptionLabel": "Short description *", + "descriptionPlaceholder": "One or two sentences summarizing what happens...", + "iconLabel": "Icon", + "contextSectionTitle": "Context and mood", + "locationLabel": "Location", + "locationPlaceholder": "E.g.: The Golden Dragon Tavern", + "timingLabel": "Time", + "timingPlaceholder": "E.g.: Evening, at nightfall", + "atmosphereLabel": "Mood and atmosphere", + "atmospherePlaceholder": "Describe the general atmosphere of the scene (sounds, smells, light, emotions...)", + "narrationSectionTitle": "Narration for players", + "narrationPlaceholder": "The text you will read to the players to set the scene...", + "narrationHint": "This text can be read directly to your players.", + "gmNotesSectionTitle": "GM notes and secrets", + "gmNotesPlaceholder": "Hidden information, clues, secret elements the players must not know...", + "gmNotesHint": "These notes are private and visible only to the GM.", + "choicesSectionTitle": "Choices and consequences", + "choicesPlaceholder": "Describe the different options available to the players and their consequences...", + "branchesSectionTitle": "Narrative branches", + "branchesNoSibling": "💡 You need at least one other scene in this chapter to create branches. Create other scenes first, then come back here to connect them.", + "branchLabelLabel": "Choice label", + "branchLabelPlaceholder": "E.g.: If the players attack the guard", + "branchTargetLabel": "Destination scene *", + "branchTargetPlaceholder": "— Choose a scene —", + "branchConditionLabel": "GM condition (optional)", + "branchConditionPlaceholder": "E.g.: Persuasion check DC 15 succeeded", + "branchRemove": "Remove", + "branchRemoveTitle": "Remove this branch", + "branchAdd": "+ Add a branch", + "branchesHint": "Each branch represents a possible \"exit\" from this scene depending on the players' action. Targets are limited to scenes in the same chapter.", + "combatSectionTitle": "Combat or encounter", + "combatDifficultyLabel": "Estimated difficulty", + "combatDifficultyPlaceholder": "E.g.: Medium, 3 level 2 goblins", + "bestiaryEnemiesLabel": "Bestiary enemies", + "bestiaryEnemiesHint": "Reference sheets from your bestiary — click a chip to open the sheet.", + "enemiesLabel": "Enemies and creatures (free text)", + "enemiesPlaceholder": "List of enemies present in this scene...", + "loreSectionTitle": "Linked Lore pages", + "loreHint": "Pin the location, NPCs or creatures of this scene here. Click a chip to open the page.", + "noLoreHint": "💡 This campaign is not associated with any universe. Associate it with a Lore on the campaign screen to be able to pin Lore pages to this scene.", + "dungeonSectionTitle": "Explorable location (dungeon, crypt…)", + "dungeonHint": "If this scene represents a location to explore room by room, add them here. The scene then switches to \"dungeon\" mode in the display.", + "chatWelcome": "I can see this scene. Ask me to enrich its mood, its narration or its choices.", + "chatSuggestion1": "Suggest an immersive sensory atmosphere for this scene", + "chatSuggestion2": "Suggest an opening narration to read to the players", + "chatSuggestion3": "Come up with 2 choices with striking consequences", + "deleteTitle": "Delete scene", + "deleteMessage": "Delete the scene \"{{name}}\"?", + "deleteIrreversible": "This action is irreversible.", + "saveError": "Error while saving", + "deleteError": "Error while deleting" + }, + "sceneView": { + "subtitle": "Scene", + "deleteTitleAttr": "Delete scene", + "mapsSectionTitle": "Maps & plans", + "descriptionSectionTitle": "Description", + "empty": "Not provided", + "locationSectionTitle": "Location", + "timingSectionTitle": "Time", + "atmosphereSectionTitle": "Mood and atmosphere", + "narrationSectionTitle": "Narration for players", + "choicesSectionTitle": "Choices and consequences", + "combatDifficultySectionTitle": "Estimated difficulty", + "enemiesSectionTitle": "Enemies and creatures", + "gmNotesSectionTitle": "GM notes and secrets", + "loreSectionTitle": "Linked Lore pages", + "deletedPage": "(page deleted)", + "roomsSectionTitle": "Rooms of the location", + "roomFloor": "Floor {{floor}}", + "roomEnemies": "⚔️ Enemies", + "roomLoot": "💰 Loot", + "roomTraps": "⚠️ Traps", + "roomGmNotes": "🔒 GM notes", + "roomExits": "→ Exits", + "roomExitCondition": "(if: {{condition}})", + "deletedRoom": "(room deleted)", + "deleteTitle": "Delete scene", + "deleteMessage": "Delete the scene \"{{name}}\"?", + "deleteIrreversible": "This action is irreversible.", + "deleteError": "Error while deleting the scene" + }, + "characterEdit": { + "backToCampaign": "Back to campaign", + "titleEdit": "Edit character sheet", + "titleNew": "New character", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to brainstorm about this PC", + "nameLabel": "Character name *", + "namePlaceholder": "E.g. Thorin Great-Axe, Lyra the Wanderer...", + "portrait": "Portrait", + "portraitHint": "Square recommended (400×400).", + "header": "Banner / Header", + "headerHint": "Landscape format recommended (1200×400).", + "chatWelcome": "I can see this character sheet. Ask me to suggest stats, backstory, equipment or personal goals.", + "chatSuggestion1": "Suggest a backstory consistent with the setting", + "chatSuggestion2": "Suggest 3 personal goals for this character", + "chatSuggestion3": "Help me balance the combat stats", + "deleteTitle": "Delete this sheet?", + "deleteMessage": "Delete the sheet for \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "characterView": { + "aiAssistant": "AI Assistant" + }, + "npcEdit": { + "backToCampaign": "Back to campaign", + "titleEdit": "Edit NPC", + "titleNew": "New NPC", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to brainstorm about this NPC", + "nameLabel": "NPC name *", + "namePlaceholder": "E.g. Borin the blacksmith, Lady Elara, Kael the innkeeper...", + "folder": "Folder", + "folderPlaceholder": "E.g. Bard's Gate, The Pipers faction… (leave empty = unfiled)", + "portrait": "Portrait", + "portraitHint": "Square recommended (400×400).", + "header": "Banner / Header", + "headerHint": "Landscape format recommended (1200×400).", + "relatedPages": "Linked Lore pages", + "relatedPagesHint": "Link this NPC to pages of the setting (their town, faction, region…). These links appear in the Lore graph.", + "chatWelcome": "I can see this NPC sheet. Ask me to suggest appearance, motivations, secrets, or signature lines.", + "chatSuggestion1": "Suggest a striking appearance and posture", + "chatSuggestion2": "Suggest 2 motivations and a secret for this NPC", + "chatSuggestion3": "Imagine 3 signature lines that characterize them", + "deleteTitle": "Delete this sheet?", + "deleteMessage": "Delete the sheet for \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "npcList": { + "create": "New NPC", + "title": "NPCs", + "hint": "All the non-player characters in the campaign, organized by folder. The \"Folder\" field of each sheet drives the classification.", + "empty": "No NPCs yet.", + "unclassified": "Unfiled", + "deleteTitle": "Delete sheet", + "deleteMessage": "Delete the sheet for \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "npcView": { + "aiAssistant": "AI Assistant", + "relatedPages": "Linked Lore pages", + "deletedPage": "(deleted page)" + }, + "enemyEdit": { + "backToEnemies": "Back to enemies", + "titleEdit": "Edit enemy", + "titleNew": "New enemy", + "nameLabel": "Enemy name *", + "namePlaceholder": "E.g. Balor, Goblin warchief, Lich…", + "level": "Level / CR", + "levelPlaceholder": "E.g. 5, CR 8, Boss…", + "folder": "Folder", + "folderPlaceholder": "E.g. Demons, Humanoids… (empty = unfiled)", + "portrait": "Portrait", + "portraitHint": "Square recommended (400×400).", + "header": "Banner / Header", + "headerHint": "Landscape format recommended (1200×400).", + "deleteTitle": "Delete this sheet?", + "deleteMessage": "Delete the sheet for \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "enemyList": { + "create": "New enemy", + "title": "Enemies", + "hint": "The campaign bestiary: monsters and creatures with their sheet (driven by the game system's Enemy template), organized by folder (Demons, Humanoids…).", + "empty": "No enemies yet.", + "unclassified": "Unfiled", + "levelShort": "Lvl {{level}}", + "deleteTitle": "Delete enemy", + "deleteMessage": "Delete \"{{name}}\"?" + }, + "enemyView": { + "levelLong": "Level {{level}}" + }, + "gameSystems": { + "heroTitle": "RPG Systems", + "heroSubtitle": "The rules the AI will know for your campaigns", + "noDescription": "(No description)", + "byAuthor": "by {{author}}", + "public": "public", + "newSystem": "New system", + "newSystemSubtitle": "Enter the rules of an RPG (markdown)", + "tip": "💡 Tip: organize your rules into sections with headings ## Combat, ## Classes, ## Lore… The system will inject the relevant sections based on what the AI needs to generate.", + "deleteTitle": "Delete system", + "deleteMessage": "Delete the system \"{{name}}\"?", + "deleteDetail": "Campaigns using it will no longer be associated with any system." + }, + "gameSystemEdit": { + "backToList": "Back to list", + "editTitle": "Edit system", + "createTitle": "New RPG system", + "nameLabel": "Name *", + "namePlaceholder": "e.g. Nimble, D&D 5.1 SRD, My Homebrew...", + "descriptionLabel": "Short description", + "descriptionPlaceholder": "In one line, what is this system about?", + "authorLabel": "Author", + "authorPlaceholder": "e.g. Hasbro, Homebrew, myself...", + "rulesTitle": "System rules", + "rulesHint": "One section = one theme. The AI will automatically inject the relevant sections based on what it generates (combat → Combat/Monsters, NPC → Classes, arc → Lore/Factions).", + "importButton": "Import a rules PDF", + "importInProgress": "Importing…", + "importHint": "The AI suggests a breakdown into sections — you review it before saving.", + "sectionsFound": "Sections found: {{titles}}", + "sectionTitlePlaceholder": "Section name (e.g. Combat)", + "removeSection": "Remove this section", + "sectionContentPlaceholder": "Describe the rules of this section...", + "noSections": "No sections yet — add one with the buttons below.", + "addSection": "Add a section:", + "addOther": "Other…", + "charactersTitle": "Character sheets", + "charactersHint": "Define the structure of PC and NPC sheets for this system. Universal fields (name, portrait, header) are automatic — only add here the fields specific to the system (Background, HP, Stats…).", + "pcFieldsLabel": "PC sheet fields", + "pcFieldsHint": "Shown when creating/editing a player character.", + "npcFieldsLabel": "NPC sheet fields", + "npcFieldsHint": "Shown when creating/editing a non-player character.", + "enemyFieldsLabel": "Enemy sheet fields", + "enemyFieldsHint": "Shown when creating/editing an enemy (bestiary). Name, level, folder, portrait and banner are automatic.", + "importExtracting": "Extracting text…", + "importAnalyzing": "Analyzing rules… ({{current}}/{{total}})", + "importFailed": "PDF import failed.", + "importFailedReason": "Import failed: {{reason}}", + "importNoRules": "No usable rules could be extracted from this PDF (scan without OCR, or unrecognized content).", + "importOcrSuffix": " (including {{count}} page(s) via OCR)", + "importNote": "{{added}} section(s) suggested from {{pages}} page(s){{ocr}}. Review and adjust below before saving." + }, + "ollamaModelManager": { + "installedModels": "Installed models", + "deleteModelTitle": "Delete {{name}}", + "pullDialogTitle": "Download an Ollama model", + "modelNameLabel": "Model name", + "suggestions": "Suggestions:", + "libraryHint": "The full list is on ollama.com/library.", + "preparing": "Preparing...", + "connecting": "connecting...", + "cancelled": "cancelled", + "pullEventError": "Failed: {{error}}", + "pullFailed": "Failed to download {{name}}.", + "pullInterrupted": "Download of {{name}} interrupted before completion. Restart to resume.", + "pullDone": "Model {{name}} downloaded.", + "deleteTitle": "Delete model", + "deleteMessage": "Delete the model '{{name}}'?", + "deleteDetail": "Disk space will be freed.", + "deleteDone": "Model {{name}} deleted.", + "deleteFailed": "Failed to delete {{name}}." + }, + "updatesSection": { + "title": "Updates", + "hint": "Checks the Docker registry to see whether a new version of the containers (core, brain, web) is available. Postgres and MinIO are excluded — they are updated manually.", + "stableChannel": "Stable channel", + "checking": "Checking...", + "checkNow": "Check now", + "featureNotConfigured": "Feature not configured (WATCHTOWER_TOKEN missing).", + "updateAvailable": "An update is available.", + "checkImpossible": "Check failed (baseline missing or registry unreachable).", + "upToDate": "Everything is up to date (checked on {{date}}).", + "applying": "Updating...", + "applyNow": "Update now", + "betaChannelTitle": "Beta channel — patrons only", + "betaChannelIntro": "Support LoreMind on Patreon to access new features in preview. The Compagnon tier (€7/month) or higher unlocks this channel.", + "connectPatreon": "Connect my Patreon account", + "connectInstructions": "A new window will open to Patreon. After authorizing, copy the displayed token and paste it below.", + "patreonToken": "Patreon token", + "activateLicense": "Activate license", + "licenseValid": "Patreon account connected. Tier {{tier}} active.", + "licenseGrace": "Patreon connection expired, but beta access is maintained during the grace period. Make sure your Patreon subscription is still active and click \"Check now\".", + "licenseExpired": "Patreon connection expired too long ago. Reconnect to regain beta access.", + "licenseUnverifiable": "The installed token can no longer be verified. Reconnect.", + "infoTier": "Tier: {{tier}}", + "infoValidity": "Validity: until {{date}}", + "renewalDay": "(renews in {{n}} day)", + "renewalDays": "(renews in {{n}} days)", + "infoLastRefresh": "Last refresh: {{date}}", + "refreshOk": "OK", + "refreshFailed": "failed", + "enableBetaChannel": "Enable beta channel", + "disconnectPatreon": "Disconnect Patreon", + "checkingBetaImages": "Checking beta images...", + "betaUnavailable": "Unavailable: {{reason}}", + "betaCheckImpossible": "Beta check failed (beta registry unreachable or baseline missing).", + "currentChannel": "Current channel:", + "channelBeta": "Beta", + "channelStable": "Stable", + "switchToBeta": "Switch to the beta channel", + "switchToStable": "Switch back to the stable channel", + "switching": "Switching...", + "switchInProgressNotice": "Switching in progress. The application will be unavailable for 10 to 30 seconds — the page will reload automatically once the new Core is ready.", + "switcherUnavailable": "The channel switcher sidecar is not installed. To benefit from automatic switching, fetch the latest docker-compose.yml from the repo and run docker compose pull && docker compose up -d once. Otherwise, switch manually by editing IMAGE_NAMESPACE in your .env (igmlcreation/loremind- for stable, igmlcreation/loremind-beta- for beta).", + "connectUrlError": "Unable to generate the connection URL. Check your config.", + "pasteTokenFirst": "First paste the token received after connecting with Patreon.", + "patreonConnectedSuccess": "Patreon account connected. Beta access is active.", + "disconnectTitle": "Disconnect Patreon", + "disconnectMessage": "Disconnect your Patreon account?", + "disconnectDetail": "You will lose access to the beta channel.", + "disconnectConfirm": "Disconnect", + "patreonDisconnected": "Patreon account disconnected.", + "switchToBetaMessage": "Switch LoreMind to the beta channel? The core/brain/web containers will be recreated with the beta images. The application will be unavailable for 10-30 seconds.", + "switchToStableMessage": "Switch LoreMind back to the stable channel? The core/brain/web containers will be recreated with the stable images. The application will be unavailable for 10-30 seconds.", + "switchToBetaTitle": "Switch to beta?", + "switchToStableTitle": "Switch back to stable?", + "switchDetailData": "Your data (DB, images) is preserved.", + "switchDetailReversible": "You can switch back at any time from this screen.", + "switchToBetaConfirm": "Switch to beta", + "switchToStableConfirm": "Switch back to stable", + "switchFailed": "Switch failed", + "applyTitle": "Update", + "applyMessage": "Download and restart the containers now?", + "applyDetail": "The app will be unavailable for a few seconds.", + "applyConfirm": "Update", + "applyTriggered": "Update triggered. Reload the page in 30s." + }, + "itemCatalogEdit": { + "editTitle": "Edit catalog", + "createTitle": "New item catalog", + "nameLabel": "Name *", + "namePlaceholder": "e.g. The dwarven armorer's shop", + "descriptionPlaceholder": "What is this catalog / shop for?", + "aiTitle": "Generate with AI", + "aiHint": "Describe the shop/loot; the AI suggests the items (review them before saving). Contextualized with your campaign.", + "aiPlaceholder": "e.g. an alchemist's shop in a port city, exotic items", + "itemsTitle": "Items", + "priceLabel": "Price", + "categoryLabel": "Category", + "itemNamePlaceholder": "Item", + "itemPricePlaceholder": "50 gp", + "itemCategoryPlaceholder": "Weapons", + "itemDescriptionPlaceholder": "Effect / details", + "emptyHint": "No items — click \"Add\".", + "aiPromptRequired": "Describe the catalog to generate.", + "aiError": "AI generation failed. Try again or rephrase.", + "nameRequired": "Name is required.", + "saveError": "Saving failed." + }, + "itemCatalogList": { + "title": "Item catalogs", + "hint": "Shops, loot, treasures… filled in by hand or via AI. Available during sessions when players visit a shop.", + "newCatalog": "New catalog", + "empty": "No catalogs yet.", + "itemCount": "{{n}} item(s)", + "deleteTitle": "Delete catalog", + "deleteMessage": "Delete \"{{name}}\"?" + }, + "itemCatalogView": { + "empty": "No items — edit the catalog to add some." + }, + "notebookActionCard": { + "arc": "Arc", + "chapter": "Chapter", + "warnArc": "First create an arc in the campaign so you can attach this element to it.", + "warnArcChapter": "First create an arc and a chapter in the campaign so you can attach this element to it.", + "creating": "Creating…", + "createInCampaign": "Create in campaign", + "createdOpen": "Created — open", + "createError": "Creation failed.", + "typeNpc": "NPC", + "typeScene": "Scene", + "typeChapter": "Chapter", + "typeArc": "Arc", + "typeTable": "Random table" + }, + "notebookList": { + "title": "Adaptation workshops", + "hint": "Chat with the AI from a source PDF (searching within the document) to adapt its content to your campaign. The source and conversation are kept.", + "namePlaceholder": "Workshop name (e.g. Adapting module X)", + "newNotebook": "New workshop", + "empty": "No workshops yet.", + "defaultName": "New workshop", + "deleteTitle": "Delete workshop", + "deleteMessage": "Delete \"{{name}}\" and its indexed sources?" + }, + "notebookDetail": { + "backToList": "Workshops", + "sources": "Sources", + "indexing": "Indexing…", + "addPdf": "Add a PDF", + "sourcesUsed": "{{selected}}/{{total}} source(s) used by the chat and deep analysis", + "sourceIncludedTitle": "Source used by the chat — uncheck to exclude it", + "sourceExcludedTitle": "Source ignored by the chat — check to include it", + "sourceMeta": "{{pages}} p. · {{chunks}} excerpts", + "indexingInProgress": "indexing in progress…", + "indexingFailed": "indexing failed", + "sourcesEmpty": "Add a source PDF to start chatting with it.", + "archiveBanner": "Archive from {{label}} — read-only", + "backToChat": "Back to chat", + "refBadgeTitle": "The content of these archives is injected as a reference in every question", + "refBadge": "{{n}} archive(s) referenced", + "archivesBtnTitle": "Archived conversations (read-only + reference)", + "archives": "Archives", + "clearBtnTitle": "Clear the conversation (the current thread is archived, not deleted)", + "clear": "Clear", + "archivesEmpty": "No archived conversations yet — \"Clear\" archives the current thread here.", + "archivesHelp": "Check an archive to use it as a reference in the chat; click its name to reread it.", + "archiveRefOnTitle": "Archive used as a reference — uncheck to remove it", + "archiveRefOffTitle": "Use this archive as a reference in the chat", + "roleUser": "You", + "roleAi": "AI", + "chatEmpty": "Ask a question about your source, or request an adaptation for your campaign.", + "chatEmptyNoSource": "
(First add an indexed source for grounded answers.)", + "deepProgress": "Deep analysis of the document… {{current}}/{{total}}", + "sourcesPagesTitle": "Source pages used to ground this answer", + "draftPlaceholder": "Request an adaptation, a summary, an NPC inspired by the source…", + "deepBtnTitle": "Deep analysis: reads the ENTIRE document (slower, exhaustive). Ideal for \"list all the…\"", + "sendBtnTitle": "Quick answer (targeted search within the document)", + "uploadError": "Indexing failed. Try again or check the embedding model.", + "clearTitle": "Clear the conversation", + "clearMessage": "Start from a blank conversation?", + "clearDetail": "The current thread is archived (not deleted): it will remain accessible via \"Archives\".", + "archiveLabel": "{{when}} · {{n}} message(s)" + }, + "sessionDetail": { + "backToCampaign": "Back to campaign", + "renameTitle": "Rename session", + "statusActive": "In progress", + "statusEnded": "Ended", + "startedOn": "Started on {{date}}", + "endedOn": "Ended on {{date}}", + "endSession": "End session", + "ctrlEnterHint": "Ctrl + Enter to add", + "addEntryPlaceholder": "Add a {{type}}…", + "journalTitle": "Session log", + "noEntries": "No entries yet.", + "noEntriesHint": "Type a note, an event or a roll above to start the log.", + "entryType": { + "NOTE": "Note", + "EVENT": "Event", + "DICE_ROLL": "Dice roll", + "PLAYER_ACTION": "Player action" + }, + "endConfirm": { + "title": "End session?", + "message": "Mark session \"{{name}}\" as ended?", + "detail": "You can still review its content afterwards.", + "confirmLabel": "End" + }, + "deleteConfirm": { + "title": "Delete session?", + "message": "Permanently delete session \"{{name}}\"?", + "noEntries": "No log entries for this session.", + "entriesOne": "1 log entry will also be deleted.", + "entriesMany": "{{n}} log entries will also be deleted.", + "irreversible": "This action is irreversible." + }, + "deleteEntryConfirm": { + "title": "Delete this entry?", + "message": "This log entry will be permanently deleted." + } + }, + "sessionReferencePanel": { + "tabAi": "AI", + "tabDice": "Dice", + "tabTables": "Tables", + "tabObjects": "Items", + "tabCharacters": "PCs/NPCs", + "tabScenes": "Scenes", + "playerCharacters": "Player characters", + "nonPlayerCharacters": "Non-player characters", + "emptyCharacters": "No characters in this campaign.", + "emptyScenes": "No narrative arc yet. Build your campaign scenario to find it here." + }, + "sessionDicePanel": { + "count": "Count", + "modifier": "Modifier", + "roll": "Roll", + "lastRolls": "Recent rolls", + "clearHistory": "Clear local history", + "addToJournal": "Add to log", + "sessionEnded": "Session ended", + "placeholder": "Pick a die and roll." + }, + "sessionAiChatPanel": { + "welcome": "Ask the AI a question during the game.", + "welcomeSub": "It knows your world, your campaign, the system rules and everything noted in the log.", + "saveReplyTitle": "Add this reply to the log", + "sessionEnded": "Session ended", + "toJournal": "To log", + "replying": "The AI is replying…", + "inputPlaceholder": "Ask for an idea, a twist, a description…", + "clearConversation": "Clear conversation", + "send": "Send", + "stop": "Stop", + "unknownError": "Unknown error", + "aiError": "AI error: {{message}}", + "interrupted": "[interrupted]" + }, + "sessionRandomTablesPanel": { + "empty": "No random table in this campaign. Create one from the sidebar.", + "tables": "Tables", + "roll": "Roll", + "noEntry": "no entry", + "journal": "Log", + "improvising": "…", + "aiImprovise": "AI improvise", + "noResult": "no result" + }, + "sessionItemCatalogsPanel": { + "empty": "No item catalog in this campaign.", + "catalogs": "Catalogs", + "noteTitle": "Record in log (e.g. purchase)", + "journal": "Log" + }, + "randomTableEdit": { + "titleEdit": "Edit table", + "titleNew": "New random table", + "nameLabel": "Name *", + "namePlaceholder": "E.g. Forest encounters", + "descPlaceholder": "What is this table for?", + "formulaLabel": "Dice formula *", + "formulaValid": "Valid formula", + "formulaExpected": "Expected format: NdM (e.g. 1d20, 2d6, d100)", + "aiTitle": "Generate with AI", + "aiHint": "Describe the table; the AI proposes the entries (review them before saving). Contextualized with your campaign.", + "aiPromptPlaceholder": "E.g. random encounters in a haunted forest, dark tone", + "generateWith": "Generate ({{formula}})", + "entriesTitle": "Entries", + "autoRanges": "Auto-ranges", + "autoRangesTitle": "Distribute ranges across the formula", + "colMin": "Min", + "colMax": "Max", + "colResult": "Result", + "colDetail": "Detail", + "detailPlaceholder": "Detail (optional)", + "emptyHint": "No entries — click \"Add\".", + "aiErrorPrompt": "Describe the table to generate.", + "aiErrorFormula": "Choose a valid dice formula first.", + "aiErrorGenerate": "AI generation failed. Try again or rephrase.", + "errorNameRequired": "Name is required.", + "errorFormulaInvalid": "Invalid dice formula (e.g. 1d20, 2d6, d100).", + "errorSaveFailed": "Save failed." + }, + "randomTableView": { + "die": "Die:", + "roll": "Roll {{formula}}", + "noMatch": "No entry for this result", + "empty": "No entries — edit the table to add some.", + "colRoll": "Roll", + "colResult": "Result" + }, + "playthroughDetail": { + "startSession": "Start a session", + "resumeSession": "Resume current session", + "facts": "Playthrough facts", + "charactersTitle": "Player characters", + "newCharacter": "New PC", + "noCharacters": "No PCs for this playthrough.", + "sessionsTitle": "Sessions", + "noSessions": "No sessions yet. Start the first one!", + "statusActive": "Ongoing", + "statusEnded": "Ended", + "impactSessions": "{{n}} session(s)", + "impactCharacters": "{{n}} PC(s)", + "impactFlags": "{{n}} fact(s)", + "impactProgressions": "{{n}} quest progression(s)", + "deleteCascade": "This action will also delete: {{parts}}.", + "irreversible": "This action is irreversible.", + "deleteTitle": "Delete playthrough", + "deleteMessage": "Delete \"{{name}}\"?" + }, + "playthroughFlagsPage": { + "title": "Facts — {{name}}", + "pageTitle": "{{name}} — Facts", + "subtitle": "Boolean facts specific to this playthrough. Toggle them in play to unlock the quests that depend on them." + }, + "campaignTree": { + "npcs": "NPCs", + "newNpc": "New NPC", + "newScene": "New scene", + "newQuest": "New quest", + "newChapter": "New chapter", + "randomTables": "Random tables", + "newTable": "New table", + "notebooks": "Workshops (AI + PDF)", + "itemCatalogs": "Item catalogs", + "enemies": "Enemies", + "newEnemy": "New enemy", + "importPdf": "Import a PDF", + "newArc": "+ New arc", + "allCampaigns": "All campaigns", + "sectionCharacters": "Characters", + "sectionNarration": "Narration", + "sectionTools": "Tools" + }, + "services": { + "importFailed": "Import failed.", + "importInterrupted": "The import was interrupted before completion (connection dropped or timed out). Please try again.", + "unknownServerError": "Unknown server error.", + "networkError": "Network error" + } +} diff --git a/web/src/assets/i18n/fr.json b/web/src/assets/i18n/fr.json new file mode 100644 index 0000000..06b1599 --- /dev/null +++ b/web/src/assets/i18n/fr.json @@ -0,0 +1,1491 @@ +{ + "language": { + "label": "Langue", + "fr": "Français", + "en": "Anglais" + }, + "common": { + "back": "Retour", + "save": "Sauvegarder", + "saving": "Sauvegarde...", + "loading": "Chargement...", + "refresh": "Actualiser", + "download": "Télécharger", + "cancel": "Annuler", + "delete": "Supprimer", + "deleting": "Suppression...", + "edit": "Modifier", + "create": "Créer", + "creating": "Création...", + "add": "Ajouter", + "remove": "Retirer", + "close": "Fermer", + "confirm": "Confirmer", + "yes": "Oui", + "no": "Non", + "search": "Rechercher", + "name": "Nom", + "description": "Description", + "titleField": "Titre", + "type": "Type", + "actions": "Actions", + "open": "Ouvrir", + "view": "Voir", + "duplicate": "Dupliquer", + "rename": "Renommer", + "import": "Importer", + "export": "Exporter", + "generate": "Générer", + "generating": "Génération...", + "validate": "Valider", + "none": "Aucun", + "optional": "(optionnel)", + "required": "(requis)", + "error": "Erreur", + "success": "Succès", + "warning": "Attention", + "previous": "Précédent", + "next": "Suivant", + "select": "Sélectionner" + }, + "settings": { + "title": "Paramètres", + "language": { + "title": "Langue de l'interface", + "hint": "Choix de la langue d'affichage de l'application. Le changement est immédiat et mémorisé sur cet appareil." + }, + "ai": { + "title": "Moteur IA", + "hint": "Choix du fournisseur de modèle de langage utilisé par le chat et la génération de pages.", + "provider": "Fournisseur", + "providerOllama": "Ollama (local)", + "providerOnemin": "1min.ai (cloud)", + "providerOpenrouter": "OpenRouter (cloud)", + "providerMistral": "Mistral (cloud)", + "providerGemini": "Gemini (cloud)" + }, + "ollama": { + "title": "Configuration Ollama", + "url": "URL du serveur Ollama", + "model": "Modèle", + "noModels": "Aucun modèle détecté. Vérifie que Ollama tourne et que l'URL est correcte." + }, + "onemin": { + "title": "Configuration 1min.ai" + }, + "openrouter": { + "title": "Configuration OpenRouter", + "freeOnly": "Gratuits seulement", + "hint": "Liste chargée automatiquement depuis OpenRouter. Pour rester gratuit : un modèle marqué · gratuit ou le routeur openrouter/free (choisit un modèle gratuit tout seul). Pour les imports, préfère un gratuit à grand contexte (valeur « ctx »)." + }, + "mistral": { + "title": "Configuration Mistral", + "hint": "Clé gratuite sur console.mistral.ai (tier « Experiment », sans CB). Pour les imports, préfère mistral-large-latest (128k contexte, fidèle et bon en français) ou mistral-small-latest (plus rapide)." + }, + "gemini": { + "title": "Configuration Gemini", + "hint": "Clé gratuite sur aistudio.google.com (sans CB). Idéal pour les imports : gemini-2.0-flash a un contexte ~1M → un livre entier tient en 1-2 appels (plus de morceaux perdus). Pense à monter la taille des morceaux d'import pour en profiter." + }, + "apiKey": { + "label": "Clé API", + "configured": "✓ configurée", + "placeholderSet": "Clé configurée (laisser vide pour ne pas changer)", + "placeholderOnemin": "Saisir votre clé API", + "placeholderOpenrouter": "Saisir votre clé API (sk-or-...)", + "placeholderMistral": "Saisir votre clé API Mistral", + "placeholderGemini": "Saisir votre clé API Gemini", + "clear": "Effacer la clé enregistrée" + }, + "model": "Modèle", + "provider": "Fournisseur", + "embeddings": { + "title": "Embeddings (Atelier RAG)", + "hint": "Modèle utilisé pour indexer les sources PDF des ateliers et y faire de la recherche. C'est un modèle séparé du modèle de chat ci-dessus.", + "provider": "Fournisseur d'embeddings", + "providerOllama": "Ollama (local, gratuit)", + "providerMistral": "Mistral (cloud, EU)", + "ollamaModel": "Modèle d'embedding Ollama", + "ollamaModelHint": "Recommandé : nomic-embed-text (léger). Gratuit et illimité (local).", + "autoPull": "Installer automatiquement le modèle au démarrage s'il manque", + "topK": "Extraits récupérés par question (couverture RAG)", + "topKHint": "Plus haut = l'IA voit plus de passages par question (mieux pour les questions larges « liste les… »), mais prompt plus long. Défaut 8. Avec un modèle gros-contexte (Gemini), tu peux monter à 50-150 pour une couverture quasi « document entier ». Pour les questions exhaustives, préfère le bouton « Analyse approfondie » de l'atelier.", + "mistralKey": "Clé API Mistral", + "mistralKeyHint": "C'est la même clé que pour le chat Mistral (une seule clé partagée). Soumis au rate limit du tier gratuit.", + "mistralModel": "Modèle d'embedding Mistral" + }, + "context": { + "title": "Fenêtre de contexte", + "tokens": "Tokens alloués au modèle", + "max": "/ {{max}} max", + "hintKnown": "Le modèle {{model}} accepte jusqu'à {{max}} tokens. Plus la valeur est élevée, plus l'IA peut tenir d'historique et de contexte — au prix de VRAM et de latence.", + "hintUnknown": "Impossible de déterminer la fenêtre max du modèle (Ollama injoignable ou modèle inconnu). Slider borné à {{max}} par sécurité." + }, + "pdf": { + "title": "Import de PDF", + "chunkTokens": "Taille des morceaux à l'import (tokens)", + "chunkTokensHint": "Lors de l'import d'un PDF (règles ou campagne), le texte est découpé en morceaux de cette taille avant d'être analysé par l'IA. Plus gros = moins de morceaux, donc plus rapide et moins de fragmentation/doublons — mais le morceau doit tenir dans la fenêtre du modèle.
Repère : ~10 000 pour Ollama (16k de contexte) ; jusqu'à ~100 000 pour un modèle à grand contexte (ex : GPT-5 mini, 400k) afin de traiter un livre entier en une seule passe. Attention : un gros morceau = génération plus longue (voir le timeout ci-dessous).", + "timeout": "Timeout des appels IA (secondes)", + "timeoutHint": "Délai max d'attente d'une réponse du modèle. Si un import lourd échoue avec « délai dépassé » (timeout), augmentez cette valeur (ex : 600) ou réduisez la taille des morceaux ci-dessus. Défaut : 300 s." + }, + "messages": { + "saveSuccess": "Paramètres enregistrés.", + "saveError": "Échec de l'enregistrement des paramètres.", + "loadError": "Impossible de charger les paramètres." + } + }, + "lore": { + "heroTitle": "Vos Univers", + "heroSubtitle": "Sélectionnez un lore existant ou créez un nouvel univers", + "cardDate": "Il y a 2h", + "statPages": "{{n}} pages", + "statFolders": "{{n}} dossiers", + "newLore": "Nouveau Lore", + "newLoreSubtitle": "Créez un nouvel univers", + "tip": "💡 Astuce : Utilisez les templates pour structurer votre univers de manière cohérente" + }, + "folderView": { + "breadcrumb": "Fil d'Ariane", + "summary": "{{folders}} sous-dossier(s) · {{pages}} page(s)", + "editTitle": "Modifier le dossier", + "deleteTitle": "Supprimer le dossier et tout son contenu", + "subfolders": "Sous-dossiers", + "newSubfolder": "Nouveau sous-dossier", + "noSubfolders": "Aucun sous-dossier.", + "pages": "Pages", + "newPage": "Nouvelle page", + "noPages": "Aucune page dans ce dossier.", + "deleteConfirmTitle": "Supprimer le dossier", + "deleteConfirmMessage": "Supprimer le dossier « {{name}} » ?", + "impact": { + "subfolders": "{{n}} sous-dossier", + "subfoldersPlural": "{{n}} sous-dossiers", + "pages": "{{n}} page", + "pagesPlural": "{{n}} pages", + "alsoDeletes": "Cette action supprimera aussi : {{items}}.", + "irreversible": "Cette action est irréversible." + } + }, + "loreCreate": { + "title": "Créer un nouveau Lore", + "nameLabel": "Nom de l'univers *", + "namePlaceholder": "Ex: Royaume des Ombres, Cyberpunk 2157...", + "descriptionPlaceholder": "Décrivez brièvement votre univers, son ambiance, son genre...", + "infoIntro": "💡 Astuce : Votre lore sera créé avec quelques templates par défaut :", + "infoNpc": "PNJ - Pour vos personnages", + "infoPlace": "Lieu - Pour vos villes et régions", + "infoFaction": "Faction - Pour vos organisations", + "infoItem": "Objet - Pour vos artefacts", + "infoFooter": "Vous pourrez créer vos propres templates ensuite !", + "submit": "Créer le lore" + }, + "loreDetail": { + "graph": "Graphe", + "graphTitle": "Visualiser le graphe des pages et de leurs liens (PNJ inclus)", + "editTitle": "Modifier le Lore", + "deleteTitle": "Supprimer le Lore", + "folders": "Dossiers", + "newFolder": "Nouveau dossier", + "noFolders": "Aucun dossier pour le moment.", + "createFirstFolder": "Créer votre premier dossier", + "deleteConfirmTitle": "Supprimer le Lore", + "deleteConfirmMessage": "Supprimer définitivement le Lore « {{name}} » ?", + "impact": { + "folders": "{{n}} dossier", + "foldersPlural": "{{n}} dossiers", + "pages": "{{n}} page", + "pagesPlural": "{{n}} pages", + "templates": "{{n}} template", + "templatesPlural": "{{n}} templates", + "alsoDeletes": "Cette action supprimera aussi : {{items}}.", + "detachedCampaigns": "{{n}} campagne sera conservée mais perdra son lien vers cet univers.", + "detachedCampaignsPlural": "{{n}} campagnes seront conservées mais perdront leur lien vers cet univers.", + "irreversible": "Cette action est irréversible." + } + }, + "loreGraph": { + "back": "Retour au Lore", + "title": "{{name}} — Graphe", + "subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.", + "legendPage": "Page de Lore", + "legendNpc": "PNJ", + "legendLink": "Lien PNJ → page", + "empty": "Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.", + "hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche." + }, + "loreNodeCreate": { + "title": "Créer un nouveau dossier", + "subtitle": "Les dossiers permettent d'organiser vos pages par catégorie", + "nameLabel": "Nom du dossier *", + "namePlaceholder": "Ex: Personnages, Créatures...", + "parentLabel": "Dossier parent", + "rootOption": "— Racine du Lore —", + "parentHint": "Laissez vide pour créer un dossier à la racine du lore", + "iconLabel": "Icône", + "descriptionPlaceholder": "Décrivez le type de contenu que ce dossier contiendra...", + "submit": "Créer le dossier" + }, + "loreNodeEdit": { + "title": "Éditer le dossier", + "summary": "{{folders}} sous-dossier(s) · {{pages}} page(s)", + "nameLabel": "Nom du dossier *", + "parentLabel": "Dossier parent", + "rootOption": "— Racine du Lore —", + "parentHint": "Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)", + "iconLabel": "Icône" + }, + "sidebar": { + "subtitle": "LE CODEX NUMÉRIQUE", + "tabLore": "Lore", + "tabCampaign": "Campagne", + "toolsLabel": "OUTILS", + "globalSearch": "Recherche globale", + "gameSystems": "Systèmes de JDR", + "vttExport": "Export VTT", + "settings": "Paramètres", + "updateAvailable": "Mise à jour disponible", + "updateBadge": "MAJ", + "version": "Version {{version}}" + }, + "secondarySidebar": { + "backToCampaignHome": "Retour à l'accueil de la campagne", + "resizeHandle": "Glissez pour redimensionner" + }, + "breadcrumb": { + "ariaLabel": "Fil d'Ariane" + }, + "globalSearch": { + "placeholder": "Rechercher dans LoreMind...", + "noResults": "Aucun résultat", + "minChars": "Tape au moins 2 caractères", + "hintNavigate": "naviguer", + "hintSelect": "sélectionner", + "hintClose": "fermer", + "countSingular": "{{count}} résultat", + "countPlural": "{{count}} résultats", + "enemyLevel": "Niv. {{level}}", + "tags": { + "page": "Page", + "node": "Dossier", + "template": "Template", + "lore": "Lore", + "campaign": "Campagne", + "npc": "PNJ", + "character": "PJ", + "randomTable": "Table aléatoire", + "itemCatalog": "Catalogue d'objets", + "enemy": "Ennemi" + } + }, + "updateBanner": { + "message": "Une nouvelle version de LoreMind est disponible ({{version}}). Recharge pour profiter des dernières améliorations.", + "reload": "Recharger", + "dismissTitle": "Fermer (sera réaffiché au prochain démarrage)", + "dismissAria": "Fermer" + }, + "confirmDialog": { + "defaultTitle": "Confirmation" + }, + "aiChatDrawer": { + "title": "Assistant IA", + "resizeAria": "Redimensionner le panneau", + "resizeTitle": "Glisser pour redimensionner", + "conversations": "Conversations", + "newConversation": "Nouvelle conversation", + "noConversation": "Aucune conversation", + "hideList": "Masquer la liste", + "showList": "Afficher la liste", + "shrink": "Réduire", + "shrinkAria": "Réduire le panneau", + "expand": "Agrandir", + "expandAria": "Agrandir le panneau", + "gaugeTitle": "Système : {{system}} · Historique : {{history}} · Courant : {{current}} tokens", + "gaugeTitleMax": "Système : {{system}} · Historique : {{history}} · Courant : {{current}} / {{max}} tokens", + "gaugeLabel": "Contexte : {{total}} tokens", + "gaugeLabelMax": "Contexte : {{total}} / {{max}} tokens", + "quickSuggestions": "Suggestions rapides :", + "inputPlaceholder": "Posez une question...", + "send": "Envoyer", + "welcome": "Bonjour ! Je peux vous aider à développer cette page. Que souhaitez-vous créer ?", + "loadConversationError": "Impossible de charger la conversation.", + "deleteConversationTitle": "Supprimer la conversation", + "deleteConversationMessage": "Supprimer la conversation « {{title}} » ?", + "missingContextError": "Contexte manquant pour créer une conversation.", + "createConversationError": "Impossible de créer la conversation.", + "unknownError": "Erreur inconnue." + }, + "imageGallery": { + "empty": "Aucune illustration", + "illustrationAlt": "Illustration", + "mainIllustrationAlt": "Illustration principale", + "mapAlt": "Carte", + "removeImage": "Retirer cette image", + "removeMap": "Retirer cette carte", + "lightboxAria": "Image en plein écran", + "lightboxAlt": "Image agrandie" + }, + "imageUploader": { + "addImage": "Ajouter une image", + "uploading": "Upload en cours", + "uploadingHint": "Upload en cours...", + "dropTitle": "Glisse une image ici", + "dropHint": "ou clique pour choisir un fichier (JPEG, PNG, WebP, GIF, max 10 Mo)", + "unsupportedFormat": "Format non supporté (JPEG, PNG, WebP, GIF uniquement).", + "tooLarge": "Fichier trop volumineux (max {{max}} Mo).", + "serverRejected": "Fichier refusé par le serveur (trop volumineux).", + "uploadFailed": "Échec de l'upload. Vérifiez que le backend et MinIO tournent." + }, + "singleImagePicker": { + "removeImage": "Retirer l'image" + }, + "chipsInput": { + "placeholder": "Ajouter...", + "removeTag": "Retirer" + }, + "dynamicFieldsForm": { + "textPlaceholder": "Renseignez {{name}}…", + "noLabels": "Aucun label défini dans le template pour ce champ.", + "noFields": "Aucun champ défini dans le template de ce système. Éditez le GameSystem pour ajouter des champs." + }, + "loreLinkPicker": { + "searchPlaceholder": "Rechercher une page à lier...", + "openInNewTab": "Ouvrir dans un nouvel onglet", + "removeLink": "Retirer le lien", + "noMatch": "Aucune page ne correspond" + }, + "enemyLinkPicker": { + "searchPlaceholder": "Rechercher un ennemi du bestiaire...", + "openInNewTab": "Ouvrir la fiche dans un nouvel onglet", + "removeLink": "Retirer le lien", + "noMatch": "Aucun ennemi ne correspond" + }, + "personaView": { + "empty": "Cette fiche est encore vide." + }, + "playthroughFlagsManager": { + "empty": "Aucun fait référencé. Ajoutez une condition « Quand un fait est vrai » sur au moins une quête pour qu'il apparaisse ici.", + "true": "vrai", + "false": "faux" + }, + "prerequisiteEditor": { + "empty": "Aucune condition — la quête est immédiatement disponible.", + "questCompletedLabel": "Quête terminée :", + "noOtherQuest": "(aucune autre quête)", + "sessionReachedLabel": "À partir de la session :", + "flagSetLabel": "Quand le fait est vrai :", + "flagNamePlaceholder": "nom_du_fait", + "removePrerequisite": "Retirer ce prérequis", + "addCondition": "Ajouter une condition", + "menuAfterQuest": "Après une autre quête", + "menuFromSession": "À partir d'une session", + "menuWhenFlag": "Quand un fait est vrai" + }, + "questStatusBadge": { + "locked": "Verrouillée", + "inProgress": "En cours", + "completed": "Terminée", + "available": "Disponible" + }, + "roomsEditor": { + "empty": "Aucune pièce. La scène se comporte comme un beat narratif classique. Ajoutez une première pièce pour la convertir en lieu explorable (donjon, crypte…).", + "roomNamePlaceholder": "Nom de la pièce", + "floorPlaceholder": "Ét.", + "floorTitle": "Étage (0 = RdC)", + "moveUp": "Monter", + "moveDown": "Descendre", + "removeRoom": "Supprimer la pièce", + "descriptionLabel": "Description (lue/résumée aux joueurs)", + "descriptionPlaceholder": "Atmosphère, ce que voient les PJ en entrant…", + "bestiaryLabel": "Ennemis du bestiaire", + "enemiesLabel": "Ennemis / créatures / boss (texte libre)", + "enemiesPlaceholder": "2 gobelins, 1 ogre boss (PV 60, AC 14)…", + "lootLabel": "Loot / trésors", + "lootPlaceholder": "50 po, potion de soin, clé en argent…", + "trapsLabel": "Pièges / dangers", + "trapsPlaceholder": "Trappe (DC 15 Perception), flèches empoisonnées…", + "gmNotesLabel": "Notes MJ (privé)", + "gmNotesPlaceholder": "Le boss connaît les PJ par leur nom, indice…", + "exitsLabel": "Sorties / portes vers d'autres pièces", + "branchLabelPlaceholder": "Porte nord", + "noOtherRoom": "(aucune autre pièce)", + "branchConditionPlaceholder": "Condition (optionnelle)", + "addExit": "Ajouter une sortie", + "addRoom": "Ajouter une pièce", + "defaultRoomName": "Salle {{n}}", + "defaultBranchLabel": "Porte" + }, + "templateFieldsEditor": { + "moveUp": "Monter", + "moveDown": "Descendre", + "fieldNamePlaceholder": "Nom du champ (ex: Histoire, PV...)", + "removeField": "Supprimer ce champ", + "labelsHeader": "Labels (clés fixes pour toutes les fiches)", + "labelPlaceholder": "Ex: FOR, DEX...", + "removeLabel": "Retirer ce label", + "addLabel": "Ajouter un label", + "empty": "Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.", + "addPrefix": "Ajouter :", + "typeText": "Texte", + "typeNumber": "Nombre", + "typeImage": "Image(s)", + "typeKeyValue": "Liste clé/valeur", + "layoutGallery": "Galerie", + "layoutHero": "Bandeau", + "layoutMasonry": "Mosaïque", + "layoutCarousel": "Carrousel", + "defaultLabel": "Champs du template" + }, + "arcCreate": { + "title": "Créer un nouvel arc narratif", + "nameLabel": "Nom de l'arc *", + "namePlaceholder": "Ex: L'Ombre du Nord", + "structureLabel": "Structure de l'arc", + "linearTitle": "Linéaire", + "linearDesc": "Chapitres joués dans l'ordre — narration séquentielle classique.", + "hubTitle": "Hub", + "hubDesc": "Quêtes parallèles débloquées par des conditions — type sandbox (ex : Phandalin / Icespire Peak).", + "descriptionPlaceholder": "Décrivez l'arc narratif principal...", + "iconLabel": "Icône", + "submit": "Créer l'arc" + }, + "arcEdit": { + "fallbackTitle": "Arc", + "subtitle": "Arc narratif", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cet arc", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max.", + "mapsLabel": "Cartes & plans", + "mapsHint": "Cartes regionales et plans utiles aux joueurs pour situer l'action.", + "nameLabel": "Titre de l'arc *", + "namePlaceholder": "Ex: L'Ombre du Nord", + "synopsisLabel": "Synopsis de l'arc", + "synopsisPlaceholder": "Décrivez l'histoire principale de cet arc narratif...", + "structureLabel": "Structure de l'arc", + "linearTitle": "Linéaire", + "linearDesc": "Chapitres joués dans l'ordre — narration séquentielle.", + "hubTitle": "Hub", + "hubDesc": "Quêtes parallèles débloquées par des conditions (sandbox).", + "iconLabel": "Icône", + "themesLabel": "Thèmes principaux", + "themesPlaceholder": "Quels sont les thèmes explorés dans cet arc ? (trahison, rédemption...)", + "stakesLabel": "Enjeux globaux", + "stakesPlaceholder": "Quels sont les enjeux majeurs de cet arc pour les personnages ?", + "gmNotesLabel": "Notes et planification du MJ", + "gmNotesPlaceholder": "Vos notes sur la direction de l'arc, les twists prévus, les révélations importantes...", + "gmNotesHint": "Ces notes sont privées et ne seront pas exportées vers FoundryVTT.", + "rewardsLabel": "Récompenses et progression", + "rewardsPlaceholder": "Quelles récompenses les joueurs obtiendront-ils ? Objets, niveaux, connaissances, contacts...", + "resolutionLabel": "Dénouement prévu", + "resolutionPlaceholder": "Comment cet arc devrait-il se terminer ? Quelles sont les issues possibles ?", + "relatedPagesLabel": "Pages Lore associées", + "relatedPagesHint": "Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.", + "noLoreHint": "💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).", + "chatWelcome": "Je vois cet arc. Demande-moi d'enrichir ses thèmes, ses enjeux ou son dénouement.", + "chatSuggestion1": "Propose 3 thèmes majeurs pour cet arc", + "chatSuggestion2": "Imagine des enjeux qui mettent la pression sur les joueurs", + "chatSuggestion3": "Suggère un dénouement en deux actes", + "deleteTitle": "Supprimer l'arc", + "deleteMessage": "Supprimer l'arc \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "arcView": { + "subtitleHub": "Arc en hub (quêtes non linéaires)", + "subtitleLinear": "Arc narratif", + "deleteTitle": "Supprimer l'arc et tout son contenu", + "mapsTitle": "Cartes & plans", + "hubQuestsTitle": "Quêtes du hub (scénario)", + "hubQuestsEmpty": "Aucune quête pour ce hub. Créez-en une pour démarrer.", + "unlockConditions": "{{n}} condition(s) de déblocage", + "synopsisTitle": "Synopsis", + "themesTitle": "Thèmes principaux", + "stakesTitle": "Enjeux globaux", + "rewardsTitle": "Récompenses et progression", + "resolutionTitle": "Dénouement prévu", + "gmNotesTitle": "Notes et planification du MJ", + "relatedPagesTitle": "Pages Lore associées", + "notProvided": "Non renseigné", + "prereqQuestCompleted": "Quête « {{name}} » terminée", + "prereqSessionReached": "Session {{n}} atteinte", + "prereqFlagSet": "Fait : {{flag}}", + "deletedPage": "(page supprimée)", + "chaptersSingular": "{{n}} chapitre", + "chaptersPlural": "{{n}} chapitres", + "scenesSingular": "{{n}} scène", + "scenesPlural": "{{n}} scènes", + "deleteCascade": "Cette action supprimera aussi : {{parts}}.", + "irreversible": "Cette action est irréversible.", + "deleteConfirmTitle": "Supprimer l'arc", + "deleteConfirmMessage": "Supprimer l'arc \"{{name}}\" ?" + }, + "campaigns": { + "heroTitle": "Vos Campagnes", + "heroSubtitle": "Rejoignez une campagne ou créez-en de nouvelles", + "statusInProgress": "En cours", + "players": "{{n}} joueurs", + "arcs": "{{n}} arcs", + "chapters": "{{n}} chapitres", + "newCampaign": "Nouvelle Campagne", + "newCampaignSubtitle": "Créez une nouvelle aventure", + "tip": "💡 Astuce : Organisez vos arcs et chapitres pour ne rien oublier de vos aventures" + }, + "campaignCreate": { + "title": "Créer une nouvelle Campagne", + "nameLabel": "Nom de la campagne *", + "namePlaceholder": "Ex: L'Ombre du Nord, Les Héritiers Oubliés...", + "descriptionLabel": "Description / Pitch", + "descriptionPlaceholder": "Résumez l'intrigue principale de votre campagne...", + "playerCountLabel": "Nombre de joueurs", + "loreLabel": "Univers associé", + "noLoreOption": "— Aucun univers (campagne libre) —", + "loreHint": "Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore. Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.", + "gameSystemLabel": "Système de JDR", + "noGameSystemOption": "— Aucun (campagne générique) —", + "createGameSystemOption": "+ Créer un nouveau système…", + "gameSystemNamePlaceholder": "Nom du nouveau système (ex: D&D 5e, Nimble, Maison)", + "gameSystemCreateHint": "Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ et le reste depuis la section « Systèmes » plus tard.", + "gameSystemHint": "Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...) dans ses suggestions pour respecter les mécaniques du JDR.", + "gameSystemWarning": "⚠️ Le système de jeu choisi détermine aussi le template des fiches de PJ et PNJ. Le changer plus tard rendra les champs des fiches existantes invisibles (les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système). Choisissez bien dès le départ si possible.", + "orgIntro": "💡 Organisation : Votre campagne sera structurée en :", + "orgArcs": "Arcs - Les grandes phases narratives", + "orgChapters": "Chapitres - Les segments d'un arc", + "orgScenes": "Scènes - Les moments de jeu individuels", + "submit": "Créer la campagne" + }, + "campaignDetail": { + "players": "{{n}} joueurs", + "openLinkedLore": "Ouvrir l'univers associé", + "loreMissingTitle": "L'univers associé est introuvable", + "loreMissing": "Univers introuvable", + "loreLabel": "Univers associé", + "noLoreOption": "— Aucun univers (campagne libre) —", + "gameSystemLabel": "Système de JDR", + "noGameSystemOption": "— Aucun (générique) —", + "createGameSystemOption": "+ Créer un nouveau système…", + "gameSystemNamePlaceholder": "Nom du nouveau système (ex: D&D 5e, Nimble, Maison)", + "charactersTitle": "Personnages", + "npcTitle": "Personnages non-joueurs", + "newNpc": "Nouveau PNJ", + "noNpc": "Aucun PNJ pour le moment.", + "createFirstNpc": "Créer votre premier PNJ", + "arcsTitle": "Arcs narratifs", + "newArc": "Nouvel arc", + "chapters": "{{n}} chapitres", + "noArc": "Aucun arc narratif pour le moment.", + "createFirstArc": "Créer votre premier arc", + "playthroughsTitle": "Mes parties", + "newPlaythrough": "Nouvelle partie", + "noPlaythrough": "Aucune partie. Créez-en une pour démarrer une session avec une table.", + "defaultPlaythroughName": "Nouvelle partie {{n}}", + "gameSystemChange": { + "title": "Changer le système de jeu ?", + "message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.", + "detailSheets": "{{n}} fiche(s) existante(s) sont liées au template du système actuel.", + "detailFields": "Leurs champs ne s'afficheront plus avec le nouveau système.", + "detailStored": "Les données restent stockées : revenir à l'ancien système les rendra à nouveau visibles.", + "confirm": "Changer quand même" + }, + "delete": { + "title": "Supprimer la campagne ?", + "message": "Supprimer définitivement la campagne « {{name}} » ?", + "arcs": "{{n}} arc", + "arcsPlural": "{{n}} arcs", + "chapters": "{{n}} chapitre", + "chaptersPlural": "{{n}} chapitres", + "scenes": "{{n}} scène", + "scenesPlural": "{{n}} scènes", + "playthroughs": "{{n}} partie (avec ses PJ, sessions, faits)", + "playthroughsPlural": "{{n}} parties (avec ses PJ, sessions, faits)", + "alsoDeletes": "Sera aussi supprimé : {{items}}.", + "irreversible": "Cette action est irréversible." + } + }, + "campaignImport": { + "pageTitle": "Importer une campagne", + "back": "Retour à la campagne", + "title": "Importer un PDF de campagne", + "subtitle": "L'IA propose une arborescence arc → chapitre → scène. Vous la révisez et l'ajustez avant de créer quoi que ce soit.", + "importing": "Import en cours…", + "choosePdf": "Choisir un PDF de campagne", + "phaseExtracting": "Extraction du texte…", + "phaseAnalyzing": "Analyse de la campagne… ({{current}}/{{total}})", + "foundSoFar": "Trouvé jusqu'ici : {{arcs}} arc(s) · {{chapters}} chapitre(s) · {{scenes}} scène(s) · {{npcs}} PNJ", + "errorNoStructure": "Aucune structure narrative détectée dans ce PDF.", + "errorImport": "Échec de l'import du PDF.", + "errorImportDetail": "Échec de l'import : {{message}}", + "errorApply": "Échec de la création. La campagne existe-t-elle toujours ?", + "summaryToCreate": "À créer : {{arcs}} nouvel(s) arc(s), {{chapters}} chapitre(s), {{scenes}} scène(s)", + "summaryNpcs": ", {{npcs}} PNJ", + "summaryHint": ". Les éléments « déjà présent » (grisés) sont affichés pour situer les ajouts ; ils ne seront pas recréés. Modifiez les nouveaux, puis créez.", + "alreadyPresent": "déjà présent", + "arcNamePlaceholder": "Nom de l'arc", + "removeArc": "Supprimer l'arc", + "typeLabel": "Type :", + "typeLinear": "Linéaire", + "typeHub": "Hub (quêtes)", + "arcSynopsisPlaceholder": "Synopsis de l'arc (optionnel)", + "chapterNamePlaceholder": "Nom du chapitre", + "removeChapter": "Supprimer le chapitre", + "chapterSynopsisPlaceholder": "Synopsis du chapitre (optionnel)", + "sceneNamePlaceholder": "Nom de la scène", + "synopsisOptionalPlaceholder": "Synopsis (optionnel)", + "detailsTitle": "Narration joueurs, notes MJ, pièces", + "details": "Détails", + "roomsCount": "· {{n}} pièce(s)", + "removeScene": "Supprimer la scène", + "playerNarrationLabel": "À lire aux joueurs", + "playerNarrationPlaceholder": "Texte d'encadré lu aux joueurs (optionnel)", + "gmNotesLabel": "Notes MJ", + "gmNotesPlaceholder": "Secrets, développement, conséquences (optionnel)", + "roomsLabel": "Pièces (lieu explorable)", + "roomPlaceholder": "Pièce", + "enemiesPlaceholder": "Ennemis", + "lootPlaceholder": "Trésor", + "removeRoom": "Supprimer la pièce", + "room": "Pièce", + "scene": "Scène", + "chapter": "Chapitre", + "addArc": "Ajouter un arc", + "npcReviewTitle": "PNJ et créatures détectés ({{n}})", + "npcReviewHint": "Cochez ceux à créer dans la campagne (description issue du livre). Ceux déjà présents dans la campagne sont grisés et ne seront pas recréés.", + "creating": "Création…", + "createInCampaign": "Créer dans la campagne" + }, + "chapterCreate": { + "titleQuest": "Créer une nouvelle quête", + "titleChapter": "Créer un nouveau chapitre", + "hub": "Hub", + "arc": "Arc", + "nameQuestLabel": "Nom de la quête *", + "nameChapterLabel": "Nom du chapitre *", + "namePlaceholderQuest": "Ex: Sauver le marchand disparu", + "namePlaceholderChapter": "Ex: Chapitre 1: Les Disparitions", + "descPlaceholderQuest": "Décrivez cette quête...", + "descPlaceholderChapter": "Décrivez ce chapitre...", + "icon": "Icône", + "createQuest": "Créer la quête", + "createChapter": "Créer le chapitre" + }, + "chapterEdit": { + "chapter": "Chapitre", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre", + "unlockTitleQuest": "🗺️ Conditions de déblocage de la quête", + "unlockTitleChapter": "🔒 Conditions de déblocage du chapitre", + "unlockHintQuest": "Définition du scénario : toutes les conditions doivent être remplies (ET) pour que la quête soit débloquée dans une Partie. Laissez vide pour qu'elle soit disponible dès le départ. La progression et l'état des faits se gèrent dans l'écran de la Partie, pas ici.", + "unlockHintChapter": "Définition du scénario : toutes les conditions doivent être remplies (ET) pour que ce chapitre soit débloqué dans une Partie. Laissez vide pour qu'il soit disponible dès le départ. La progression et l'état des faits se gèrent dans l'écran de la Partie, pas ici.", + "illustrations": "Illustrations", + "illustrationsHint": "Portraits, ambiances, scènes marquantes du chapitre.", + "maps": "Cartes & plans", + "mapsHint": "Cartes régionales, plans de donjon, schémas utiles à la table.", + "nameLabel": "Titre du chapitre *", + "namePlaceholder": "Ex: Chapitre 1: Les Disparitions", + "synopsisLabel": "Synopsis du chapitre", + "synopsisPlaceholder": "Décrivez brièvement ce qui se passe dans ce chapitre...", + "icon": "Icône", + "gmNotesLabel": "Notes du Maître de Jeu", + "gmNotesPlaceholder": "Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements...", + "gmNotesHint": "Ces notes sont privées et ne seront pas exportées vers FoundryVTT.", + "playerObjectivesLabel": "Objectifs des joueurs", + "playerObjectivesPlaceholder": "Que doivent accomplir les joueurs dans ce chapitre ?", + "narrativeStakesLabel": "Enjeux narratifs", + "narrativeStakesPlaceholder": "Quels sont les enjeux dramatiques ?", + "relatedPages": "Pages Lore associées", + "relatedPagesHint": "Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.", + "noLoreHint": "💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne pour pouvoir lier ce chapitre à des pages du Lore.", + "chatWelcome": "Je vois ce chapitre. Demande-moi d'étoffer ses objectifs, ses enjeux ou sa scène d'ouverture.", + "chatSuggestion1": "Propose des objectifs clairs pour les joueurs dans ce chapitre", + "chatSuggestion2": "Imagine 2 tensions narratives qui relancent l'intérêt en milieu de chapitre", + "chatSuggestion3": "Suggère une scène d'ouverture marquante", + "deleteTitle": "Supprimer le chapitre", + "deleteMessage": "Supprimer le chapitre \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "chapterGraph": { + "chapter": "Chapitre", + "title": "{{name}} — Carte", + "subtitle": "Organigramme des scènes et de leurs branches narratives", + "back": "Retour au chapitre", + "empty": "Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.", + "hint": "💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.", + "allCampaigns": "Toutes les campagnes" + }, + "chapterView": { + "chapter": "Chapitre", + "questHub": "Quête (Hub)", + "conditional": "Conditionnel", + "conditionalBadgeTitle": "Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.", + "map": "Carte du chapitre", + "mapTitle": "Voir l'organigramme des scènes et de leurs branches", + "deleteTitle": "Supprimer le chapitre et ses scènes", + "unlockConditions": "Conditions de déblocage", + "unlockHintQuest": "Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran « Faits » de la Partie.", + "unlockHintChapter": "Pendant une partie, ce chapitre se débloque dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran « Faits » de la Partie.", + "noConditionQuest": "Aucune condition — cette quête est disponible dès le début dans chaque Partie.", + "noConditionEdit": "Cliquez sur Modifier pour ajouter des conditions (« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »).", + "maps": "Cartes & plans", + "synopsis": "Synopsis", + "notProvided": "Non renseigné", + "playerObjectives": "Objectifs des joueurs", + "narrativeStakes": "Enjeux narratifs", + "gmNotes": "Notes du Maître de Jeu", + "relatedPages": "Pages Lore associées", + "prereqQuestCompleted": "Quête « {{name}} » terminée", + "prereqSessionReached": "Session {{n}} atteinte", + "prereqFlagSet": "Fait : {{flag}}", + "deletedPage": "(page supprimée)", + "deleteScenes": "Cette action supprimera aussi : {{n}} scène.", + "deleteScenesPlural": "Cette action supprimera aussi : {{n}} scènes.", + "irreversible": "Cette action est irréversible.", + "deleteChapterTitle": "Supprimer le chapitre", + "deleteChapterMessage": "Supprimer le chapitre \"{{name}}\" ?" + }, + "pageCreate": { + "title": "Créer une nouvelle Page", + "subtitle": "Créez une page à partir d'un template existant", + "pageTitle": "Nouvelle page", + "pageTitleLabel": "Titre de la page *", + "pageTitlePlaceholder": "Ex : Maître Eldrin, La Cité d'Argent...", + "templateLabel": "Template *", + "createTemplate": "Créer un template", + "createTemplateTitle": "Créer un nouveau template pour ce Lore", + "createTemplateHint": "Vous reviendrez ici automatiquement, votre saisie sera conservée.", + "noTemplates": "Aucun template défini pour ce Lore.", + "firstSuffix": "d'abord.", + "nodeLabel": "Dossier de destination *", + "nodePlaceholder": "Sélectionnez un dossier", + "nodeHint": "La page sera créée dans ce dossier", + "noNodes": "Aucun dossier dans ce Lore.", + "createNode": "Créer un dossier", + "infoBox": "💡 Option 1 : Créer la page vide, puis remplir les champs manuellement.
💡 Option 2 : Créer avec l'IA pour dialoguer avec un assistant qui pré-remplira les champs.", + "aiTitle": "Ouvrir l'assistant IA pour pré-remplir les champs", + "createWithAi": "Créer avec l'IA", + "createPage": "Créer la page", + "wizardPrimaryAction": "Appliquer et créer la page", + "wizardSuggestion1": "Rends la description plus courte", + "wizardSuggestion2": "Ajoute un trait distinctif marquant", + "wizardSuggestion3": "Donne un ton plus sombre", + "wizardWelcomeEmpty": "Décrivez ce que vous souhaitez créer.", + "wizardWelcome": "Super, on va créer une page « {{name}} » ! Décrivez-la-moi en quelques mots — contexte, rôle, traits marquants — et je proposerai des valeurs pour chaque champ.", + "errorNoReply": "L'assistant n'a pas encore répondu. Décrivez d'abord votre idée.", + "errorNoValues": "Impossible d'extraire les valeurs. Demandez à l'assistant de proposer à nouveau.", + "errorApplyValues": "Page créée, mais impossible d'appliquer les valeurs.", + "errorCreate": "Erreur lors de la création de la page." + }, + "pageEdit": { + "pageFallback": "Page", + "aiTitle": "Ouvrir l'Assistant IA (chat ou remplissage automatique)", + "aiAssistant": "Assistant IA", + "generating": "Génération…", + "folder": "Dossier", + "folderHint": "Déplacez cette page dans un autre dossier", + "fields": "Champs", + "valuePlaceholder": "Valeur pour {{field}}...", + "noLabels": "Aucun libellé défini dans le template pour ce champ.", + "deleteRow": "Supprimer la ligne", + "deleteRowN": "Supprimer la ligne {{n}}", + "addRow": "Ajouter une ligne", + "noColumns": "Aucune colonne définie dans le template pour ce tableau.", + "tags": "Tags", + "tagsPlaceholder": "Ajouter un tag (Entrée pour valider)...", + "tagsHint": "Mots-clés libres pour classer et retrouver cette page", + "relatedPages": "Pages liées", + "relatedPagesHint": "Cliquez sur un lien pour ouvrir la page associée", + "privateNotes": "Notes privées", + "privateNotesPlaceholder": "Notes personnelles (non exportées vers FoundryVTT)", + "privateNotesHint": "Visibles uniquement par vous. Utiles pour préparer vos sessions.", + "chatPrimaryAction": "Remplir automatiquement tous les champs", + "chatSuggestion1": "Étoffe l'histoire de cette page", + "chatSuggestion2": "Suggère des liens avec d'autres pages du Lore", + "chatSuggestion3": "Propose une intrigue secondaire", + "aiUnreachable": "L'assistant IA est injoignable. Vérifiez que le service Brain tourne.", + "aiFailed": "Échec de la génération IA. Réessayez dans un instant.", + "deleteTitle": "Supprimer la page", + "deleteMessage": "Supprimer la page « {{title}} » ?" + }, + "pageView": { + "pageFallback": "Page", + "deleteTitle": "Supprimer la page", + "notFilled": "Non renseigné", + "tags": "Tags", + "relatedPages": "Pages liées", + "privateNotes": "Notes privées", + "deletedPage": "(page supprimée)", + "deleteMessage": "Supprimer la page « {{title}} » ?", + "deleteIrreversible": "Cette action est irréversible." + }, + "templateCreate": { + "title": "Créer un nouveau Template", + "subtitle": "Définissez un gabarit personnalisé pour créer des pages cohérentes", + "nameLabel": "Nom du template *", + "namePlaceholder": "Ex : Auberge, Artefact, Monstre...", + "descriptionPlaceholder": "À quoi sert ce template ?", + "defaultNodeLabel": "Dossier par défaut *", + "nodePlaceholder": "Sélectionnez un dossier", + "defaultNodeHint": "Les pages créées avec ce template seront placées dans ce dossier", + "noNodes": "Aucun dossier dans ce Lore.", + "createNode": "Créer un dossier", + "firstSuffix": "d'abord.", + "fieldsLabel": "Champs du template *", + "moveUp": "Monter", + "moveDown": "Descendre", + "fieldTypeTitle": "Type du champ", + "typeText": "Texte", + "typeImage": "Image", + "typeKeyValue": "Liste clé/valeur", + "typeTable": "Tableau", + "layoutTitle": "Mise en page des images", + "layoutGallery": "Grille", + "layoutHero": "Héros", + "layoutMasonry": "Mosaïque", + "layoutCarousel": "Carrousel", + "column": "Colonne", + "label": "Libellé", + "columnN": "Colonne {{n}}", + "labelN": "Libellé {{n}}", + "tableLabelsHint": "Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)", + "kvLabelsHint": "Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)", + "addFieldPlaceholder": "+ Ajouter un champ", + "addFieldTitle": "Ajouter le champ", + "fieldsHelp": "Texte = libre + utilisable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).", + "createTemplate": "Créer le template" + }, + "templateEdit": { + "subtitle": "Template", + "defaultNodeLabel": "Dossier par défaut", + "noneOption": "-- Aucun --", + "defaultNodeHint": "Les pages créées avec ce template seront placées dans ce dossier par défaut", + "fieldsLabel": "Champs du template", + "moveUp": "Monter", + "moveDown": "Descendre", + "fieldTypeTitle": "Type du champ", + "typeText": "Texte", + "typeImage": "Image", + "typeKeyValue": "Liste clé/valeur", + "typeTable": "Tableau", + "layoutTitle": "Mise en page des images", + "layoutGallery": "Grille", + "layoutHero": "Héros", + "layoutMasonry": "Mosaïque", + "layoutCarousel": "Carrousel", + "column": "Colonne", + "label": "Libellé", + "columnN": "Colonne {{n}}", + "labelN": "Libellé {{n}}", + "tableLabelsHint": "Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)", + "kvLabelsHint": "Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)", + "addFieldPlaceholder": "+ Ajouter un champ", + "addFieldTitle": "Ajouter le champ", + "fieldsHelp": "Texte = libre + générable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).", + "deleteTitle": "Supprimer le template", + "deleteMessage": "Supprimer le template « {{name}} » ?" + }, + "sceneCreate": { + "title": "Créer une nouvelle scène", + "chapterRef": "Chapitre : {{name}}", + "nameLabel": "Nom de la scène *", + "namePlaceholder": "Ex: Arrivée au village", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Décrivez la scène, les événements clés, les PNJ présents...", + "iconLabel": "Icône", + "createButton": "Créer la scène", + "createError": "Erreur lors de la création de la scène" + }, + "sceneEdit": { + "defaultTitle": "Scène", + "subtitle": "Scène", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cette scène", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Portraits des PNJ, ambiance visuelle, scenes evocatrices...", + "mapsLabel": "Cartes & plans", + "mapsHint": "Plans du lieu, cartes tactiques, schemas utilisables a la table.", + "nameLabel": "Titre de la scène *", + "namePlaceholder": "Ex: Arrivée au village", + "descriptionLabel": "Description courte *", + "descriptionPlaceholder": "Résumé en une ou deux phrases de ce qui se passe...", + "iconLabel": "Icône", + "contextSectionTitle": "Contexte et ambiance", + "locationLabel": "Lieu", + "locationPlaceholder": "Ex: Taverne du Dragon d'Or", + "timingLabel": "Moment", + "timingPlaceholder": "Ex: Soir, à la tombée de la nuit", + "atmosphereLabel": "Ambiance et atmosphère", + "atmospherePlaceholder": "Décrivez l'ambiance générale de la scène (sons, odeurs, lumière, émotions...)", + "narrationSectionTitle": "Narration pour les joueurs", + "narrationPlaceholder": "Le texte que vous lirez aux joueurs pour planter le décor de cette scène...", + "narrationHint": "Ce texte peut être lu directement à vos joueurs.", + "gmNotesSectionTitle": "Notes et secrets du MJ", + "gmNotesPlaceholder": "Informations cachées, indices, éléments secrets que les joueurs ne doivent pas connaître...", + "gmNotesHint": "Ces notes sont privées et visibles uniquement par le MJ.", + "choicesSectionTitle": "Choix et conséquences", + "choicesPlaceholder": "Décrivez les différentes options qui s'offrent aux joueurs et leurs conséquences...", + "branchesSectionTitle": "Branches narratives", + "branchesNoSibling": "💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches. Créez d'abord d'autres scènes, puis revenez ici pour les connecter.", + "branchLabelLabel": "Libellé du choix", + "branchLabelPlaceholder": "Ex: Si les joueurs attaquent le garde", + "branchTargetLabel": "Scène de destination *", + "branchTargetPlaceholder": "— Choisir une scène —", + "branchConditionLabel": "Condition MJ (optionnel)", + "branchConditionPlaceholder": "Ex: Jet de Persuasion DD 15 réussi", + "branchRemove": "Retirer", + "branchRemoveTitle": "Supprimer cette branche", + "branchAdd": "+ Ajouter une branche", + "branchesHint": "Chaque branche représente une \"sortie\" possible depuis cette scène selon l'action des joueurs. Les cibles sont limitées aux scènes du même chapitre.", + "combatSectionTitle": "Combat ou rencontre", + "combatDifficultyLabel": "Difficulté estimée", + "combatDifficultyPlaceholder": "Ex: Moyenne, 3 gobelins niveau 2", + "bestiaryEnemiesLabel": "Ennemis du bestiaire", + "bestiaryEnemiesHint": "Référencez des fiches de votre bestiaire — cliquez sur un chip pour ouvrir la fiche.", + "enemiesLabel": "Ennemis et créatures (texte libre)", + "enemiesPlaceholder": "Liste des ennemis présents dans cette scène...", + "loreSectionTitle": "Pages Lore associées", + "loreHint": "Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page.", + "noLoreHint": "💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne pour pouvoir épingler des pages du Lore à cette scène.", + "dungeonSectionTitle": "Lieu explorable (donjon, crypte…)", + "dungeonHint": "Si cette scène représente un lieu à parcourir pièce par pièce, ajoutez-les ici. La scène bascule alors en mode « donjon » côté affichage.", + "chatWelcome": "Je vois cette scène. Demande-moi d'enrichir son ambiance, sa narration ou ses choix.", + "chatSuggestion1": "Propose une ambiance sensorielle immersive pour cette scène", + "chatSuggestion2": "Suggère une narration d'ouverture à lire aux joueurs", + "chatSuggestion3": "Imagine 2 choix avec conséquences marquantes", + "deleteTitle": "Supprimer la scène", + "deleteMessage": "Supprimer la scène \"{{name}}\" ?", + "deleteIrreversible": "Cette action est irréversible.", + "saveError": "Erreur lors de la sauvegarde", + "deleteError": "Erreur lors de la suppression" + }, + "sceneView": { + "subtitle": "Scène", + "deleteTitleAttr": "Supprimer la scène", + "mapsSectionTitle": "Cartes & plans", + "descriptionSectionTitle": "Description", + "empty": "Non renseigné", + "locationSectionTitle": "Lieu", + "timingSectionTitle": "Moment", + "atmosphereSectionTitle": "Ambiance et atmosphère", + "narrationSectionTitle": "Narration pour les joueurs", + "choicesSectionTitle": "Choix et conséquences", + "combatDifficultySectionTitle": "Difficulté estimée", + "enemiesSectionTitle": "Ennemis et créatures", + "gmNotesSectionTitle": "Notes et secrets du MJ", + "loreSectionTitle": "Pages Lore associées", + "deletedPage": "(page supprimée)", + "roomsSectionTitle": "Pièces du lieu", + "roomFloor": "Étage {{floor}}", + "roomEnemies": "⚔️ Ennemis", + "roomLoot": "💰 Loot", + "roomTraps": "⚠️ Pièges", + "roomGmNotes": "🔒 Notes MJ", + "roomExits": "→ Sorties", + "roomExitCondition": "(si : {{condition}})", + "deletedRoom": "(pièce supprimée)", + "deleteTitle": "Supprimer la scène", + "deleteMessage": "Supprimer la scène \"{{name}}\" ?", + "deleteIrreversible": "Cette action est irréversible.", + "deleteError": "Erreur lors de la suppression de la scène" + }, + "characterEdit": { + "backToCampaign": "Retour à la campagne", + "titleEdit": "Éditer la fiche", + "titleNew": "Nouveau personnage", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de ce PJ", + "nameLabel": "Nom du personnage *", + "namePlaceholder": "Ex: Thorin le Grand-Hache, Lyra l'Errante...", + "portrait": "Portrait", + "portraitHint": "Carré conseillé (400×400).", + "header": "Bandeau / Header", + "headerHint": "Format paysage conseillé (1200×400).", + "chatWelcome": "Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels.", + "chatSuggestion1": "Propose une backstory cohérente avec l'univers", + "chatSuggestion2": "Suggère 3 objectifs personnels pour ce personnage", + "chatSuggestion3": "Aide-moi à équilibrer les stats de combat", + "deleteTitle": "Supprimer la fiche ?", + "deleteMessage": "Supprimer la fiche de \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "characterView": { + "aiAssistant": "Assistant IA" + }, + "npcEdit": { + "backToCampaign": "Retour à la campagne", + "titleEdit": "Éditer le PNJ", + "titleNew": "Nouveau PNJ", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ", + "nameLabel": "Nom du PNJ *", + "namePlaceholder": "Ex: Borin le forgeron, Dame Elara, Kael l'aubergiste...", + "folder": "Dossier", + "folderPlaceholder": "Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)", + "portrait": "Portrait", + "portraitHint": "Carré conseillé (400×400).", + "header": "Bandeau / Header", + "headerHint": "Format paysage conseillé (1200×400).", + "relatedPages": "Pages de Lore liées", + "relatedPagesHint": "Reliez ce PNJ aux pages de l'univers (sa ville, sa faction, sa région…). Ces liens apparaissent dans le graphe du Lore.", + "chatWelcome": "Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures.", + "chatSuggestion1": "Propose une apparence et une posture marquantes", + "chatSuggestion2": "Suggère 2 motivations et un secret pour ce PNJ", + "chatSuggestion3": "Imagine 3 répliques signatures qui le caractérisent", + "deleteTitle": "Supprimer la fiche ?", + "deleteMessage": "Supprimer la fiche de \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "npcList": { + "create": "Nouveau PNJ", + "title": "PNJ", + "hint": "Tous les personnages non-joueurs de la campagne, classés par dossier. Le champ « Dossier » de chaque fiche pilote le classement.", + "empty": "Aucun PNJ pour l'instant.", + "unclassified": "Non classés", + "deleteTitle": "Supprimer la fiche", + "deleteMessage": "Supprimer la fiche de « {{name}} » ?", + "irreversible": "Cette action est irréversible." + }, + "npcView": { + "aiAssistant": "Assistant IA", + "relatedPages": "Pages de Lore liées", + "deletedPage": "(page supprimée)" + }, + "enemyEdit": { + "backToEnemies": "Retour aux ennemis", + "titleEdit": "Éditer l'ennemi", + "titleNew": "Nouvel ennemi", + "nameLabel": "Nom de l'ennemi *", + "namePlaceholder": "Ex: Balor, Gobelin chef de guerre, Liche…", + "level": "Niveau / FP", + "levelPlaceholder": "Ex: 5, FP 8, Boss…", + "folder": "Dossier", + "folderPlaceholder": "Ex: Démons, Humanoïdes… (vide = non classé)", + "portrait": "Portrait", + "portraitHint": "Carré conseillé (400×400).", + "header": "Bandeau / Header", + "headerHint": "Format paysage conseillé (1200×400).", + "deleteTitle": "Supprimer la fiche ?", + "deleteMessage": "Supprimer la fiche de \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "enemyList": { + "create": "Nouvel ennemi", + "title": "Ennemis", + "hint": "Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…).", + "empty": "Aucun ennemi pour l'instant.", + "unclassified": "Non classés", + "levelShort": "Niv. {{level}}", + "deleteTitle": "Supprimer l'ennemi", + "deleteMessage": "Supprimer « {{name}} » ?" + }, + "enemyView": { + "levelLong": "Niveau {{level}}" + }, + "gameSystems": { + "heroTitle": "Systèmes de JDR", + "heroSubtitle": "Les règles que l'IA connaîtra pour vos campagnes", + "noDescription": "(Pas de description)", + "byAuthor": "par {{author}}", + "public": "public", + "newSystem": "Nouveau système", + "newSystemSubtitle": "Saisir les règles d'un JDR (markdown)", + "tip": "💡 Astuce : organisez vos règles par sections avec des titres ## Combat, ## Classes, ## Lore… Le système injectera les sections pertinentes selon ce que l'IA doit générer.", + "deleteTitle": "Supprimer le système", + "deleteMessage": "Supprimer le système \"{{name}}\" ?", + "deleteDetail": "Les campagnes qui l'utilisent ne seront plus associées à aucun système." + }, + "gameSystemEdit": { + "backToList": "Retour à la liste", + "editTitle": "Éditer le système", + "createTitle": "Nouveau système de JDR", + "nameLabel": "Nom *", + "namePlaceholder": "Ex: Nimble, D&D 5.1 SRD, Mon Homebrew...", + "descriptionLabel": "Description courte", + "descriptionPlaceholder": "En une ligne, de quoi parle ce système ?", + "authorLabel": "Auteur", + "authorPlaceholder": "Ex: Hasbro, Homebrew, moi-même...", + "rulesTitle": "Règles du système", + "rulesHint": "Une section = un thème. L'IA injectera automatiquement les sections pertinentes selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions).", + "importButton": "Importer un PDF de règles", + "importInProgress": "Import en cours…", + "importHint": "L'IA propose un découpage en sections — vous révisez avant d'enregistrer.", + "sectionsFound": "Sections trouvées : {{titles}}", + "sectionTitlePlaceholder": "Nom de la section (ex: Combat)", + "removeSection": "Supprimer cette section", + "sectionContentPlaceholder": "Décrivez les règles de cette section...", + "noSections": "Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous.", + "addSection": "Ajouter une section :", + "addOther": "Autre…", + "charactersTitle": "Fiches de personnages", + "charactersHint": "Définissez la structure des fiches PJ et PNJ pour ce système. Les champs universels (nom, portrait, header) sont automatiques — ne rajoutez ici que les champs spécifiques au système (Histoire, PV, Stats…).", + "pcFieldsLabel": "Champs de la fiche PJ", + "pcFieldsHint": "Affichés lors de la création/édition d'un personnage joueur.", + "npcFieldsLabel": "Champs de la fiche PNJ", + "npcFieldsHint": "Affichés lors de la création/édition d'un personnage non-joueur.", + "enemyFieldsLabel": "Champs de la fiche Ennemi", + "enemyFieldsHint": "Affichés lors de la création/édition d'un ennemi (bestiaire). Nom, niveau, dossier, portrait et bandeau sont automatiques.", + "importExtracting": "Extraction du texte…", + "importAnalyzing": "Analyse des règles… ({{current}}/{{total}})", + "importFailed": "Échec de l'import du PDF.", + "importFailedReason": "Échec de l'import : {{reason}}", + "importNoRules": "Aucune règle exploitable n'a été extraite de ce PDF (scan sans OCR, ou contenu non reconnu).", + "importOcrSuffix": " (dont {{count}} page(s) via OCR)", + "importNote": "{{added}} section(s) proposée(s) depuis {{pages}} page(s){{ocr}}. Relisez et ajustez ci-dessous avant d'enregistrer." + }, + "ollamaModelManager": { + "installedModels": "Modèles installés", + "deleteModelTitle": "Supprimer {{name}}", + "pullDialogTitle": "Télécharger un modèle Ollama", + "modelNameLabel": "Nom du modèle", + "suggestions": "Suggestions :", + "libraryHint": "La liste complète est sur ollama.com/library.", + "preparing": "Préparation...", + "connecting": "connexion...", + "cancelled": "annulé", + "pullEventError": "Échec : {{error}}", + "pullFailed": "Échec du téléchargement de {{name}}.", + "pullInterrupted": "Téléchargement de {{name}} interrompu avant la fin. Relancez pour reprendre.", + "pullDone": "Modèle {{name}} téléchargé.", + "deleteTitle": "Supprimer le modèle", + "deleteMessage": "Supprimer le modèle '{{name}}' ?", + "deleteDetail": "L'espace disque sera libéré.", + "deleteDone": "Modèle {{name}} supprimé.", + "deleteFailed": "Échec de la suppression de {{name}}." + }, + "updatesSection": { + "title": "Mises à jour", + "hint": "Vérifie auprès du registry Docker si une nouvelle version des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont exclus — ils sont mis à jour manuellement.", + "stableChannel": "Canal stable", + "checking": "Vérification...", + "checkNow": "Vérifier maintenant", + "featureNotConfigured": "Feature non configurée (WATCHTOWER_TOKEN absent).", + "updateAvailable": "Une mise à jour est disponible.", + "checkImpossible": "Vérification impossible (baseline absente ou registry injoignable).", + "upToDate": "Tout est à jour (vérifié le {{date}}).", + "applying": "Mise à jour en cours...", + "applyNow": "Mettre à jour maintenant", + "betaChannelTitle": "Canal beta — réservé aux patrons", + "betaChannelIntro": "Soutiens LoreMind sur Patreon pour accéder aux nouvelles features en avant-première. Le tier Compagnon (7€/mois) ou supérieur débloque ce canal.", + "connectPatreon": "Connecter mon compte Patreon", + "connectInstructions": "Une nouvelle fenêtre va s'ouvrir vers Patreon. Après autorisation, copie le token affiché et colle-le ci-dessous.", + "patreonToken": "Token Patreon", + "activateLicense": "Activer la licence", + "licenseValid": "Compte Patreon connecté. Tier {{tier}} actif.", + "licenseGrace": "Connexion Patreon expirée, mais accès beta maintenu pendant la période de tolérance. Vérifie que ton abonnement Patreon est toujours actif et clique sur \"Vérifier maintenant\".", + "licenseExpired": "Connexion Patreon expirée depuis trop longtemps. Reconnecte-toi pour retrouver l'accès beta.", + "licenseUnverifiable": "Le token installé ne peut plus être vérifié. Reconnecte-toi.", + "infoTier": "Tier : {{tier}}", + "infoValidity": "Validité : jusqu'au {{date}}", + "renewalDay": "(renouvellement dans {{n}} jour)", + "renewalDays": "(renouvellement dans {{n}} jours)", + "infoLastRefresh": "Dernier refresh : {{date}}", + "refreshOk": "OK", + "refreshFailed": "échec", + "enableBetaChannel": "Activer le canal beta", + "disconnectPatreon": "Déconnecter Patreon", + "checkingBetaImages": "Vérification des images beta...", + "betaUnavailable": "Indisponible : {{reason}}", + "betaCheckImpossible": "Vérification beta impossible (registry beta injoignable ou baseline absente).", + "currentChannel": "Canal actuel :", + "channelBeta": "Bêta", + "channelStable": "Stable", + "switchToBeta": "Passer sur le canal beta", + "switchToStable": "Repasser sur le canal stable", + "switching": "Bascule en cours...", + "switchInProgressNotice": "Bascule en cours. L'application va être indisponible 10 à 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera prêt.", + "switcherUnavailable": "Le sidecar de bascule n'est pas installé. Pour bénéficier du switch automatique, récupère le dernier docker-compose.yml du repo et fais docker compose pull && docker compose up -d une fois. Sinon, bascule manuellement en éditant IMAGE_NAMESPACE dans ton .env (igmlcreation/loremind- pour stable, igmlcreation/loremind-beta- pour beta).", + "connectUrlError": "Impossible de générer l'URL de connexion. Vérifie ta config.", + "pasteTokenFirst": "Colle d'abord le token reçu après connexion Patreon.", + "patreonConnectedSuccess": "Compte Patreon connecté. L'accès beta est actif.", + "disconnectTitle": "Déconnecter Patreon", + "disconnectMessage": "Déconnecter ton compte Patreon ?", + "disconnectDetail": "Tu perdras l'accès au canal beta.", + "disconnectConfirm": "Déconnecter", + "patreonDisconnected": "Compte Patreon déconnecté.", + "switchToBetaMessage": "Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont être recréés avec les images beta. L'application sera indisponible 10-30 secondes.", + "switchToStableMessage": "Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont être recréés avec les images stables. L'application sera indisponible 10-30 secondes.", + "switchToBetaTitle": "Passer en beta ?", + "switchToStableTitle": "Repasser en stable ?", + "switchDetailData": "Les données (DB, images) sont préservées.", + "switchDetailReversible": "Tu pourras refaire le chemin inverse à tout moment depuis cet écran.", + "switchToBetaConfirm": "Passer en beta", + "switchToStableConfirm": "Repasser en stable", + "switchFailed": "Échec du switch", + "applyTitle": "Mettre à jour", + "applyMessage": "Télécharger et redémarrer les conteneurs maintenant ?", + "applyDetail": "L'app sera indisponible quelques secondes.", + "applyConfirm": "Mettre à jour", + "applyTriggered": "Mise à jour déclenchée. Rechargez la page dans 30s." + }, + "itemCatalogEdit": { + "editTitle": "Éditer le catalogue", + "createTitle": "Nouveau catalogue d'objets", + "nameLabel": "Nom *", + "namePlaceholder": "Ex: Échoppe de l'armurier nain", + "descriptionPlaceholder": "À quoi sert ce catalogue / cette boutique ?", + "aiTitle": "Générer avec l'IA", + "aiHint": "Décris la boutique/le butin ; l'IA propose les objets (revois-les avant d'enregistrer). Contextualisé avec ta campagne.", + "aiPlaceholder": "Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques", + "itemsTitle": "Objets", + "priceLabel": "Prix", + "categoryLabel": "Catégorie", + "itemNamePlaceholder": "Objet", + "itemPricePlaceholder": "50 po", + "itemCategoryPlaceholder": "Armes", + "itemDescriptionPlaceholder": "Effet / détails", + "emptyHint": "Aucun objet — clique « Ajouter ».", + "aiPromptRequired": "Décris le catalogue à générer.", + "aiError": "Échec de la génération IA. Réessaie ou reformule.", + "nameRequired": "Le nom est requis.", + "saveError": "Échec de l'enregistrement." + }, + "itemCatalogList": { + "title": "Catalogues d'objets", + "hint": "Boutiques, butins, trésors… à remplir à la main ou via l'IA. Consultables en session quand les joueurs visitent une échoppe.", + "newCatalog": "Nouveau catalogue", + "empty": "Aucun catalogue pour l'instant.", + "itemCount": "{{n}} objet(s)", + "deleteTitle": "Supprimer le catalogue", + "deleteMessage": "Supprimer « {{name}} » ?" + }, + "itemCatalogView": { + "empty": "Aucun objet — édite le catalogue pour en ajouter." + }, + "notebookActionCard": { + "arc": "Arc", + "chapter": "Chapitre", + "warnArc": "Crée d'abord un arc dans la campagne pour pouvoir y rattacher cet élément.", + "warnArcChapter": "Crée d'abord un arc et un chapitre dans la campagne pour pouvoir y rattacher cet élément.", + "creating": "Création…", + "createInCampaign": "Créer dans la campagne", + "createdOpen": "Créé — ouvrir", + "createError": "Échec de la création.", + "typeNpc": "PNJ", + "typeScene": "Scène", + "typeChapter": "Chapitre", + "typeArc": "Arc", + "typeTable": "Table aléatoire" + }, + "notebookList": { + "title": "Ateliers d'adaptation", + "hint": "Discute avec l'IA à partir d'un PDF source (recherche dans le document) pour adapter son contenu à ta campagne. La source et la conversation sont conservées.", + "namePlaceholder": "Nom de l'atelier (ex: Adaptation du module X)", + "newNotebook": "Nouvel atelier", + "empty": "Aucun atelier pour l'instant.", + "defaultName": "Nouvel atelier", + "deleteTitle": "Supprimer l'atelier", + "deleteMessage": "Supprimer « {{name}} » et ses sources indexées ?" + }, + "notebookDetail": { + "backToList": "Ateliers", + "sources": "Sources", + "indexing": "Indexation…", + "addPdf": "Ajouter un PDF", + "sourcesUsed": "{{selected}}/{{total}} source(s) utilisée(s) par le chat et l'analyse approfondie", + "sourceIncludedTitle": "Source utilisée par le chat — décocher pour l'exclure", + "sourceExcludedTitle": "Source ignorée par le chat — cocher pour l'inclure", + "sourceMeta": "{{pages}} p. · {{chunks}} extraits", + "indexingInProgress": "indexation en cours…", + "indexingFailed": "échec de l'indexation", + "sourcesEmpty": "Ajoute un PDF source pour commencer à discuter avec.", + "archiveBanner": "Archive du {{label}} — lecture seule", + "backToChat": "Revenir au chat", + "refBadgeTitle": "Le contenu de ces archives est injecté comme référence dans chaque question", + "refBadge": "{{n}} archive(s) en référence", + "archivesBtnTitle": "Conversations archivées (lecture seule + référence)", + "archives": "Archives", + "clearBtnTitle": "Vider la conversation (le fil actuel est archivé, pas supprimé)", + "clear": "Vider", + "archivesEmpty": "Aucune conversation archivée pour l'instant — « Vider » archive le fil courant ici.", + "archivesHelp": "Cochez une archive pour l'utiliser comme référence dans le chat ; cliquez sur son nom pour la relire.", + "archiveRefOnTitle": "Archive utilisée comme référence — décocher pour la retirer", + "archiveRefOffTitle": "Utiliser cette archive comme référence dans le chat", + "roleUser": "Vous", + "roleAi": "IA", + "chatEmpty": "Pose une question sur ta source, ou demande une adaptation pour ta campagne.", + "chatEmptyNoSource": "
(Ajoute d'abord une source indexée pour des réponses ancrées.)", + "deepProgress": "Analyse approfondie du document… {{current}}/{{total}}", + "sourcesPagesTitle": "Pages de la source utilisées pour ancrer cette réponse", + "draftPlaceholder": "Demande une adaptation, un résumé, un PNJ inspiré de la source…", + "deepBtnTitle": "Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »", + "sendBtnTitle": "Réponse rapide (recherche ciblée dans le document)", + "uploadError": "Échec de l'indexation. Réessaie ou vérifie le modèle d'embedding.", + "clearTitle": "Vider la conversation", + "clearMessage": "Repartir d'une conversation vierge ?", + "clearDetail": "Le fil actuel est archivé (pas supprimé) : il restera consultable via « Archives ».", + "archiveLabel": "{{when}} · {{n}} message(s)" + }, + "sessionDetail": { + "backToCampaign": "Retour à la campagne", + "renameTitle": "Renommer la session", + "statusActive": "En cours", + "statusEnded": "Terminée", + "startedOn": "Démarrée le {{date}}", + "endedOn": "Terminée le {{date}}", + "endSession": "Terminer la session", + "ctrlEnterHint": "Ctrl + Entrée pour ajouter", + "addEntryPlaceholder": "Ajouter une {{type}}…", + "journalTitle": "Journal de session", + "noEntries": "Aucune entrée pour le moment.", + "noEntriesHint": "Saisis une note, un évènement ou un jet ci-dessus pour commencer le journal.", + "entryType": { + "NOTE": "Note", + "EVENT": "Évènement", + "DICE_ROLL": "Jet de dés", + "PLAYER_ACTION": "Action joueur" + }, + "endConfirm": { + "title": "Terminer la session ?", + "message": "Marquer la session \"{{name}}\" comme terminée ?", + "detail": "Tu pourras toujours consulter son contenu après.", + "confirmLabel": "Terminer" + }, + "deleteConfirm": { + "title": "Supprimer la session ?", + "message": "Supprimer définitivement la session \"{{name}}\" ?", + "noEntries": "Aucune entrée de journal pour cette session.", + "entriesOne": "1 entrée de journal sera également supprimée.", + "entriesMany": "{{n}} entrées de journal seront également supprimées.", + "irreversible": "Cette action est irréversible." + }, + "deleteEntryConfirm": { + "title": "Supprimer cette entrée ?", + "message": "Cette entrée du journal sera définitivement supprimée." + } + }, + "sessionReferencePanel": { + "tabAi": "IA", + "tabDice": "Dés", + "tabTables": "Tables", + "tabObjects": "Objets", + "tabCharacters": "PJ/PNJ", + "tabScenes": "Scènes", + "playerCharacters": "Personnages joueurs", + "nonPlayerCharacters": "Personnages non-joueurs", + "emptyCharacters": "Aucun personnage dans cette campagne.", + "emptyScenes": "Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici." + }, + "sessionDicePanel": { + "count": "Nombre", + "modifier": "Modificateur", + "roll": "Lancer", + "lastRolls": "Derniers jets", + "clearHistory": "Vider l'historique local", + "addToJournal": "Ajouter au journal", + "sessionEnded": "Session terminée", + "placeholder": "Choisis un dé et lance." + }, + "sessionAiChatPanel": { + "welcome": "Pose une question à l'IA pendant la partie.", + "welcomeSub": "Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal.", + "saveReplyTitle": "Ajouter cette réponse au journal", + "sessionEnded": "Session terminée", + "toJournal": "Au journal", + "replying": "L’IA répond…", + "inputPlaceholder": "Demande une idée, un rebondissement, une description…", + "clearConversation": "Effacer la conversation", + "send": "Envoyer", + "stop": "Stop", + "unknownError": "Erreur inconnue", + "aiError": "Erreur IA : {{message}}", + "interrupted": "[interrompu]" + }, + "sessionRandomTablesPanel": { + "empty": "Aucune table aléatoire dans cette campagne. Crée-en une depuis la sidebar.", + "tables": "Tables", + "roll": "Lancer", + "noEntry": "aucune entrée", + "journal": "Journal", + "improvising": "…", + "aiImprovise": "IA improvise", + "noResult": "aucun résultat" + }, + "sessionItemCatalogsPanel": { + "empty": "Aucun catalogue d'objets dans cette campagne.", + "catalogs": "Catalogues", + "noteTitle": "Consigner au journal (ex. achat)", + "journal": "Journal" + }, + "randomTableEdit": { + "titleEdit": "Éditer la table", + "titleNew": "Nouvelle table aléatoire", + "nameLabel": "Nom *", + "namePlaceholder": "Ex: Rencontres en forêt", + "descPlaceholder": "À quoi sert cette table ?", + "formulaLabel": "Formule du dé *", + "formulaValid": "Formule valide", + "formulaExpected": "Format attendu : NdM (ex. 1d20, 2d6, d100)", + "aiTitle": "Générer avec l'IA", + "aiHint": "Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.", + "aiPromptPlaceholder": "Ex: rencontres aléatoires dans une forêt hantée, ton sombre", + "generateWith": "Générer ({{formula}})", + "entriesTitle": "Entrées", + "autoRanges": "Auto-plages", + "autoRangesTitle": "Répartir les plages sur la formule", + "colMin": "Min", + "colMax": "Max", + "colResult": "Résultat", + "colDetail": "Détail", + "detailPlaceholder": "Détail (optionnel)", + "emptyHint": "Aucune entrée — clique « Ajouter ».", + "aiErrorPrompt": "Décris la table à générer.", + "aiErrorFormula": "Choisis d'abord une formule de dé valide.", + "aiErrorGenerate": "Échec de la génération IA. Réessaie ou reformule.", + "errorNameRequired": "Le nom est requis.", + "errorFormulaInvalid": "Formule de dé invalide (ex. 1d20, 2d6, d100).", + "errorSaveFailed": "Échec de l'enregistrement." + }, + "randomTableView": { + "die": "Dé :", + "roll": "Lancer {{formula}}", + "noMatch": "Aucune entrée pour ce résultat", + "empty": "Aucune entrée — édite la table pour en ajouter.", + "colRoll": "Jet", + "colResult": "Résultat" + }, + "playthroughDetail": { + "startSession": "Lancer une session", + "resumeSession": "Reprendre la session en cours", + "facts": "Faits de la partie", + "charactersTitle": "Personnages joueurs", + "newCharacter": "Nouveau PJ", + "noCharacters": "Aucun PJ pour cette partie.", + "sessionsTitle": "Sessions", + "noSessions": "Aucune session encore. Lancez la première !", + "statusActive": "En cours", + "statusEnded": "Terminée", + "impactSessions": "{{n}} session(s)", + "impactCharacters": "{{n}} PJ", + "impactFlags": "{{n}} fait(s)", + "impactProgressions": "{{n}} progression(s) de quête", + "deleteCascade": "Cette action supprimera aussi : {{parts}}.", + "irreversible": "Cette action est irréversible.", + "deleteTitle": "Supprimer la Partie", + "deleteMessage": "Supprimer \"{{name}}\" ?" + }, + "playthroughFlagsPage": { + "title": "Faits — {{name}}", + "pageTitle": "{{name}} — Faits", + "subtitle": "Faits booléens propres à cette partie. Toggle-les en jeu pour débloquer les quêtes qui en dépendent." + }, + "campaignTree": { + "npcs": "PNJ", + "newNpc": "Nouveau PNJ", + "newScene": "Nouvelle scène", + "newQuest": "Nouvelle quête", + "newChapter": "Nouveau chapitre", + "randomTables": "Tables aléatoires", + "newTable": "Nouvelle table", + "notebooks": "Ateliers (IA + PDF)", + "itemCatalogs": "Catalogues d'objets", + "enemies": "Ennemis", + "newEnemy": "Nouvel ennemi", + "importPdf": "Importer un PDF", + "newArc": "+ Nouvel arc", + "allCampaigns": "Toutes les campagnes", + "sectionCharacters": "Personnages", + "sectionNarration": "Narration", + "sectionTools": "Outils" + }, + "services": { + "importFailed": "Échec de l'import.", + "importInterrupted": "L'import s'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.", + "unknownServerError": "Erreur inconnue côté serveur.", + "networkError": "Erreur réseau" + } +} diff --git a/web/src/assets/i18n/fragments/arcs.en.json b/web/src/assets/i18n/fragments/arcs.en.json new file mode 100644 index 0000000..712ef98 --- /dev/null +++ b/web/src/assets/i18n/fragments/arcs.en.json @@ -0,0 +1,85 @@ +{ + "arcCreate": { + "title": "Create a new story arc", + "nameLabel": "Arc name *", + "namePlaceholder": "e.g. The Shadow of the North", + "structureLabel": "Arc structure", + "linearTitle": "Linear", + "linearDesc": "Chapters played in order — classic sequential storytelling.", + "hubTitle": "Hub", + "hubDesc": "Parallel quests unlocked by conditions — sandbox style (e.g. Phandalin / Icespire Peak).", + "descriptionPlaceholder": "Describe the main story arc...", + "iconLabel": "Icon", + "submit": "Create arc" + }, + "arcEdit": { + "fallbackTitle": "Arc", + "subtitle": "Story arc", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to discuss this arc", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Moods, portraits, evocative visuals for the arc. JPEG, PNG, WebP or GIF, 10 MB max.", + "mapsLabel": "Maps & plans", + "mapsHint": "Regional maps and plans to help players locate the action.", + "nameLabel": "Arc title *", + "namePlaceholder": "e.g. The Shadow of the North", + "synopsisLabel": "Arc synopsis", + "synopsisPlaceholder": "Describe the main story of this arc...", + "structureLabel": "Arc structure", + "linearTitle": "Linear", + "linearDesc": "Chapters played in order — sequential storytelling.", + "hubTitle": "Hub", + "hubDesc": "Parallel quests unlocked by conditions (sandbox).", + "iconLabel": "Icon", + "themesLabel": "Main themes", + "themesPlaceholder": "What themes does this arc explore? (betrayal, redemption...)", + "stakesLabel": "Overall stakes", + "stakesPlaceholder": "What are the major stakes of this arc for the characters?", + "gmNotesLabel": "GM notes and planning", + "gmNotesPlaceholder": "Your notes on the arc's direction, planned twists, key reveals...", + "gmNotesHint": "These notes are private and will not be exported to FoundryVTT.", + "rewardsLabel": "Rewards and progression", + "rewardsPlaceholder": "What rewards will the players gain? Items, levels, knowledge, contacts...", + "resolutionLabel": "Planned resolution", + "resolutionPlaceholder": "How should this arc end? What are the possible outcomes?", + "relatedPagesLabel": "Linked Lore pages", + "relatedPagesHint": "Link this arc to NPCs, places or Lore elements. Click a chip to open the linked page.", + "noLoreHint": "💡 This campaign is not linked to any universe. Link it to a Lore in the campaign screen to be able to link this arc to Lore pages (NPCs, places, etc.).", + "chatWelcome": "I can see this arc. Ask me to enrich its themes, stakes or resolution.", + "chatSuggestion1": "Suggest 3 major themes for this arc", + "chatSuggestion2": "Imagine stakes that put pressure on the players", + "chatSuggestion3": "Suggest a two-act resolution", + "deleteTitle": "Delete arc", + "deleteMessage": "Delete the arc \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "arcView": { + "subtitleHub": "Hub arc (non-linear quests)", + "subtitleLinear": "Story arc", + "deleteTitle": "Delete the arc and all its content", + "mapsTitle": "Maps & plans", + "hubQuestsTitle": "Hub quests (scenario)", + "hubQuestsEmpty": "No quests for this hub. Create one to get started.", + "unlockConditions": "{{n}} unlock condition(s)", + "synopsisTitle": "Synopsis", + "themesTitle": "Main themes", + "stakesTitle": "Overall stakes", + "rewardsTitle": "Rewards and progression", + "resolutionTitle": "Planned resolution", + "gmNotesTitle": "GM notes and planning", + "relatedPagesTitle": "Linked Lore pages", + "notProvided": "Not provided", + "prereqQuestCompleted": "Quest “{{name}}” completed", + "prereqSessionReached": "Session {{n}} reached", + "prereqFlagSet": "Fact: {{flag}}", + "deletedPage": "(deleted page)", + "chaptersSingular": "{{n}} chapter", + "chaptersPlural": "{{n}} chapters", + "scenesSingular": "{{n}} scene", + "scenesPlural": "{{n}} scenes", + "deleteCascade": "This action will also delete: {{parts}}.", + "irreversible": "This action is irreversible.", + "deleteConfirmTitle": "Delete arc", + "deleteConfirmMessage": "Delete the arc \"{{name}}\"?" + } +} diff --git a/web/src/assets/i18n/fragments/arcs.fr.json b/web/src/assets/i18n/fragments/arcs.fr.json new file mode 100644 index 0000000..5a15b59 --- /dev/null +++ b/web/src/assets/i18n/fragments/arcs.fr.json @@ -0,0 +1,85 @@ +{ + "arcCreate": { + "title": "Créer un nouvel arc narratif", + "nameLabel": "Nom de l'arc *", + "namePlaceholder": "Ex: L'Ombre du Nord", + "structureLabel": "Structure de l'arc", + "linearTitle": "Linéaire", + "linearDesc": "Chapitres joués dans l'ordre — narration séquentielle classique.", + "hubTitle": "Hub", + "hubDesc": "Quêtes parallèles débloquées par des conditions — type sandbox (ex : Phandalin / Icespire Peak).", + "descriptionPlaceholder": "Décrivez l'arc narratif principal...", + "iconLabel": "Icône", + "submit": "Créer l'arc" + }, + "arcEdit": { + "fallbackTitle": "Arc", + "subtitle": "Arc narratif", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cet arc", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max.", + "mapsLabel": "Cartes & plans", + "mapsHint": "Cartes regionales et plans utiles aux joueurs pour situer l'action.", + "nameLabel": "Titre de l'arc *", + "namePlaceholder": "Ex: L'Ombre du Nord", + "synopsisLabel": "Synopsis de l'arc", + "synopsisPlaceholder": "Décrivez l'histoire principale de cet arc narratif...", + "structureLabel": "Structure de l'arc", + "linearTitle": "Linéaire", + "linearDesc": "Chapitres joués dans l'ordre — narration séquentielle.", + "hubTitle": "Hub", + "hubDesc": "Quêtes parallèles débloquées par des conditions (sandbox).", + "iconLabel": "Icône", + "themesLabel": "Thèmes principaux", + "themesPlaceholder": "Quels sont les thèmes explorés dans cet arc ? (trahison, rédemption...)", + "stakesLabel": "Enjeux globaux", + "stakesPlaceholder": "Quels sont les enjeux majeurs de cet arc pour les personnages ?", + "gmNotesLabel": "Notes et planification du MJ", + "gmNotesPlaceholder": "Vos notes sur la direction de l'arc, les twists prévus, les révélations importantes...", + "gmNotesHint": "Ces notes sont privées et ne seront pas exportées vers FoundryVTT.", + "rewardsLabel": "Récompenses et progression", + "rewardsPlaceholder": "Quelles récompenses les joueurs obtiendront-ils ? Objets, niveaux, connaissances, contacts...", + "resolutionLabel": "Dénouement prévu", + "resolutionPlaceholder": "Comment cet arc devrait-il se terminer ? Quelles sont les issues possibles ?", + "relatedPagesLabel": "Pages Lore associées", + "relatedPagesHint": "Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.", + "noLoreHint": "💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).", + "chatWelcome": "Je vois cet arc. Demande-moi d'enrichir ses thèmes, ses enjeux ou son dénouement.", + "chatSuggestion1": "Propose 3 thèmes majeurs pour cet arc", + "chatSuggestion2": "Imagine des enjeux qui mettent la pression sur les joueurs", + "chatSuggestion3": "Suggère un dénouement en deux actes", + "deleteTitle": "Supprimer l'arc", + "deleteMessage": "Supprimer l'arc \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "arcView": { + "subtitleHub": "Arc en hub (quêtes non linéaires)", + "subtitleLinear": "Arc narratif", + "deleteTitle": "Supprimer l'arc et tout son contenu", + "mapsTitle": "Cartes & plans", + "hubQuestsTitle": "Quêtes du hub (scénario)", + "hubQuestsEmpty": "Aucune quête pour ce hub. Créez-en une pour démarrer.", + "unlockConditions": "{{n}} condition(s) de déblocage", + "synopsisTitle": "Synopsis", + "themesTitle": "Thèmes principaux", + "stakesTitle": "Enjeux globaux", + "rewardsTitle": "Récompenses et progression", + "resolutionTitle": "Dénouement prévu", + "gmNotesTitle": "Notes et planification du MJ", + "relatedPagesTitle": "Pages Lore associées", + "notProvided": "Non renseigné", + "prereqQuestCompleted": "Quête « {{name}} » terminée", + "prereqSessionReached": "Session {{n}} atteinte", + "prereqFlagSet": "Fait : {{flag}}", + "deletedPage": "(page supprimée)", + "chaptersSingular": "{{n}} chapitre", + "chaptersPlural": "{{n}} chapitres", + "scenesSingular": "{{n}} scène", + "scenesPlural": "{{n}} scènes", + "deleteCascade": "Cette action supprimera aussi : {{parts}}.", + "irreversible": "Cette action est irréversible.", + "deleteConfirmTitle": "Supprimer l'arc", + "deleteConfirmMessage": "Supprimer l'arc \"{{name}}\" ?" + } +} diff --git a/web/src/assets/i18n/fragments/campaign-tree.en.json b/web/src/assets/i18n/fragments/campaign-tree.en.json new file mode 100644 index 0000000..4b640b2 --- /dev/null +++ b/web/src/assets/i18n/fragments/campaign-tree.en.json @@ -0,0 +1,21 @@ +{ + "campaignTree": { + "npcs": "NPCs", + "newNpc": "New NPC", + "newScene": "New scene", + "newQuest": "New quest", + "newChapter": "New chapter", + "randomTables": "Random tables", + "newTable": "New table", + "notebooks": "Workshops (AI + PDF)", + "itemCatalogs": "Item catalogs", + "enemies": "Enemies", + "newEnemy": "New enemy", + "importPdf": "Import a PDF", + "newArc": "+ New arc", + "allCampaigns": "All campaigns", + "sectionCharacters": "Characters", + "sectionNarration": "Narration", + "sectionTools": "Tools" + } +} diff --git a/web/src/assets/i18n/fragments/campaign-tree.fr.json b/web/src/assets/i18n/fragments/campaign-tree.fr.json new file mode 100644 index 0000000..cff4af3 --- /dev/null +++ b/web/src/assets/i18n/fragments/campaign-tree.fr.json @@ -0,0 +1,21 @@ +{ + "campaignTree": { + "npcs": "PNJ", + "newNpc": "Nouveau PNJ", + "newScene": "Nouvelle scène", + "newQuest": "Nouvelle quête", + "newChapter": "Nouveau chapitre", + "randomTables": "Tables aléatoires", + "newTable": "Nouvelle table", + "notebooks": "Ateliers (IA + PDF)", + "itemCatalogs": "Catalogues d'objets", + "enemies": "Ennemis", + "newEnemy": "Nouvel ennemi", + "importPdf": "Importer un PDF", + "newArc": "+ Nouvel arc", + "allCampaigns": "Toutes les campagnes", + "sectionCharacters": "Personnages", + "sectionNarration": "Narration", + "sectionTools": "Outils" + } +} diff --git a/web/src/assets/i18n/fragments/campaigns-top.en.json b/web/src/assets/i18n/fragments/campaigns-top.en.json new file mode 100644 index 0000000..f9cae92 --- /dev/null +++ b/web/src/assets/i18n/fragments/campaigns-top.en.json @@ -0,0 +1,135 @@ +{ + "campaigns": { + "heroTitle": "Your Campaigns", + "heroSubtitle": "Join a campaign or create new ones", + "statusInProgress": "In progress", + "players": "{{n}} players", + "arcs": "{{n}} arcs", + "chapters": "{{n}} chapters", + "newCampaign": "New Campaign", + "newCampaignSubtitle": "Create a new adventure", + "tip": "💡 Tip: Organize your arcs and chapters so you never forget anything in your adventures" + }, + "campaignCreate": { + "title": "Create a new Campaign", + "nameLabel": "Campaign name *", + "namePlaceholder": "e.g. The Shadow of the North, The Forgotten Heirs...", + "descriptionLabel": "Description / Pitch", + "descriptionPlaceholder": "Summarize the main plot of your campaign...", + "playerCountLabel": "Number of players", + "loreLabel": "Linked universe", + "noLoreOption": "— No universe (standalone campaign) —", + "loreHint": "Optional. If linked, you'll be able to connect arcs, chapters and scenes to Lore pages. Leave empty for a one-shot or if you'll create the Lore later.", + "gameSystemLabel": "RPG system", + "noGameSystemOption": "— None (generic campaign) —", + "createGameSystemOption": "+ Create a new system…", + "gameSystemNamePlaceholder": "Name of the new system (e.g. D&D 5e, Nimble, Homebrew)", + "gameSystemCreateHint": "Quick creation — you'll be able to add rules, PC/NPC sheet templates and the rest from the \"Systems\" section later.", + "gameSystemHint": "Optional. If set, the AI will inject the system's rules (classes, combat, lore...) into its suggestions to respect the RPG's mechanics.", + "gameSystemWarning": "⚠️ The chosen game system also determines the template for PC and NPC sheets. Changing it later will make the fields of existing sheets invisible (the data stays stored but will only show again when reverting to the previous system). Choose carefully from the start if possible.", + "orgIntro": "💡 Organization: Your campaign will be structured into:", + "orgArcs": "Arcs - The major narrative phases", + "orgChapters": "Chapters - The segments of an arc", + "orgScenes": "Scenes - The individual moments of play", + "submit": "Create the campaign" + }, + "campaignDetail": { + "players": "{{n}} players", + "openLinkedLore": "Open the linked universe", + "loreMissingTitle": "The linked universe cannot be found", + "loreMissing": "Universe not found", + "loreLabel": "Linked universe", + "noLoreOption": "— No universe (standalone campaign) —", + "gameSystemLabel": "RPG system", + "noGameSystemOption": "— None (generic) —", + "createGameSystemOption": "+ Create a new system…", + "gameSystemNamePlaceholder": "Name of the new system (e.g. D&D 5e, Nimble, Homebrew)", + "charactersTitle": "Characters", + "npcTitle": "Non-player characters", + "newNpc": "New NPC", + "noNpc": "No NPCs yet.", + "createFirstNpc": "Create your first NPC", + "arcsTitle": "Narrative arcs", + "newArc": "New arc", + "chapters": "{{n}} chapters", + "noArc": "No narrative arcs yet.", + "createFirstArc": "Create your first arc", + "playthroughsTitle": "My playthroughs", + "newPlaythrough": "New playthrough", + "noPlaythrough": "No playthroughs. Create one to start a session with a table.", + "defaultPlaythroughName": "New playthrough {{n}}", + "gameSystemChange": { + "title": "Change the game system?", + "message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.", + "detailSheets": "{{n}} existing sheet(s) are linked to the current system's template.", + "detailFields": "Their fields will no longer display with the new system.", + "detailStored": "The data stays stored: reverting to the previous system will make them visible again.", + "confirm": "Change anyway" + }, + "delete": { + "title": "Delete the campaign?", + "message": "Permanently delete the campaign \"{{name}}\"?", + "arcs": "{{n}} arc", + "arcsPlural": "{{n}} arcs", + "chapters": "{{n}} chapter", + "chaptersPlural": "{{n}} chapters", + "scenes": "{{n}} scene", + "scenesPlural": "{{n}} scenes", + "playthroughs": "{{n}} playthrough (with its PCs, sessions, facts)", + "playthroughsPlural": "{{n}} playthroughs (with their PCs, sessions, facts)", + "alsoDeletes": "Will also be deleted: {{items}}.", + "irreversible": "This action is irreversible." + } + }, + "campaignImport": { + "pageTitle": "Import a campaign", + "back": "Back to the campaign", + "title": "Import a campaign PDF", + "subtitle": "The AI proposes an arc → chapter → scene tree. You review and adjust it before creating anything.", + "importing": "Import in progress…", + "choosePdf": "Choose a campaign PDF", + "phaseExtracting": "Extracting text…", + "phaseAnalyzing": "Analyzing the campaign… ({{current}}/{{total}})", + "foundSoFar": "Found so far: {{arcs}} arc(s) · {{chapters}} chapter(s) · {{scenes}} scene(s) · {{npcs}} NPCs", + "errorNoStructure": "No narrative structure detected in this PDF.", + "errorImport": "PDF import failed.", + "errorImportDetail": "Import failed: {{message}}", + "errorApply": "Creation failed. Does the campaign still exist?", + "summaryToCreate": "To create: {{arcs}} new arc(s), {{chapters}} chapter(s), {{scenes}} scene(s)", + "summaryNpcs": ", {{npcs}} NPCs", + "summaryHint": ". Items marked \"already present\" (greyed out) are shown to place the additions in context; they will not be recreated. Edit the new ones, then create.", + "alreadyPresent": "already present", + "arcNamePlaceholder": "Arc name", + "removeArc": "Remove the arc", + "typeLabel": "Type:", + "typeLinear": "Linear", + "typeHub": "Hub (quests)", + "arcSynopsisPlaceholder": "Arc synopsis (optional)", + "chapterNamePlaceholder": "Chapter name", + "removeChapter": "Remove the chapter", + "chapterSynopsisPlaceholder": "Chapter synopsis (optional)", + "sceneNamePlaceholder": "Scene name", + "synopsisOptionalPlaceholder": "Synopsis (optional)", + "detailsTitle": "Player narration, GM notes, rooms", + "details": "Details", + "roomsCount": "· {{n}} room(s)", + "removeScene": "Remove the scene", + "playerNarrationLabel": "Read to the players", + "playerNarrationPlaceholder": "Boxed text read to the players (optional)", + "gmNotesLabel": "GM notes", + "gmNotesPlaceholder": "Secrets, development, consequences (optional)", + "roomsLabel": "Rooms (explorable location)", + "roomPlaceholder": "Room", + "enemiesPlaceholder": "Enemies", + "lootPlaceholder": "Loot", + "removeRoom": "Remove the room", + "room": "Room", + "scene": "Scene", + "chapter": "Chapter", + "addArc": "Add an arc", + "npcReviewTitle": "Detected NPCs and creatures ({{n}})", + "npcReviewHint": "Check the ones to create in the campaign (description taken from the book). Those already present in the campaign are greyed out and will not be recreated.", + "creating": "Creating…", + "createInCampaign": "Create in the campaign" + } +} diff --git a/web/src/assets/i18n/fragments/campaigns-top.fr.json b/web/src/assets/i18n/fragments/campaigns-top.fr.json new file mode 100644 index 0000000..6615928 --- /dev/null +++ b/web/src/assets/i18n/fragments/campaigns-top.fr.json @@ -0,0 +1,135 @@ +{ + "campaigns": { + "heroTitle": "Vos Campagnes", + "heroSubtitle": "Rejoignez une campagne ou créez-en de nouvelles", + "statusInProgress": "En cours", + "players": "{{n}} joueurs", + "arcs": "{{n}} arcs", + "chapters": "{{n}} chapitres", + "newCampaign": "Nouvelle Campagne", + "newCampaignSubtitle": "Créez une nouvelle aventure", + "tip": "💡 Astuce : Organisez vos arcs et chapitres pour ne rien oublier de vos aventures" + }, + "campaignCreate": { + "title": "Créer une nouvelle Campagne", + "nameLabel": "Nom de la campagne *", + "namePlaceholder": "Ex: L'Ombre du Nord, Les Héritiers Oubliés...", + "descriptionLabel": "Description / Pitch", + "descriptionPlaceholder": "Résumez l'intrigue principale de votre campagne...", + "playerCountLabel": "Nombre de joueurs", + "loreLabel": "Univers associé", + "noLoreOption": "— Aucun univers (campagne libre) —", + "loreHint": "Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore. Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.", + "gameSystemLabel": "Système de JDR", + "noGameSystemOption": "— Aucun (campagne générique) —", + "createGameSystemOption": "+ Créer un nouveau système…", + "gameSystemNamePlaceholder": "Nom du nouveau système (ex: D&D 5e, Nimble, Maison)", + "gameSystemCreateHint": "Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ et le reste depuis la section « Systèmes » plus tard.", + "gameSystemHint": "Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...) dans ses suggestions pour respecter les mécaniques du JDR.", + "gameSystemWarning": "⚠️ Le système de jeu choisi détermine aussi le template des fiches de PJ et PNJ. Le changer plus tard rendra les champs des fiches existantes invisibles (les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système). Choisissez bien dès le départ si possible.", + "orgIntro": "💡 Organisation : Votre campagne sera structurée en :", + "orgArcs": "Arcs - Les grandes phases narratives", + "orgChapters": "Chapitres - Les segments d'un arc", + "orgScenes": "Scènes - Les moments de jeu individuels", + "submit": "Créer la campagne" + }, + "campaignDetail": { + "players": "{{n}} joueurs", + "openLinkedLore": "Ouvrir l'univers associé", + "loreMissingTitle": "L'univers associé est introuvable", + "loreMissing": "Univers introuvable", + "loreLabel": "Univers associé", + "noLoreOption": "— Aucun univers (campagne libre) —", + "gameSystemLabel": "Système de JDR", + "noGameSystemOption": "— Aucun (générique) —", + "createGameSystemOption": "+ Créer un nouveau système…", + "gameSystemNamePlaceholder": "Nom du nouveau système (ex: D&D 5e, Nimble, Maison)", + "charactersTitle": "Personnages", + "npcTitle": "Personnages non-joueurs", + "newNpc": "Nouveau PNJ", + "noNpc": "Aucun PNJ pour le moment.", + "createFirstNpc": "Créer votre premier PNJ", + "arcsTitle": "Arcs narratifs", + "newArc": "Nouvel arc", + "chapters": "{{n}} chapitres", + "noArc": "Aucun arc narratif pour le moment.", + "createFirstArc": "Créer votre premier arc", + "playthroughsTitle": "Mes parties", + "newPlaythrough": "Nouvelle partie", + "noPlaythrough": "Aucune partie. Créez-en une pour démarrer une session avec une table.", + "defaultPlaythroughName": "Nouvelle partie {{n}}", + "gameSystemChange": { + "title": "Changer le système de jeu ?", + "message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.", + "detailSheets": "{{n}} fiche(s) existante(s) sont liées au template du système actuel.", + "detailFields": "Leurs champs ne s'afficheront plus avec le nouveau système.", + "detailStored": "Les données restent stockées : revenir à l'ancien système les rendra à nouveau visibles.", + "confirm": "Changer quand même" + }, + "delete": { + "title": "Supprimer la campagne ?", + "message": "Supprimer définitivement la campagne « {{name}} » ?", + "arcs": "{{n}} arc", + "arcsPlural": "{{n}} arcs", + "chapters": "{{n}} chapitre", + "chaptersPlural": "{{n}} chapitres", + "scenes": "{{n}} scène", + "scenesPlural": "{{n}} scènes", + "playthroughs": "{{n}} partie (avec ses PJ, sessions, faits)", + "playthroughsPlural": "{{n}} parties (avec ses PJ, sessions, faits)", + "alsoDeletes": "Sera aussi supprimé : {{items}}.", + "irreversible": "Cette action est irréversible." + } + }, + "campaignImport": { + "pageTitle": "Importer une campagne", + "back": "Retour à la campagne", + "title": "Importer un PDF de campagne", + "subtitle": "L'IA propose une arborescence arc → chapitre → scène. Vous la révisez et l'ajustez avant de créer quoi que ce soit.", + "importing": "Import en cours…", + "choosePdf": "Choisir un PDF de campagne", + "phaseExtracting": "Extraction du texte…", + "phaseAnalyzing": "Analyse de la campagne… ({{current}}/{{total}})", + "foundSoFar": "Trouvé jusqu'ici : {{arcs}} arc(s) · {{chapters}} chapitre(s) · {{scenes}} scène(s) · {{npcs}} PNJ", + "errorNoStructure": "Aucune structure narrative détectée dans ce PDF.", + "errorImport": "Échec de l'import du PDF.", + "errorImportDetail": "Échec de l'import : {{message}}", + "errorApply": "Échec de la création. La campagne existe-t-elle toujours ?", + "summaryToCreate": "À créer : {{arcs}} nouvel(s) arc(s), {{chapters}} chapitre(s), {{scenes}} scène(s)", + "summaryNpcs": ", {{npcs}} PNJ", + "summaryHint": ". Les éléments « déjà présent » (grisés) sont affichés pour situer les ajouts ; ils ne seront pas recréés. Modifiez les nouveaux, puis créez.", + "alreadyPresent": "déjà présent", + "arcNamePlaceholder": "Nom de l'arc", + "removeArc": "Supprimer l'arc", + "typeLabel": "Type :", + "typeLinear": "Linéaire", + "typeHub": "Hub (quêtes)", + "arcSynopsisPlaceholder": "Synopsis de l'arc (optionnel)", + "chapterNamePlaceholder": "Nom du chapitre", + "removeChapter": "Supprimer le chapitre", + "chapterSynopsisPlaceholder": "Synopsis du chapitre (optionnel)", + "sceneNamePlaceholder": "Nom de la scène", + "synopsisOptionalPlaceholder": "Synopsis (optionnel)", + "detailsTitle": "Narration joueurs, notes MJ, pièces", + "details": "Détails", + "roomsCount": "· {{n}} pièce(s)", + "removeScene": "Supprimer la scène", + "playerNarrationLabel": "À lire aux joueurs", + "playerNarrationPlaceholder": "Texte d'encadré lu aux joueurs (optionnel)", + "gmNotesLabel": "Notes MJ", + "gmNotesPlaceholder": "Secrets, développement, conséquences (optionnel)", + "roomsLabel": "Pièces (lieu explorable)", + "roomPlaceholder": "Pièce", + "enemiesPlaceholder": "Ennemis", + "lootPlaceholder": "Trésor", + "removeRoom": "Supprimer la pièce", + "room": "Pièce", + "scene": "Scène", + "chapter": "Chapitre", + "addArc": "Ajouter un arc", + "npcReviewTitle": "PNJ et créatures détectés ({{n}})", + "npcReviewHint": "Cochez ceux à créer dans la campagne (description issue du livre). Ceux déjà présents dans la campagne sont grisés et ne seront pas recréés.", + "creating": "Création…", + "createInCampaign": "Créer dans la campagne" + } +} diff --git a/web/src/assets/i18n/fragments/chapters.en.json b/web/src/assets/i18n/fragments/chapters.en.json new file mode 100644 index 0000000..fb98f3d --- /dev/null +++ b/web/src/assets/i18n/fragments/chapters.en.json @@ -0,0 +1,91 @@ +{ + "chapterCreate": { + "titleQuest": "Create a new quest", + "titleChapter": "Create a new chapter", + "hub": "Hub", + "arc": "Arc", + "nameQuestLabel": "Quest name *", + "nameChapterLabel": "Chapter name *", + "namePlaceholderQuest": "e.g. Rescue the missing merchant", + "namePlaceholderChapter": "e.g. Chapter 1: The Disappearances", + "descPlaceholderQuest": "Describe this quest...", + "descPlaceholderChapter": "Describe this chapter...", + "icon": "Icon", + "createQuest": "Create quest", + "createChapter": "Create chapter" + }, + "chapterEdit": { + "chapter": "Chapter", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to discuss this chapter", + "unlockTitleQuest": "🗺️ Quest unlock conditions", + "unlockTitleChapter": "🔒 Chapter unlock conditions", + "unlockHintQuest": "Scenario definition: all conditions must be met (AND) for the quest to unlock in a Playthrough. Leave empty to make it available from the start. Progression and fact states are managed in the Playthrough screen, not here.", + "unlockHintChapter": "Scenario definition: all conditions must be met (AND) for this chapter to unlock in a Playthrough. Leave empty to make it available from the start. Progression and fact states are managed in the Playthrough screen, not here.", + "illustrations": "Illustrations", + "illustrationsHint": "Portraits, moods and standout scenes of the chapter.", + "maps": "Maps & plans", + "mapsHint": "Regional maps, dungeon layouts and diagrams useful at the table.", + "nameLabel": "Chapter title *", + "namePlaceholder": "e.g. Chapter 1: The Disappearances", + "synopsisLabel": "Chapter synopsis", + "synopsisPlaceholder": "Briefly describe what happens in this chapter...", + "icon": "Icon", + "gmNotesLabel": "Game Master notes", + "gmNotesPlaceholder": "Your private notes on how the chapter unfolds, key events, twists...", + "gmNotesHint": "These notes are private and will not be exported to FoundryVTT.", + "playerObjectivesLabel": "Player objectives", + "playerObjectivesPlaceholder": "What must the players accomplish in this chapter?", + "narrativeStakesLabel": "Narrative stakes", + "narrativeStakesPlaceholder": "What are the dramatic stakes?", + "relatedPages": "Linked Lore pages", + "relatedPagesHint": "Link this chapter to NPCs, places or Lore elements that appear in it.", + "noLoreHint": "💡 This campaign is not linked to any universe. Link it to a Lore in the campaign screen to be able to link this chapter to Lore pages.", + "chatWelcome": "I can see this chapter. Ask me to flesh out its objectives, its stakes or its opening scene.", + "chatSuggestion1": "Suggest clear objectives for the players in this chapter", + "chatSuggestion2": "Imagine 2 narrative tensions that renew interest midway through the chapter", + "chatSuggestion3": "Suggest a striking opening scene", + "deleteTitle": "Delete chapter", + "deleteMessage": "Delete the chapter \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "chapterGraph": { + "chapter": "Chapter", + "title": "{{name}} — Map", + "subtitle": "Flowchart of scenes and their narrative branches", + "back": "Back to chapter", + "empty": "This chapter has no scenes. Create some to see the map appear.", + "hint": "💡 Click a scene to open it, or drag it to rearrange the map. Scenes not connected to the entry point (scene of order 1) appear at the bottom.", + "allCampaigns": "All campaigns" + }, + "chapterView": { + "chapter": "Chapter", + "questHub": "Quest (Hub)", + "conditional": "Conditional", + "conditionalBadgeTitle": "This chapter has unlock conditions: it unlocks in a Playthrough when they are met.", + "map": "Chapter map", + "mapTitle": "View the flowchart of scenes and their branches", + "deleteTitle": "Delete the chapter and its scenes", + "unlockConditions": "Unlock conditions", + "unlockHintQuest": "During a playthrough, the quest unlocks as soon as all these conditions are met. Toggling facts is done in the Playthrough's \"Facts\" screen.", + "unlockHintChapter": "During a playthrough, this chapter unlocks as soon as all these conditions are met. Toggling facts is done in the Playthrough's \"Facts\" screen.", + "noConditionQuest": "No conditions — this quest is available from the start in every Playthrough.", + "noConditionEdit": "Click Edit to add conditions (\"previous quest completed\", \"from session N onward\", \"when a fact is true\").", + "maps": "Maps & plans", + "synopsis": "Synopsis", + "notProvided": "Not provided", + "playerObjectives": "Player objectives", + "narrativeStakes": "Narrative stakes", + "gmNotes": "Game Master notes", + "relatedPages": "Linked Lore pages", + "prereqQuestCompleted": "Quest \"{{name}}\" completed", + "prereqSessionReached": "Session {{n}} reached", + "prereqFlagSet": "Fact: {{flag}}", + "deletedPage": "(deleted page)", + "deleteScenes": "This action will also delete: {{n}} scene.", + "deleteScenesPlural": "This action will also delete: {{n}} scenes.", + "irreversible": "This action is irreversible.", + "deleteChapterTitle": "Delete chapter", + "deleteChapterMessage": "Delete the chapter \"{{name}}\"?" + } +} diff --git a/web/src/assets/i18n/fragments/chapters.fr.json b/web/src/assets/i18n/fragments/chapters.fr.json new file mode 100644 index 0000000..8d83fa1 --- /dev/null +++ b/web/src/assets/i18n/fragments/chapters.fr.json @@ -0,0 +1,91 @@ +{ + "chapterCreate": { + "titleQuest": "Créer une nouvelle quête", + "titleChapter": "Créer un nouveau chapitre", + "hub": "Hub", + "arc": "Arc", + "nameQuestLabel": "Nom de la quête *", + "nameChapterLabel": "Nom du chapitre *", + "namePlaceholderQuest": "Ex: Sauver le marchand disparu", + "namePlaceholderChapter": "Ex: Chapitre 1: Les Disparitions", + "descPlaceholderQuest": "Décrivez cette quête...", + "descPlaceholderChapter": "Décrivez ce chapitre...", + "icon": "Icône", + "createQuest": "Créer la quête", + "createChapter": "Créer le chapitre" + }, + "chapterEdit": { + "chapter": "Chapitre", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre", + "unlockTitleQuest": "🗺️ Conditions de déblocage de la quête", + "unlockTitleChapter": "🔒 Conditions de déblocage du chapitre", + "unlockHintQuest": "Définition du scénario : toutes les conditions doivent être remplies (ET) pour que la quête soit débloquée dans une Partie. Laissez vide pour qu'elle soit disponible dès le départ. La progression et l'état des faits se gèrent dans l'écran de la Partie, pas ici.", + "unlockHintChapter": "Définition du scénario : toutes les conditions doivent être remplies (ET) pour que ce chapitre soit débloqué dans une Partie. Laissez vide pour qu'il soit disponible dès le départ. La progression et l'état des faits se gèrent dans l'écran de la Partie, pas ici.", + "illustrations": "Illustrations", + "illustrationsHint": "Portraits, ambiances, scènes marquantes du chapitre.", + "maps": "Cartes & plans", + "mapsHint": "Cartes régionales, plans de donjon, schémas utiles à la table.", + "nameLabel": "Titre du chapitre *", + "namePlaceholder": "Ex: Chapitre 1: Les Disparitions", + "synopsisLabel": "Synopsis du chapitre", + "synopsisPlaceholder": "Décrivez brièvement ce qui se passe dans ce chapitre...", + "icon": "Icône", + "gmNotesLabel": "Notes du Maître de Jeu", + "gmNotesPlaceholder": "Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements...", + "gmNotesHint": "Ces notes sont privées et ne seront pas exportées vers FoundryVTT.", + "playerObjectivesLabel": "Objectifs des joueurs", + "playerObjectivesPlaceholder": "Que doivent accomplir les joueurs dans ce chapitre ?", + "narrativeStakesLabel": "Enjeux narratifs", + "narrativeStakesPlaceholder": "Quels sont les enjeux dramatiques ?", + "relatedPages": "Pages Lore associées", + "relatedPagesHint": "Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.", + "noLoreHint": "💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne pour pouvoir lier ce chapitre à des pages du Lore.", + "chatWelcome": "Je vois ce chapitre. Demande-moi d'étoffer ses objectifs, ses enjeux ou sa scène d'ouverture.", + "chatSuggestion1": "Propose des objectifs clairs pour les joueurs dans ce chapitre", + "chatSuggestion2": "Imagine 2 tensions narratives qui relancent l'intérêt en milieu de chapitre", + "chatSuggestion3": "Suggère une scène d'ouverture marquante", + "deleteTitle": "Supprimer le chapitre", + "deleteMessage": "Supprimer le chapitre \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "chapterGraph": { + "chapter": "Chapitre", + "title": "{{name}} — Carte", + "subtitle": "Organigramme des scènes et de leurs branches narratives", + "back": "Retour au chapitre", + "empty": "Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.", + "hint": "💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.", + "allCampaigns": "Toutes les campagnes" + }, + "chapterView": { + "chapter": "Chapitre", + "questHub": "Quête (Hub)", + "conditional": "Conditionnel", + "conditionalBadgeTitle": "Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.", + "map": "Carte du chapitre", + "mapTitle": "Voir l'organigramme des scènes et de leurs branches", + "deleteTitle": "Supprimer le chapitre et ses scènes", + "unlockConditions": "Conditions de déblocage", + "unlockHintQuest": "Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran « Faits » de la Partie.", + "unlockHintChapter": "Pendant une partie, ce chapitre se débloque dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran « Faits » de la Partie.", + "noConditionQuest": "Aucune condition — cette quête est disponible dès le début dans chaque Partie.", + "noConditionEdit": "Cliquez sur Modifier pour ajouter des conditions (« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »).", + "maps": "Cartes & plans", + "synopsis": "Synopsis", + "notProvided": "Non renseigné", + "playerObjectives": "Objectifs des joueurs", + "narrativeStakes": "Enjeux narratifs", + "gmNotes": "Notes du Maître de Jeu", + "relatedPages": "Pages Lore associées", + "prereqQuestCompleted": "Quête « {{name}} » terminée", + "prereqSessionReached": "Session {{n}} atteinte", + "prereqFlagSet": "Fait : {{flag}}", + "deletedPage": "(page supprimée)", + "deleteScenes": "Cette action supprimera aussi : {{n}} scène.", + "deleteScenesPlural": "Cette action supprimera aussi : {{n}} scènes.", + "irreversible": "Cette action est irréversible.", + "deleteChapterTitle": "Supprimer le chapitre", + "deleteChapterMessage": "Supprimer le chapitre \"{{name}}\" ?" + } +} diff --git a/web/src/assets/i18n/fragments/chars-npcs-enemies.en.json b/web/src/assets/i18n/fragments/chars-npcs-enemies.en.json new file mode 100644 index 0000000..fe5f878 --- /dev/null +++ b/web/src/assets/i18n/fragments/chars-npcs-enemies.en.json @@ -0,0 +1,95 @@ +{ + "characterEdit": { + "backToCampaign": "Back to campaign", + "titleEdit": "Edit character sheet", + "titleNew": "New character", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to brainstorm about this PC", + "nameLabel": "Character name *", + "namePlaceholder": "E.g. Thorin Great-Axe, Lyra the Wanderer...", + "portrait": "Portrait", + "portraitHint": "Square recommended (400×400).", + "header": "Banner / Header", + "headerHint": "Landscape format recommended (1200×400).", + "chatWelcome": "I can see this character sheet. Ask me to suggest stats, backstory, equipment or personal goals.", + "chatSuggestion1": "Suggest a backstory consistent with the setting", + "chatSuggestion2": "Suggest 3 personal goals for this character", + "chatSuggestion3": "Help me balance the combat stats", + "deleteTitle": "Delete this sheet?", + "deleteMessage": "Delete the sheet for \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "characterView": { + "aiAssistant": "AI Assistant" + }, + "npcEdit": { + "backToCampaign": "Back to campaign", + "titleEdit": "Edit NPC", + "titleNew": "New NPC", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to brainstorm about this NPC", + "nameLabel": "NPC name *", + "namePlaceholder": "E.g. Borin the blacksmith, Lady Elara, Kael the innkeeper...", + "folder": "Folder", + "folderPlaceholder": "E.g. Bard's Gate, The Pipers faction… (leave empty = unfiled)", + "portrait": "Portrait", + "portraitHint": "Square recommended (400×400).", + "header": "Banner / Header", + "headerHint": "Landscape format recommended (1200×400).", + "relatedPages": "Linked Lore pages", + "relatedPagesHint": "Link this NPC to pages of the setting (their town, faction, region…). These links appear in the Lore graph.", + "chatWelcome": "I can see this NPC sheet. Ask me to suggest appearance, motivations, secrets, or signature lines.", + "chatSuggestion1": "Suggest a striking appearance and posture", + "chatSuggestion2": "Suggest 2 motivations and a secret for this NPC", + "chatSuggestion3": "Imagine 3 signature lines that characterize them", + "deleteTitle": "Delete this sheet?", + "deleteMessage": "Delete the sheet for \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "npcList": { + "create": "New NPC", + "title": "NPCs", + "hint": "All the non-player characters in the campaign, organized by folder. The \"Folder\" field of each sheet drives the classification.", + "empty": "No NPCs yet.", + "unclassified": "Unfiled", + "deleteTitle": "Delete sheet", + "deleteMessage": "Delete the sheet for \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "npcView": { + "aiAssistant": "AI Assistant", + "relatedPages": "Linked Lore pages", + "deletedPage": "(deleted page)" + }, + "enemyEdit": { + "backToEnemies": "Back to enemies", + "titleEdit": "Edit enemy", + "titleNew": "New enemy", + "nameLabel": "Enemy name *", + "namePlaceholder": "E.g. Balor, Goblin warchief, Lich…", + "level": "Level / CR", + "levelPlaceholder": "E.g. 5, CR 8, Boss…", + "folder": "Folder", + "folderPlaceholder": "E.g. Demons, Humanoids… (empty = unfiled)", + "portrait": "Portrait", + "portraitHint": "Square recommended (400×400).", + "header": "Banner / Header", + "headerHint": "Landscape format recommended (1200×400).", + "deleteTitle": "Delete this sheet?", + "deleteMessage": "Delete the sheet for \"{{name}}\"?", + "irreversible": "This action is irreversible." + }, + "enemyList": { + "create": "New enemy", + "title": "Enemies", + "hint": "The campaign bestiary: monsters and creatures with their sheet (driven by the game system's Enemy template), organized by folder (Demons, Humanoids…).", + "empty": "No enemies yet.", + "unclassified": "Unfiled", + "levelShort": "Lvl {{level}}", + "deleteTitle": "Delete enemy", + "deleteMessage": "Delete \"{{name}}\"?" + }, + "enemyView": { + "levelLong": "Level {{level}}" + } +} diff --git a/web/src/assets/i18n/fragments/chars-npcs-enemies.fr.json b/web/src/assets/i18n/fragments/chars-npcs-enemies.fr.json new file mode 100644 index 0000000..f04c777 --- /dev/null +++ b/web/src/assets/i18n/fragments/chars-npcs-enemies.fr.json @@ -0,0 +1,95 @@ +{ + "characterEdit": { + "backToCampaign": "Retour à la campagne", + "titleEdit": "Éditer la fiche", + "titleNew": "Nouveau personnage", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de ce PJ", + "nameLabel": "Nom du personnage *", + "namePlaceholder": "Ex: Thorin le Grand-Hache, Lyra l'Errante...", + "portrait": "Portrait", + "portraitHint": "Carré conseillé (400×400).", + "header": "Bandeau / Header", + "headerHint": "Format paysage conseillé (1200×400).", + "chatWelcome": "Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels.", + "chatSuggestion1": "Propose une backstory cohérente avec l'univers", + "chatSuggestion2": "Suggère 3 objectifs personnels pour ce personnage", + "chatSuggestion3": "Aide-moi à équilibrer les stats de combat", + "deleteTitle": "Supprimer la fiche ?", + "deleteMessage": "Supprimer la fiche de \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "characterView": { + "aiAssistant": "Assistant IA" + }, + "npcEdit": { + "backToCampaign": "Retour à la campagne", + "titleEdit": "Éditer le PNJ", + "titleNew": "Nouveau PNJ", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ", + "nameLabel": "Nom du PNJ *", + "namePlaceholder": "Ex: Borin le forgeron, Dame Elara, Kael l'aubergiste...", + "folder": "Dossier", + "folderPlaceholder": "Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)", + "portrait": "Portrait", + "portraitHint": "Carré conseillé (400×400).", + "header": "Bandeau / Header", + "headerHint": "Format paysage conseillé (1200×400).", + "relatedPages": "Pages de Lore liées", + "relatedPagesHint": "Reliez ce PNJ aux pages de l'univers (sa ville, sa faction, sa région…). Ces liens apparaissent dans le graphe du Lore.", + "chatWelcome": "Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures.", + "chatSuggestion1": "Propose une apparence et une posture marquantes", + "chatSuggestion2": "Suggère 2 motivations et un secret pour ce PNJ", + "chatSuggestion3": "Imagine 3 répliques signatures qui le caractérisent", + "deleteTitle": "Supprimer la fiche ?", + "deleteMessage": "Supprimer la fiche de \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "npcList": { + "create": "Nouveau PNJ", + "title": "PNJ", + "hint": "Tous les personnages non-joueurs de la campagne, classés par dossier. Le champ « Dossier » de chaque fiche pilote le classement.", + "empty": "Aucun PNJ pour l'instant.", + "unclassified": "Non classés", + "deleteTitle": "Supprimer la fiche", + "deleteMessage": "Supprimer la fiche de « {{name}} » ?", + "irreversible": "Cette action est irréversible." + }, + "npcView": { + "aiAssistant": "Assistant IA", + "relatedPages": "Pages de Lore liées", + "deletedPage": "(page supprimée)" + }, + "enemyEdit": { + "backToEnemies": "Retour aux ennemis", + "titleEdit": "Éditer l'ennemi", + "titleNew": "Nouvel ennemi", + "nameLabel": "Nom de l'ennemi *", + "namePlaceholder": "Ex: Balor, Gobelin chef de guerre, Liche…", + "level": "Niveau / FP", + "levelPlaceholder": "Ex: 5, FP 8, Boss…", + "folder": "Dossier", + "folderPlaceholder": "Ex: Démons, Humanoïdes… (vide = non classé)", + "portrait": "Portrait", + "portraitHint": "Carré conseillé (400×400).", + "header": "Bandeau / Header", + "headerHint": "Format paysage conseillé (1200×400).", + "deleteTitle": "Supprimer la fiche ?", + "deleteMessage": "Supprimer la fiche de \"{{name}}\" ?", + "irreversible": "Cette action est irréversible." + }, + "enemyList": { + "create": "Nouvel ennemi", + "title": "Ennemis", + "hint": "Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…).", + "empty": "Aucun ennemi pour l'instant.", + "unclassified": "Non classés", + "levelShort": "Niv. {{level}}", + "deleteTitle": "Supprimer l'ennemi", + "deleteMessage": "Supprimer « {{name}} » ?" + }, + "enemyView": { + "levelLong": "Niveau {{level}}" + } +} diff --git a/web/src/assets/i18n/fragments/gamesys-settingssub.en.json b/web/src/assets/i18n/fragments/gamesys-settingssub.en.json new file mode 100644 index 0000000..8b343a0 --- /dev/null +++ b/web/src/assets/i18n/fragments/gamesys-settingssub.en.json @@ -0,0 +1,138 @@ +{ + "gameSystems": { + "heroTitle": "RPG Systems", + "heroSubtitle": "The rules the AI will know for your campaigns", + "noDescription": "(No description)", + "byAuthor": "by {{author}}", + "public": "public", + "newSystem": "New system", + "newSystemSubtitle": "Enter the rules of an RPG (markdown)", + "tip": "💡 Tip: organize your rules into sections with headings ## Combat, ## Classes, ## Lore… The system will inject the relevant sections based on what the AI needs to generate.", + "deleteTitle": "Delete system", + "deleteMessage": "Delete the system \"{{name}}\"?", + "deleteDetail": "Campaigns using it will no longer be associated with any system." + }, + "gameSystemEdit": { + "backToList": "Back to list", + "editTitle": "Edit system", + "createTitle": "New RPG system", + "nameLabel": "Name *", + "namePlaceholder": "e.g. Nimble, D&D 5.1 SRD, My Homebrew...", + "descriptionLabel": "Short description", + "descriptionPlaceholder": "In one line, what is this system about?", + "authorLabel": "Author", + "authorPlaceholder": "e.g. Hasbro, Homebrew, myself...", + "rulesTitle": "System rules", + "rulesHint": "One section = one theme. The AI will automatically inject the relevant sections based on what it generates (combat → Combat/Monsters, NPC → Classes, arc → Lore/Factions).", + "importButton": "Import a rules PDF", + "importInProgress": "Importing…", + "importHint": "The AI suggests a breakdown into sections — you review it before saving.", + "sectionsFound": "Sections found: {{titles}}", + "sectionTitlePlaceholder": "Section name (e.g. Combat)", + "removeSection": "Remove this section", + "sectionContentPlaceholder": "Describe the rules of this section...", + "noSections": "No sections yet — add one with the buttons below.", + "addSection": "Add a section:", + "addOther": "Other…", + "charactersTitle": "Character sheets", + "charactersHint": "Define the structure of PC and NPC sheets for this system. Universal fields (name, portrait, header) are automatic — only add here the fields specific to the system (Background, HP, Stats…).", + "pcFieldsLabel": "PC sheet fields", + "pcFieldsHint": "Shown when creating/editing a player character.", + "npcFieldsLabel": "NPC sheet fields", + "npcFieldsHint": "Shown when creating/editing a non-player character.", + "enemyFieldsLabel": "Enemy sheet fields", + "enemyFieldsHint": "Shown when creating/editing an enemy (bestiary). Name, level, folder, portrait and banner are automatic.", + "importExtracting": "Extracting text…", + "importAnalyzing": "Analyzing rules… ({{current}}/{{total}})", + "importFailed": "PDF import failed.", + "importFailedReason": "Import failed: {{reason}}", + "importNoRules": "No usable rules could be extracted from this PDF (scan without OCR, or unrecognized content).", + "importOcrSuffix": " (including {{count}} page(s) via OCR)", + "importNote": "{{added}} section(s) suggested from {{pages}} page(s){{ocr}}. Review and adjust below before saving." + }, + "ollamaModelManager": { + "installedModels": "Installed models", + "deleteModelTitle": "Delete {{name}}", + "pullDialogTitle": "Download an Ollama model", + "modelNameLabel": "Model name", + "suggestions": "Suggestions:", + "libraryHint": "The full list is on ollama.com/library.", + "preparing": "Preparing...", + "connecting": "connecting...", + "cancelled": "cancelled", + "pullEventError": "Failed: {{error}}", + "pullFailed": "Failed to download {{name}}.", + "pullInterrupted": "Download of {{name}} interrupted before completion. Restart to resume.", + "pullDone": "Model {{name}} downloaded.", + "deleteTitle": "Delete model", + "deleteMessage": "Delete the model '{{name}}'?", + "deleteDetail": "Disk space will be freed.", + "deleteDone": "Model {{name}} deleted.", + "deleteFailed": "Failed to delete {{name}}." + }, + "updatesSection": { + "title": "Updates", + "hint": "Checks the Docker registry to see whether a new version of the containers (core, brain, web) is available. Postgres and MinIO are excluded — they are updated manually.", + "stableChannel": "Stable channel", + "checking": "Checking...", + "checkNow": "Check now", + "featureNotConfigured": "Feature not configured (WATCHTOWER_TOKEN missing).", + "updateAvailable": "An update is available.", + "checkImpossible": "Check failed (baseline missing or registry unreachable).", + "upToDate": "Everything is up to date (checked on {{date}}).", + "applying": "Updating...", + "applyNow": "Update now", + "betaChannelTitle": "Beta channel — patrons only", + "betaChannelIntro": "Support LoreMind on Patreon to access new features in preview. The Compagnon tier (€7/month) or higher unlocks this channel.", + "connectPatreon": "Connect my Patreon account", + "connectInstructions": "A new window will open to Patreon. After authorizing, copy the displayed token and paste it below.", + "patreonToken": "Patreon token", + "activateLicense": "Activate license", + "licenseValid": "Patreon account connected. Tier {{tier}} active.", + "licenseGrace": "Patreon connection expired, but beta access is maintained during the grace period. Make sure your Patreon subscription is still active and click \"Check now\".", + "licenseExpired": "Patreon connection expired too long ago. Reconnect to regain beta access.", + "licenseUnverifiable": "The installed token can no longer be verified. Reconnect.", + "infoTier": "Tier: {{tier}}", + "infoValidity": "Validity: until {{date}}", + "renewalDay": "(renews in {{n}} day)", + "renewalDays": "(renews in {{n}} days)", + "infoLastRefresh": "Last refresh: {{date}}", + "refreshOk": "OK", + "refreshFailed": "failed", + "enableBetaChannel": "Enable beta channel", + "disconnectPatreon": "Disconnect Patreon", + "checkingBetaImages": "Checking beta images...", + "betaUnavailable": "Unavailable: {{reason}}", + "betaCheckImpossible": "Beta check failed (beta registry unreachable or baseline missing).", + "currentChannel": "Current channel:", + "channelBeta": "Beta", + "channelStable": "Stable", + "switchToBeta": "Switch to the beta channel", + "switchToStable": "Switch back to the stable channel", + "switching": "Switching...", + "switchInProgressNotice": "Switching in progress. The application will be unavailable for 10 to 30 seconds — the page will reload automatically once the new Core is ready.", + "switcherUnavailable": "The channel switcher sidecar is not installed. To benefit from automatic switching, fetch the latest docker-compose.yml from the repo and run docker compose pull && docker compose up -d once. Otherwise, switch manually by editing IMAGE_NAMESPACE in your .env (igmlcreation/loremind- for stable, igmlcreation/loremind-beta- for beta).", + "connectUrlError": "Unable to generate the connection URL. Check your config.", + "pasteTokenFirst": "First paste the token received after connecting with Patreon.", + "patreonConnectedSuccess": "Patreon account connected. Beta access is active.", + "disconnectTitle": "Disconnect Patreon", + "disconnectMessage": "Disconnect your Patreon account?", + "disconnectDetail": "You will lose access to the beta channel.", + "disconnectConfirm": "Disconnect", + "patreonDisconnected": "Patreon account disconnected.", + "switchToBetaMessage": "Switch LoreMind to the beta channel? The core/brain/web containers will be recreated with the beta images. The application will be unavailable for 10-30 seconds.", + "switchToStableMessage": "Switch LoreMind back to the stable channel? The core/brain/web containers will be recreated with the stable images. The application will be unavailable for 10-30 seconds.", + "switchToBetaTitle": "Switch to beta?", + "switchToStableTitle": "Switch back to stable?", + "switchDetailData": "Your data (DB, images) is preserved.", + "switchDetailReversible": "You can switch back at any time from this screen.", + "switchToBetaConfirm": "Switch to beta", + "switchToStableConfirm": "Switch back to stable", + "switchFailed": "Switch failed", + "applyTitle": "Update", + "applyMessage": "Download and restart the containers now?", + "applyDetail": "The app will be unavailable for a few seconds.", + "applyConfirm": "Update", + "applyTriggered": "Update triggered. Reload the page in 30s." + } +} diff --git a/web/src/assets/i18n/fragments/gamesys-settingssub.fr.json b/web/src/assets/i18n/fragments/gamesys-settingssub.fr.json new file mode 100644 index 0000000..d82b370 --- /dev/null +++ b/web/src/assets/i18n/fragments/gamesys-settingssub.fr.json @@ -0,0 +1,138 @@ +{ + "gameSystems": { + "heroTitle": "Systèmes de JDR", + "heroSubtitle": "Les règles que l'IA connaîtra pour vos campagnes", + "noDescription": "(Pas de description)", + "byAuthor": "par {{author}}", + "public": "public", + "newSystem": "Nouveau système", + "newSystemSubtitle": "Saisir les règles d'un JDR (markdown)", + "tip": "💡 Astuce : organisez vos règles par sections avec des titres ## Combat, ## Classes, ## Lore… Le système injectera les sections pertinentes selon ce que l'IA doit générer.", + "deleteTitle": "Supprimer le système", + "deleteMessage": "Supprimer le système \"{{name}}\" ?", + "deleteDetail": "Les campagnes qui l'utilisent ne seront plus associées à aucun système." + }, + "gameSystemEdit": { + "backToList": "Retour à la liste", + "editTitle": "Éditer le système", + "createTitle": "Nouveau système de JDR", + "nameLabel": "Nom *", + "namePlaceholder": "Ex: Nimble, D&D 5.1 SRD, Mon Homebrew...", + "descriptionLabel": "Description courte", + "descriptionPlaceholder": "En une ligne, de quoi parle ce système ?", + "authorLabel": "Auteur", + "authorPlaceholder": "Ex: Hasbro, Homebrew, moi-même...", + "rulesTitle": "Règles du système", + "rulesHint": "Une section = un thème. L'IA injectera automatiquement les sections pertinentes selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions).", + "importButton": "Importer un PDF de règles", + "importInProgress": "Import en cours…", + "importHint": "L'IA propose un découpage en sections — vous révisez avant d'enregistrer.", + "sectionsFound": "Sections trouvées : {{titles}}", + "sectionTitlePlaceholder": "Nom de la section (ex: Combat)", + "removeSection": "Supprimer cette section", + "sectionContentPlaceholder": "Décrivez les règles de cette section...", + "noSections": "Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous.", + "addSection": "Ajouter une section :", + "addOther": "Autre…", + "charactersTitle": "Fiches de personnages", + "charactersHint": "Définissez la structure des fiches PJ et PNJ pour ce système. Les champs universels (nom, portrait, header) sont automatiques — ne rajoutez ici que les champs spécifiques au système (Histoire, PV, Stats…).", + "pcFieldsLabel": "Champs de la fiche PJ", + "pcFieldsHint": "Affichés lors de la création/édition d'un personnage joueur.", + "npcFieldsLabel": "Champs de la fiche PNJ", + "npcFieldsHint": "Affichés lors de la création/édition d'un personnage non-joueur.", + "enemyFieldsLabel": "Champs de la fiche Ennemi", + "enemyFieldsHint": "Affichés lors de la création/édition d'un ennemi (bestiaire). Nom, niveau, dossier, portrait et bandeau sont automatiques.", + "importExtracting": "Extraction du texte…", + "importAnalyzing": "Analyse des règles… ({{current}}/{{total}})", + "importFailed": "Échec de l'import du PDF.", + "importFailedReason": "Échec de l'import : {{reason}}", + "importNoRules": "Aucune règle exploitable n'a été extraite de ce PDF (scan sans OCR, ou contenu non reconnu).", + "importOcrSuffix": " (dont {{count}} page(s) via OCR)", + "importNote": "{{added}} section(s) proposée(s) depuis {{pages}} page(s){{ocr}}. Relisez et ajustez ci-dessous avant d'enregistrer." + }, + "ollamaModelManager": { + "installedModels": "Modèles installés", + "deleteModelTitle": "Supprimer {{name}}", + "pullDialogTitle": "Télécharger un modèle Ollama", + "modelNameLabel": "Nom du modèle", + "suggestions": "Suggestions :", + "libraryHint": "La liste complète est sur ollama.com/library.", + "preparing": "Préparation...", + "connecting": "connexion...", + "cancelled": "annulé", + "pullEventError": "Échec : {{error}}", + "pullFailed": "Échec du téléchargement de {{name}}.", + "pullInterrupted": "Téléchargement de {{name}} interrompu avant la fin. Relancez pour reprendre.", + "pullDone": "Modèle {{name}} téléchargé.", + "deleteTitle": "Supprimer le modèle", + "deleteMessage": "Supprimer le modèle '{{name}}' ?", + "deleteDetail": "L'espace disque sera libéré.", + "deleteDone": "Modèle {{name}} supprimé.", + "deleteFailed": "Échec de la suppression de {{name}}." + }, + "updatesSection": { + "title": "Mises à jour", + "hint": "Vérifie auprès du registry Docker si une nouvelle version des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont exclus — ils sont mis à jour manuellement.", + "stableChannel": "Canal stable", + "checking": "Vérification...", + "checkNow": "Vérifier maintenant", + "featureNotConfigured": "Feature non configurée (WATCHTOWER_TOKEN absent).", + "updateAvailable": "Une mise à jour est disponible.", + "checkImpossible": "Vérification impossible (baseline absente ou registry injoignable).", + "upToDate": "Tout est à jour (vérifié le {{date}}).", + "applying": "Mise à jour en cours...", + "applyNow": "Mettre à jour maintenant", + "betaChannelTitle": "Canal beta — réservé aux patrons", + "betaChannelIntro": "Soutiens LoreMind sur Patreon pour accéder aux nouvelles features en avant-première. Le tier Compagnon (7€/mois) ou supérieur débloque ce canal.", + "connectPatreon": "Connecter mon compte Patreon", + "connectInstructions": "Une nouvelle fenêtre va s'ouvrir vers Patreon. Après autorisation, copie le token affiché et colle-le ci-dessous.", + "patreonToken": "Token Patreon", + "activateLicense": "Activer la licence", + "licenseValid": "Compte Patreon connecté. Tier {{tier}} actif.", + "licenseGrace": "Connexion Patreon expirée, mais accès beta maintenu pendant la période de tolérance. Vérifie que ton abonnement Patreon est toujours actif et clique sur \"Vérifier maintenant\".", + "licenseExpired": "Connexion Patreon expirée depuis trop longtemps. Reconnecte-toi pour retrouver l'accès beta.", + "licenseUnverifiable": "Le token installé ne peut plus être vérifié. Reconnecte-toi.", + "infoTier": "Tier : {{tier}}", + "infoValidity": "Validité : jusqu'au {{date}}", + "renewalDay": "(renouvellement dans {{n}} jour)", + "renewalDays": "(renouvellement dans {{n}} jours)", + "infoLastRefresh": "Dernier refresh : {{date}}", + "refreshOk": "OK", + "refreshFailed": "échec", + "enableBetaChannel": "Activer le canal beta", + "disconnectPatreon": "Déconnecter Patreon", + "checkingBetaImages": "Vérification des images beta...", + "betaUnavailable": "Indisponible : {{reason}}", + "betaCheckImpossible": "Vérification beta impossible (registry beta injoignable ou baseline absente).", + "currentChannel": "Canal actuel :", + "channelBeta": "Bêta", + "channelStable": "Stable", + "switchToBeta": "Passer sur le canal beta", + "switchToStable": "Repasser sur le canal stable", + "switching": "Bascule en cours...", + "switchInProgressNotice": "Bascule en cours. L'application va être indisponible 10 à 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera prêt.", + "switcherUnavailable": "Le sidecar de bascule n'est pas installé. Pour bénéficier du switch automatique, récupère le dernier docker-compose.yml du repo et fais docker compose pull && docker compose up -d une fois. Sinon, bascule manuellement en éditant IMAGE_NAMESPACE dans ton .env (igmlcreation/loremind- pour stable, igmlcreation/loremind-beta- pour beta).", + "connectUrlError": "Impossible de générer l'URL de connexion. Vérifie ta config.", + "pasteTokenFirst": "Colle d'abord le token reçu après connexion Patreon.", + "patreonConnectedSuccess": "Compte Patreon connecté. L'accès beta est actif.", + "disconnectTitle": "Déconnecter Patreon", + "disconnectMessage": "Déconnecter ton compte Patreon ?", + "disconnectDetail": "Tu perdras l'accès au canal beta.", + "disconnectConfirm": "Déconnecter", + "patreonDisconnected": "Compte Patreon déconnecté.", + "switchToBetaMessage": "Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont être recréés avec les images beta. L'application sera indisponible 10-30 secondes.", + "switchToStableMessage": "Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont être recréés avec les images stables. L'application sera indisponible 10-30 secondes.", + "switchToBetaTitle": "Passer en beta ?", + "switchToStableTitle": "Repasser en stable ?", + "switchDetailData": "Les données (DB, images) sont préservées.", + "switchDetailReversible": "Tu pourras refaire le chemin inverse à tout moment depuis cet écran.", + "switchToBetaConfirm": "Passer en beta", + "switchToStableConfirm": "Repasser en stable", + "switchFailed": "Échec du switch", + "applyTitle": "Mettre à jour", + "applyMessage": "Télécharger et redémarrer les conteneurs maintenant ?", + "applyDetail": "L'app sera indisponible quelques secondes.", + "applyConfirm": "Mettre à jour", + "applyTriggered": "Mise à jour déclenchée. Rechargez la page dans 30s." + } +} diff --git a/web/src/assets/i18n/fragments/items-notebook.en.json b/web/src/assets/i18n/fragments/items-notebook.en.json new file mode 100644 index 0000000..e858ae5 --- /dev/null +++ b/web/src/assets/i18n/fragments/items-notebook.en.json @@ -0,0 +1,100 @@ +{ + "itemCatalogEdit": { + "editTitle": "Edit catalog", + "createTitle": "New item catalog", + "nameLabel": "Name *", + "namePlaceholder": "e.g. The dwarven armorer's shop", + "descriptionPlaceholder": "What is this catalog / shop for?", + "aiTitle": "Generate with AI", + "aiHint": "Describe the shop/loot; the AI suggests the items (review them before saving). Contextualized with your campaign.", + "aiPlaceholder": "e.g. an alchemist's shop in a port city, exotic items", + "itemsTitle": "Items", + "priceLabel": "Price", + "categoryLabel": "Category", + "itemNamePlaceholder": "Item", + "itemPricePlaceholder": "50 gp", + "itemCategoryPlaceholder": "Weapons", + "itemDescriptionPlaceholder": "Effect / details", + "emptyHint": "No items — click \"Add\".", + "aiPromptRequired": "Describe the catalog to generate.", + "aiError": "AI generation failed. Try again or rephrase.", + "nameRequired": "Name is required.", + "saveError": "Saving failed." + }, + "itemCatalogList": { + "title": "Item catalogs", + "hint": "Shops, loot, treasures… filled in by hand or via AI. Available during sessions when players visit a shop.", + "newCatalog": "New catalog", + "empty": "No catalogs yet.", + "itemCount": "{{n}} item(s)", + "deleteTitle": "Delete catalog", + "deleteMessage": "Delete \"{{name}}\"?" + }, + "itemCatalogView": { + "empty": "No items — edit the catalog to add some." + }, + "notebookActionCard": { + "arc": "Arc", + "chapter": "Chapter", + "warnArc": "First create an arc in the campaign so you can attach this element to it.", + "warnArcChapter": "First create an arc and a chapter in the campaign so you can attach this element to it.", + "creating": "Creating…", + "createInCampaign": "Create in campaign", + "createdOpen": "Created — open", + "createError": "Creation failed.", + "typeNpc": "NPC", + "typeScene": "Scene", + "typeChapter": "Chapter", + "typeArc": "Arc", + "typeTable": "Random table" + }, + "notebookList": { + "title": "Adaptation workshops", + "hint": "Chat with the AI from a source PDF (searching within the document) to adapt its content to your campaign. The source and conversation are kept.", + "namePlaceholder": "Workshop name (e.g. Adapting module X)", + "newNotebook": "New workshop", + "empty": "No workshops yet.", + "defaultName": "New workshop", + "deleteTitle": "Delete workshop", + "deleteMessage": "Delete \"{{name}}\" and its indexed sources?" + }, + "notebookDetail": { + "backToList": "Workshops", + "sources": "Sources", + "indexing": "Indexing…", + "addPdf": "Add a PDF", + "sourcesUsed": "{{selected}}/{{total}} source(s) used by the chat and deep analysis", + "sourceIncludedTitle": "Source used by the chat — uncheck to exclude it", + "sourceExcludedTitle": "Source ignored by the chat — check to include it", + "sourceMeta": "{{pages}} p. · {{chunks}} excerpts", + "indexingInProgress": "indexing in progress…", + "indexingFailed": "indexing failed", + "sourcesEmpty": "Add a source PDF to start chatting with it.", + "archiveBanner": "Archive from {{label}} — read-only", + "backToChat": "Back to chat", + "refBadgeTitle": "The content of these archives is injected as a reference in every question", + "refBadge": "{{n}} archive(s) referenced", + "archivesBtnTitle": "Archived conversations (read-only + reference)", + "archives": "Archives", + "clearBtnTitle": "Clear the conversation (the current thread is archived, not deleted)", + "clear": "Clear", + "archivesEmpty": "No archived conversations yet — \"Clear\" archives the current thread here.", + "archivesHelp": "Check an archive to use it as a reference in the chat; click its name to reread it.", + "archiveRefOnTitle": "Archive used as a reference — uncheck to remove it", + "archiveRefOffTitle": "Use this archive as a reference in the chat", + "roleUser": "You", + "roleAi": "AI", + "chatEmpty": "Ask a question about your source, or request an adaptation for your campaign.", + "chatEmptyNoSource": "
(First add an indexed source for grounded answers.)", + "deepProgress": "Deep analysis of the document… {{current}}/{{total}}", + "sourcesPagesTitle": "Source pages used to ground this answer", + "draftPlaceholder": "Request an adaptation, a summary, an NPC inspired by the source…", + "deepBtnTitle": "Deep analysis: reads the ENTIRE document (slower, exhaustive). Ideal for \"list all the…\"", + "sendBtnTitle": "Quick answer (targeted search within the document)", + "uploadError": "Indexing failed. Try again or check the embedding model.", + "clearTitle": "Clear the conversation", + "clearMessage": "Start from a blank conversation?", + "clearDetail": "The current thread is archived (not deleted): it will remain accessible via \"Archives\".", + "archiveLabel": "{{when}} · {{n}} message(s)" + } +} diff --git a/web/src/assets/i18n/fragments/items-notebook.fr.json b/web/src/assets/i18n/fragments/items-notebook.fr.json new file mode 100644 index 0000000..4eb888f --- /dev/null +++ b/web/src/assets/i18n/fragments/items-notebook.fr.json @@ -0,0 +1,100 @@ +{ + "itemCatalogEdit": { + "editTitle": "Éditer le catalogue", + "createTitle": "Nouveau catalogue d'objets", + "nameLabel": "Nom *", + "namePlaceholder": "Ex: Échoppe de l'armurier nain", + "descriptionPlaceholder": "À quoi sert ce catalogue / cette boutique ?", + "aiTitle": "Générer avec l'IA", + "aiHint": "Décris la boutique/le butin ; l'IA propose les objets (revois-les avant d'enregistrer). Contextualisé avec ta campagne.", + "aiPlaceholder": "Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques", + "itemsTitle": "Objets", + "priceLabel": "Prix", + "categoryLabel": "Catégorie", + "itemNamePlaceholder": "Objet", + "itemPricePlaceholder": "50 po", + "itemCategoryPlaceholder": "Armes", + "itemDescriptionPlaceholder": "Effet / détails", + "emptyHint": "Aucun objet — clique « Ajouter ».", + "aiPromptRequired": "Décris le catalogue à générer.", + "aiError": "Échec de la génération IA. Réessaie ou reformule.", + "nameRequired": "Le nom est requis.", + "saveError": "Échec de l'enregistrement." + }, + "itemCatalogList": { + "title": "Catalogues d'objets", + "hint": "Boutiques, butins, trésors… à remplir à la main ou via l'IA. Consultables en session quand les joueurs visitent une échoppe.", + "newCatalog": "Nouveau catalogue", + "empty": "Aucun catalogue pour l'instant.", + "itemCount": "{{n}} objet(s)", + "deleteTitle": "Supprimer le catalogue", + "deleteMessage": "Supprimer « {{name}} » ?" + }, + "itemCatalogView": { + "empty": "Aucun objet — édite le catalogue pour en ajouter." + }, + "notebookActionCard": { + "arc": "Arc", + "chapter": "Chapitre", + "warnArc": "Crée d'abord un arc dans la campagne pour pouvoir y rattacher cet élément.", + "warnArcChapter": "Crée d'abord un arc et un chapitre dans la campagne pour pouvoir y rattacher cet élément.", + "creating": "Création…", + "createInCampaign": "Créer dans la campagne", + "createdOpen": "Créé — ouvrir", + "createError": "Échec de la création.", + "typeNpc": "PNJ", + "typeScene": "Scène", + "typeChapter": "Chapitre", + "typeArc": "Arc", + "typeTable": "Table aléatoire" + }, + "notebookList": { + "title": "Ateliers d'adaptation", + "hint": "Discute avec l'IA à partir d'un PDF source (recherche dans le document) pour adapter son contenu à ta campagne. La source et la conversation sont conservées.", + "namePlaceholder": "Nom de l'atelier (ex: Adaptation du module X)", + "newNotebook": "Nouvel atelier", + "empty": "Aucun atelier pour l'instant.", + "defaultName": "Nouvel atelier", + "deleteTitle": "Supprimer l'atelier", + "deleteMessage": "Supprimer « {{name}} » et ses sources indexées ?" + }, + "notebookDetail": { + "backToList": "Ateliers", + "sources": "Sources", + "indexing": "Indexation…", + "addPdf": "Ajouter un PDF", + "sourcesUsed": "{{selected}}/{{total}} source(s) utilisée(s) par le chat et l'analyse approfondie", + "sourceIncludedTitle": "Source utilisée par le chat — décocher pour l'exclure", + "sourceExcludedTitle": "Source ignorée par le chat — cocher pour l'inclure", + "sourceMeta": "{{pages}} p. · {{chunks}} extraits", + "indexingInProgress": "indexation en cours…", + "indexingFailed": "échec de l'indexation", + "sourcesEmpty": "Ajoute un PDF source pour commencer à discuter avec.", + "archiveBanner": "Archive du {{label}} — lecture seule", + "backToChat": "Revenir au chat", + "refBadgeTitle": "Le contenu de ces archives est injecté comme référence dans chaque question", + "refBadge": "{{n}} archive(s) en référence", + "archivesBtnTitle": "Conversations archivées (lecture seule + référence)", + "archives": "Archives", + "clearBtnTitle": "Vider la conversation (le fil actuel est archivé, pas supprimé)", + "clear": "Vider", + "archivesEmpty": "Aucune conversation archivée pour l'instant — « Vider » archive le fil courant ici.", + "archivesHelp": "Cochez une archive pour l'utiliser comme référence dans le chat ; cliquez sur son nom pour la relire.", + "archiveRefOnTitle": "Archive utilisée comme référence — décocher pour la retirer", + "archiveRefOffTitle": "Utiliser cette archive comme référence dans le chat", + "roleUser": "Vous", + "roleAi": "IA", + "chatEmpty": "Pose une question sur ta source, ou demande une adaptation pour ta campagne.", + "chatEmptyNoSource": "
(Ajoute d'abord une source indexée pour des réponses ancrées.)", + "deepProgress": "Analyse approfondie du document… {{current}}/{{total}}", + "sourcesPagesTitle": "Pages de la source utilisées pour ancrer cette réponse", + "draftPlaceholder": "Demande une adaptation, un résumé, un PNJ inspiré de la source…", + "deepBtnTitle": "Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »", + "sendBtnTitle": "Réponse rapide (recherche ciblée dans le document)", + "uploadError": "Échec de l'indexation. Réessaie ou vérifie le modèle d'embedding.", + "clearTitle": "Vider la conversation", + "clearMessage": "Repartir d'une conversation vierge ?", + "clearDetail": "Le fil actuel est archivé (pas supprimé) : il restera consultable via « Archives ».", + "archiveLabel": "{{when}} · {{n}} message(s)" + } +} diff --git a/web/src/assets/i18n/fragments/lore-core.en.json b/web/src/assets/i18n/fragments/lore-core.en.json new file mode 100644 index 0000000..77c368f --- /dev/null +++ b/web/src/assets/i18n/fragments/lore-core.en.json @@ -0,0 +1,102 @@ +{ + "lore": { + "heroTitle": "Your Worlds", + "heroSubtitle": "Select an existing lore or create a new world", + "cardDate": "2h ago", + "statPages": "{{n}} pages", + "statFolders": "{{n}} folders", + "newLore": "New Lore", + "newLoreSubtitle": "Create a new world", + "tip": "💡 Tip: Use templates to structure your world consistently" + }, + "folderView": { + "breadcrumb": "Breadcrumb", + "summary": "{{folders}} subfolder(s) · {{pages}} page(s)", + "editTitle": "Edit folder", + "deleteTitle": "Delete the folder and all its contents", + "subfolders": "Subfolders", + "newSubfolder": "New subfolder", + "noSubfolders": "No subfolders.", + "pages": "Pages", + "newPage": "New page", + "noPages": "No pages in this folder.", + "deleteConfirmTitle": "Delete folder", + "deleteConfirmMessage": "Delete the folder \"{{name}}\"?", + "impact": { + "subfolders": "{{n}} subfolder", + "subfoldersPlural": "{{n}} subfolders", + "pages": "{{n}} page", + "pagesPlural": "{{n}} pages", + "alsoDeletes": "This action will also delete: {{items}}.", + "irreversible": "This action is irreversible." + } + }, + "loreCreate": { + "title": "Create a new Lore", + "nameLabel": "World name *", + "namePlaceholder": "e.g. Realm of Shadows, Cyberpunk 2157...", + "descriptionPlaceholder": "Briefly describe your world, its mood, its genre...", + "infoIntro": "💡 Tip: Your lore will be created with a few default templates:", + "infoNpc": "NPC - For your characters", + "infoPlace": "Place - For your cities and regions", + "infoFaction": "Faction - For your organizations", + "infoItem": "Item - For your artifacts", + "infoFooter": "You'll be able to create your own templates afterwards!", + "submit": "Create lore" + }, + "loreDetail": { + "graph": "Graph", + "graphTitle": "View the graph of pages and their links (NPCs included)", + "editTitle": "Edit Lore", + "deleteTitle": "Delete Lore", + "folders": "Folders", + "newFolder": "New folder", + "noFolders": "No folders yet.", + "createFirstFolder": "Create your first folder", + "deleteConfirmTitle": "Delete Lore", + "deleteConfirmMessage": "Permanently delete the Lore \"{{name}}\"?", + "impact": { + "folders": "{{n}} folder", + "foldersPlural": "{{n}} folders", + "pages": "{{n}} page", + "pagesPlural": "{{n}} pages", + "templates": "{{n}} template", + "templatesPlural": "{{n}} templates", + "alsoDeletes": "This action will also delete: {{items}}.", + "detachedCampaigns": "{{n}} campaign will be kept but will lose its link to this world.", + "detachedCampaignsPlural": "{{n}} campaigns will be kept but will lose their link to this world.", + "irreversible": "This action is irreversible." + } + }, + "loreGraph": { + "back": "Back to Lore", + "title": "{{name}} — Graph", + "subtitle": "{{pages}} page(s) · {{npcs}} NPC · {{edges}} link(s). Click a node to open it, drag it to rearrange.", + "legendPage": "Lore page", + "legendNpc": "NPC", + "legendLink": "NPC → page link", + "empty": "No pages in this Lore yet — the graph will fill in as you go.", + "hint": "No links yet: link pages together (a page's \"Related pages\") or attach an NPC to Lore pages from its sheet." + }, + "loreNodeCreate": { + "title": "Create a new folder", + "subtitle": "Folders let you organize your pages by category", + "nameLabel": "Folder name *", + "namePlaceholder": "e.g. Characters, Creatures...", + "parentLabel": "Parent folder", + "rootOption": "— Lore root —", + "parentHint": "Leave empty to create a folder at the lore root", + "iconLabel": "Icon", + "descriptionPlaceholder": "Describe the type of content this folder will contain...", + "submit": "Create folder" + }, + "loreNodeEdit": { + "title": "Edit folder", + "summary": "{{folders}} subfolder(s) · {{pages}} page(s)", + "nameLabel": "Folder name *", + "parentLabel": "Parent folder", + "rootOption": "— Lore root —", + "parentHint": "You cannot select a subfolder of the current folder (cycles are forbidden)", + "iconLabel": "Icon" + } +} diff --git a/web/src/assets/i18n/fragments/lore-core.fr.json b/web/src/assets/i18n/fragments/lore-core.fr.json new file mode 100644 index 0000000..060d2ca --- /dev/null +++ b/web/src/assets/i18n/fragments/lore-core.fr.json @@ -0,0 +1,102 @@ +{ + "lore": { + "heroTitle": "Vos Univers", + "heroSubtitle": "Sélectionnez un lore existant ou créez un nouvel univers", + "cardDate": "Il y a 2h", + "statPages": "{{n}} pages", + "statFolders": "{{n}} dossiers", + "newLore": "Nouveau Lore", + "newLoreSubtitle": "Créez un nouvel univers", + "tip": "💡 Astuce : Utilisez les templates pour structurer votre univers de manière cohérente" + }, + "folderView": { + "breadcrumb": "Fil d'Ariane", + "summary": "{{folders}} sous-dossier(s) · {{pages}} page(s)", + "editTitle": "Modifier le dossier", + "deleteTitle": "Supprimer le dossier et tout son contenu", + "subfolders": "Sous-dossiers", + "newSubfolder": "Nouveau sous-dossier", + "noSubfolders": "Aucun sous-dossier.", + "pages": "Pages", + "newPage": "Nouvelle page", + "noPages": "Aucune page dans ce dossier.", + "deleteConfirmTitle": "Supprimer le dossier", + "deleteConfirmMessage": "Supprimer le dossier « {{name}} » ?", + "impact": { + "subfolders": "{{n}} sous-dossier", + "subfoldersPlural": "{{n}} sous-dossiers", + "pages": "{{n}} page", + "pagesPlural": "{{n}} pages", + "alsoDeletes": "Cette action supprimera aussi : {{items}}.", + "irreversible": "Cette action est irréversible." + } + }, + "loreCreate": { + "title": "Créer un nouveau Lore", + "nameLabel": "Nom de l'univers *", + "namePlaceholder": "Ex: Royaume des Ombres, Cyberpunk 2157...", + "descriptionPlaceholder": "Décrivez brièvement votre univers, son ambiance, son genre...", + "infoIntro": "💡 Astuce : Votre lore sera créé avec quelques templates par défaut :", + "infoNpc": "PNJ - Pour vos personnages", + "infoPlace": "Lieu - Pour vos villes et régions", + "infoFaction": "Faction - Pour vos organisations", + "infoItem": "Objet - Pour vos artefacts", + "infoFooter": "Vous pourrez créer vos propres templates ensuite !", + "submit": "Créer le lore" + }, + "loreDetail": { + "graph": "Graphe", + "graphTitle": "Visualiser le graphe des pages et de leurs liens (PNJ inclus)", + "editTitle": "Modifier le Lore", + "deleteTitle": "Supprimer le Lore", + "folders": "Dossiers", + "newFolder": "Nouveau dossier", + "noFolders": "Aucun dossier pour le moment.", + "createFirstFolder": "Créer votre premier dossier", + "deleteConfirmTitle": "Supprimer le Lore", + "deleteConfirmMessage": "Supprimer définitivement le Lore « {{name}} » ?", + "impact": { + "folders": "{{n}} dossier", + "foldersPlural": "{{n}} dossiers", + "pages": "{{n}} page", + "pagesPlural": "{{n}} pages", + "templates": "{{n}} template", + "templatesPlural": "{{n}} templates", + "alsoDeletes": "Cette action supprimera aussi : {{items}}.", + "detachedCampaigns": "{{n}} campagne sera conservée mais perdra son lien vers cet univers.", + "detachedCampaignsPlural": "{{n}} campagnes seront conservées mais perdront leur lien vers cet univers.", + "irreversible": "Cette action est irréversible." + } + }, + "loreGraph": { + "back": "Retour au Lore", + "title": "{{name}} — Graphe", + "subtitle": "{{pages}} page(s) · {{npcs}} PNJ · {{edges}} lien(s). Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.", + "legendPage": "Page de Lore", + "legendNpc": "PNJ", + "legendLink": "Lien PNJ → page", + "empty": "Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.", + "hint": "Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) ou rattachez un PNJ à des pages de Lore depuis sa fiche." + }, + "loreNodeCreate": { + "title": "Créer un nouveau dossier", + "subtitle": "Les dossiers permettent d'organiser vos pages par catégorie", + "nameLabel": "Nom du dossier *", + "namePlaceholder": "Ex: Personnages, Créatures...", + "parentLabel": "Dossier parent", + "rootOption": "— Racine du Lore —", + "parentHint": "Laissez vide pour créer un dossier à la racine du lore", + "iconLabel": "Icône", + "descriptionPlaceholder": "Décrivez le type de contenu que ce dossier contiendra...", + "submit": "Créer le dossier" + }, + "loreNodeEdit": { + "title": "Éditer le dossier", + "summary": "{{folders}} sous-dossier(s) · {{pages}} page(s)", + "nameLabel": "Nom du dossier *", + "parentLabel": "Dossier parent", + "rootOption": "— Racine du Lore —", + "parentHint": "Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)", + "iconLabel": "Icône" + } +} diff --git a/web/src/assets/i18n/fragments/lore-pages.en.json b/web/src/assets/i18n/fragments/lore-pages.en.json new file mode 100644 index 0000000..b163fed --- /dev/null +++ b/web/src/assets/i18n/fragments/lore-pages.en.json @@ -0,0 +1,142 @@ +{ + "pageCreate": { + "title": "Create a new Page", + "subtitle": "Create a page from an existing template", + "pageTitle": "New page", + "pageTitleLabel": "Page title *", + "pageTitlePlaceholder": "E.g. Master Eldrin, The Silver City...", + "templateLabel": "Template *", + "createTemplate": "Create a template", + "createTemplateTitle": "Create a new template for this Lore", + "createTemplateHint": "You'll come back here automatically, and your input will be kept.", + "noTemplates": "No template defined for this Lore.", + "firstSuffix": "first.", + "nodeLabel": "Destination folder *", + "nodePlaceholder": "Select a folder", + "nodeHint": "The page will be created in this folder", + "noNodes": "No folder in this Lore.", + "createNode": "Create a folder", + "infoBox": "💡 Option 1: Create the page empty, then fill in the fields manually.
💡 Option 2: Create with AI to chat with an assistant that will pre-fill the fields.", + "aiTitle": "Open the AI assistant to pre-fill the fields", + "createWithAi": "Create with AI", + "createPage": "Create the page", + "wizardPrimaryAction": "Apply and create the page", + "wizardSuggestion1": "Make the description shorter", + "wizardSuggestion2": "Add a striking distinctive trait", + "wizardSuggestion3": "Give it a darker tone", + "wizardWelcomeEmpty": "Describe what you'd like to create.", + "wizardWelcome": "Great, let's create a \"{{name}}\" page! Describe it to me in a few words — context, role, notable traits — and I'll suggest values for each field.", + "errorNoReply": "The assistant hasn't replied yet. Describe your idea first.", + "errorNoValues": "Unable to extract the values. Ask the assistant to suggest them again.", + "errorApplyValues": "Page created, but the values could not be applied.", + "errorCreate": "Error while creating the page." + }, + "pageEdit": { + "pageFallback": "Page", + "aiTitle": "Open the AI Assistant (chat or auto-fill)", + "aiAssistant": "AI Assistant", + "generating": "Generating…", + "folder": "Folder", + "folderHint": "Move this page to another folder", + "fields": "Fields", + "valuePlaceholder": "Value for {{field}}...", + "noLabels": "No label defined in the template for this field.", + "deleteRow": "Delete row", + "deleteRowN": "Delete row {{n}}", + "addRow": "Add a row", + "noColumns": "No column defined in the template for this table.", + "tags": "Tags", + "tagsPlaceholder": "Add a tag (Enter to confirm)...", + "tagsHint": "Free keywords to classify and find this page", + "relatedPages": "Linked pages", + "relatedPagesHint": "Click a link to open the associated page", + "privateNotes": "Private notes", + "privateNotesPlaceholder": "Personal notes (not exported to FoundryVTT)", + "privateNotesHint": "Visible only to you. Useful to prepare your sessions.", + "chatPrimaryAction": "Auto-fill all fields", + "chatSuggestion1": "Flesh out this page's story", + "chatSuggestion2": "Suggest links with other pages of the Lore", + "chatSuggestion3": "Suggest a secondary plot", + "aiUnreachable": "The AI assistant is unreachable. Check that the Brain service is running.", + "aiFailed": "AI generation failed. Try again in a moment.", + "deleteTitle": "Delete the page", + "deleteMessage": "Delete the page \"{{title}}\"?" + }, + "pageView": { + "pageFallback": "Page", + "deleteTitle": "Delete the page", + "notFilled": "Not filled in", + "tags": "Tags", + "relatedPages": "Linked pages", + "privateNotes": "Private notes", + "deletedPage": "(deleted page)", + "deleteMessage": "Delete the page \"{{title}}\"?", + "deleteIrreversible": "This action is irreversible." + }, + "templateCreate": { + "title": "Create a new Template", + "subtitle": "Define a custom blueprint to create consistent pages", + "nameLabel": "Template name *", + "namePlaceholder": "E.g. Inn, Artifact, Monster...", + "descriptionPlaceholder": "What is this template for?", + "defaultNodeLabel": "Default folder *", + "nodePlaceholder": "Select a folder", + "defaultNodeHint": "Pages created with this template will be placed in this folder", + "noNodes": "No folder in this Lore.", + "createNode": "Create a folder", + "firstSuffix": "first.", + "fieldsLabel": "Template fields *", + "moveUp": "Move up", + "moveDown": "Move down", + "fieldTypeTitle": "Field type", + "typeText": "Text", + "typeImage": "Image", + "typeKeyValue": "Key/value list", + "typeTable": "Table", + "layoutTitle": "Image layout", + "layoutGallery": "Grid", + "layoutHero": "Hero", + "layoutMasonry": "Masonry", + "layoutCarousel": "Carousel", + "column": "Column", + "label": "Label", + "columnN": "Column {{n}}", + "labelN": "Label {{n}}", + "tableLabelsHint": "Add the table columns (e.g. Item, Price, Description…)", + "kvLabelsHint": "Add the row labels (e.g. STR, DEX, CON…)", + "addFieldPlaceholder": "+ Add a field", + "addFieldTitle": "Add the field", + "fieldsHelp": "Text = free + usable by the AI. Image = gallery. Key/value list = label/value pairs (stats). Table = fixed columns + rows added freely (shop, inventory…).", + "createTemplate": "Create the template" + }, + "templateEdit": { + "subtitle": "Template", + "defaultNodeLabel": "Default folder", + "noneOption": "-- None --", + "defaultNodeHint": "Pages created with this template will be placed in this folder by default", + "fieldsLabel": "Template fields", + "moveUp": "Move up", + "moveDown": "Move down", + "fieldTypeTitle": "Field type", + "typeText": "Text", + "typeImage": "Image", + "typeKeyValue": "Key/value list", + "typeTable": "Table", + "layoutTitle": "Image layout", + "layoutGallery": "Grid", + "layoutHero": "Hero", + "layoutMasonry": "Masonry", + "layoutCarousel": "Carousel", + "column": "Column", + "label": "Label", + "columnN": "Column {{n}}", + "labelN": "Label {{n}}", + "tableLabelsHint": "Add the table columns (e.g. Item, Price, Description…)", + "kvLabelsHint": "Add the row labels (e.g. STR, DEX, CON…)", + "addFieldPlaceholder": "+ Add a field", + "addFieldTitle": "Add the field", + "fieldsHelp": "Text = free + AI-generatable. Image = gallery. Key/value list = label/value pairs (stats). Table = fixed columns + rows added freely (shop, inventory…).", + "deleteTitle": "Delete the template", + "deleteMessage": "Delete the template \"{{name}}\"?" + } +} diff --git a/web/src/assets/i18n/fragments/lore-pages.fr.json b/web/src/assets/i18n/fragments/lore-pages.fr.json new file mode 100644 index 0000000..bc319ee --- /dev/null +++ b/web/src/assets/i18n/fragments/lore-pages.fr.json @@ -0,0 +1,142 @@ +{ + "pageCreate": { + "title": "Créer une nouvelle Page", + "subtitle": "Créez une page à partir d'un template existant", + "pageTitle": "Nouvelle page", + "pageTitleLabel": "Titre de la page *", + "pageTitlePlaceholder": "Ex : Maître Eldrin, La Cité d'Argent...", + "templateLabel": "Template *", + "createTemplate": "Créer un template", + "createTemplateTitle": "Créer un nouveau template pour ce Lore", + "createTemplateHint": "Vous reviendrez ici automatiquement, votre saisie sera conservée.", + "noTemplates": "Aucun template défini pour ce Lore.", + "firstSuffix": "d'abord.", + "nodeLabel": "Dossier de destination *", + "nodePlaceholder": "Sélectionnez un dossier", + "nodeHint": "La page sera créée dans ce dossier", + "noNodes": "Aucun dossier dans ce Lore.", + "createNode": "Créer un dossier", + "infoBox": "💡 Option 1 : Créer la page vide, puis remplir les champs manuellement.
💡 Option 2 : Créer avec l'IA pour dialoguer avec un assistant qui pré-remplira les champs.", + "aiTitle": "Ouvrir l'assistant IA pour pré-remplir les champs", + "createWithAi": "Créer avec l'IA", + "createPage": "Créer la page", + "wizardPrimaryAction": "Appliquer et créer la page", + "wizardSuggestion1": "Rends la description plus courte", + "wizardSuggestion2": "Ajoute un trait distinctif marquant", + "wizardSuggestion3": "Donne un ton plus sombre", + "wizardWelcomeEmpty": "Décrivez ce que vous souhaitez créer.", + "wizardWelcome": "Super, on va créer une page « {{name}} » ! Décrivez-la-moi en quelques mots — contexte, rôle, traits marquants — et je proposerai des valeurs pour chaque champ.", + "errorNoReply": "L'assistant n'a pas encore répondu. Décrivez d'abord votre idée.", + "errorNoValues": "Impossible d'extraire les valeurs. Demandez à l'assistant de proposer à nouveau.", + "errorApplyValues": "Page créée, mais impossible d'appliquer les valeurs.", + "errorCreate": "Erreur lors de la création de la page." + }, + "pageEdit": { + "pageFallback": "Page", + "aiTitle": "Ouvrir l'Assistant IA (chat ou remplissage automatique)", + "aiAssistant": "Assistant IA", + "generating": "Génération…", + "folder": "Dossier", + "folderHint": "Déplacez cette page dans un autre dossier", + "fields": "Champs", + "valuePlaceholder": "Valeur pour {{field}}...", + "noLabels": "Aucun libellé défini dans le template pour ce champ.", + "deleteRow": "Supprimer la ligne", + "deleteRowN": "Supprimer la ligne {{n}}", + "addRow": "Ajouter une ligne", + "noColumns": "Aucune colonne définie dans le template pour ce tableau.", + "tags": "Tags", + "tagsPlaceholder": "Ajouter un tag (Entrée pour valider)...", + "tagsHint": "Mots-clés libres pour classer et retrouver cette page", + "relatedPages": "Pages liées", + "relatedPagesHint": "Cliquez sur un lien pour ouvrir la page associée", + "privateNotes": "Notes privées", + "privateNotesPlaceholder": "Notes personnelles (non exportées vers FoundryVTT)", + "privateNotesHint": "Visibles uniquement par vous. Utiles pour préparer vos sessions.", + "chatPrimaryAction": "Remplir automatiquement tous les champs", + "chatSuggestion1": "Étoffe l'histoire de cette page", + "chatSuggestion2": "Suggère des liens avec d'autres pages du Lore", + "chatSuggestion3": "Propose une intrigue secondaire", + "aiUnreachable": "L'assistant IA est injoignable. Vérifiez que le service Brain tourne.", + "aiFailed": "Échec de la génération IA. Réessayez dans un instant.", + "deleteTitle": "Supprimer la page", + "deleteMessage": "Supprimer la page « {{title}} » ?" + }, + "pageView": { + "pageFallback": "Page", + "deleteTitle": "Supprimer la page", + "notFilled": "Non renseigné", + "tags": "Tags", + "relatedPages": "Pages liées", + "privateNotes": "Notes privées", + "deletedPage": "(page supprimée)", + "deleteMessage": "Supprimer la page « {{title}} » ?", + "deleteIrreversible": "Cette action est irréversible." + }, + "templateCreate": { + "title": "Créer un nouveau Template", + "subtitle": "Définissez un gabarit personnalisé pour créer des pages cohérentes", + "nameLabel": "Nom du template *", + "namePlaceholder": "Ex : Auberge, Artefact, Monstre...", + "descriptionPlaceholder": "À quoi sert ce template ?", + "defaultNodeLabel": "Dossier par défaut *", + "nodePlaceholder": "Sélectionnez un dossier", + "defaultNodeHint": "Les pages créées avec ce template seront placées dans ce dossier", + "noNodes": "Aucun dossier dans ce Lore.", + "createNode": "Créer un dossier", + "firstSuffix": "d'abord.", + "fieldsLabel": "Champs du template *", + "moveUp": "Monter", + "moveDown": "Descendre", + "fieldTypeTitle": "Type du champ", + "typeText": "Texte", + "typeImage": "Image", + "typeKeyValue": "Liste clé/valeur", + "typeTable": "Tableau", + "layoutTitle": "Mise en page des images", + "layoutGallery": "Grille", + "layoutHero": "Héros", + "layoutMasonry": "Mosaïque", + "layoutCarousel": "Carrousel", + "column": "Colonne", + "label": "Libellé", + "columnN": "Colonne {{n}}", + "labelN": "Libellé {{n}}", + "tableLabelsHint": "Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)", + "kvLabelsHint": "Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)", + "addFieldPlaceholder": "+ Ajouter un champ", + "addFieldTitle": "Ajouter le champ", + "fieldsHelp": "Texte = libre + utilisable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).", + "createTemplate": "Créer le template" + }, + "templateEdit": { + "subtitle": "Template", + "defaultNodeLabel": "Dossier par défaut", + "noneOption": "-- Aucun --", + "defaultNodeHint": "Les pages créées avec ce template seront placées dans ce dossier par défaut", + "fieldsLabel": "Champs du template", + "moveUp": "Monter", + "moveDown": "Descendre", + "fieldTypeTitle": "Type du champ", + "typeText": "Texte", + "typeImage": "Image", + "typeKeyValue": "Liste clé/valeur", + "typeTable": "Tableau", + "layoutTitle": "Mise en page des images", + "layoutGallery": "Grille", + "layoutHero": "Héros", + "layoutMasonry": "Mosaïque", + "layoutCarousel": "Carrousel", + "column": "Colonne", + "label": "Libellé", + "columnN": "Colonne {{n}}", + "labelN": "Libellé {{n}}", + "tableLabelsHint": "Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)", + "kvLabelsHint": "Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)", + "addFieldPlaceholder": "+ Ajouter un champ", + "addFieldTitle": "Ajouter le champ", + "fieldsHelp": "Texte = libre + générable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).", + "deleteTitle": "Supprimer le template", + "deleteMessage": "Supprimer le template « {{name}} » ?" + } +} diff --git a/web/src/assets/i18n/fragments/scenes.en.json b/web/src/assets/i18n/fragments/scenes.en.json new file mode 100644 index 0000000..aabdd0b --- /dev/null +++ b/web/src/assets/i18n/fragments/scenes.en.json @@ -0,0 +1,106 @@ +{ + "sceneCreate": { + "title": "Create a new scene", + "chapterRef": "Chapter: {{name}}", + "nameLabel": "Scene name *", + "namePlaceholder": "E.g.: Arrival at the village", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Describe the scene, the key events, the NPCs present...", + "iconLabel": "Icon", + "createButton": "Create scene", + "createError": "Error while creating the scene" + }, + "sceneEdit": { + "defaultTitle": "Scene", + "subtitle": "Scene", + "aiAssistant": "AI Assistant", + "aiAssistantTitle": "Open the AI Assistant to discuss this scene", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "NPC portraits, visual mood, evocative scenes...", + "mapsLabel": "Maps & plans", + "mapsHint": "Location plans, tactical maps, diagrams usable at the table.", + "nameLabel": "Scene title *", + "namePlaceholder": "E.g.: Arrival at the village", + "descriptionLabel": "Short description *", + "descriptionPlaceholder": "One or two sentences summarizing what happens...", + "iconLabel": "Icon", + "contextSectionTitle": "Context and mood", + "locationLabel": "Location", + "locationPlaceholder": "E.g.: The Golden Dragon Tavern", + "timingLabel": "Time", + "timingPlaceholder": "E.g.: Evening, at nightfall", + "atmosphereLabel": "Mood and atmosphere", + "atmospherePlaceholder": "Describe the general atmosphere of the scene (sounds, smells, light, emotions...)", + "narrationSectionTitle": "Narration for players", + "narrationPlaceholder": "The text you will read to the players to set the scene...", + "narrationHint": "This text can be read directly to your players.", + "gmNotesSectionTitle": "GM notes and secrets", + "gmNotesPlaceholder": "Hidden information, clues, secret elements the players must not know...", + "gmNotesHint": "These notes are private and visible only to the GM.", + "choicesSectionTitle": "Choices and consequences", + "choicesPlaceholder": "Describe the different options available to the players and their consequences...", + "branchesSectionTitle": "Narrative branches", + "branchesNoSibling": "💡 You need at least one other scene in this chapter to create branches. Create other scenes first, then come back here to connect them.", + "branchLabelLabel": "Choice label", + "branchLabelPlaceholder": "E.g.: If the players attack the guard", + "branchTargetLabel": "Destination scene *", + "branchTargetPlaceholder": "— Choose a scene —", + "branchConditionLabel": "GM condition (optional)", + "branchConditionPlaceholder": "E.g.: Persuasion check DC 15 succeeded", + "branchRemove": "Remove", + "branchRemoveTitle": "Remove this branch", + "branchAdd": "+ Add a branch", + "branchesHint": "Each branch represents a possible \"exit\" from this scene depending on the players' action. Targets are limited to scenes in the same chapter.", + "combatSectionTitle": "Combat or encounter", + "combatDifficultyLabel": "Estimated difficulty", + "combatDifficultyPlaceholder": "E.g.: Medium, 3 level 2 goblins", + "bestiaryEnemiesLabel": "Bestiary enemies", + "bestiaryEnemiesHint": "Reference sheets from your bestiary — click a chip to open the sheet.", + "enemiesLabel": "Enemies and creatures (free text)", + "enemiesPlaceholder": "List of enemies present in this scene...", + "loreSectionTitle": "Linked Lore pages", + "loreHint": "Pin the location, NPCs or creatures of this scene here. Click a chip to open the page.", + "noLoreHint": "💡 This campaign is not associated with any universe. Associate it with a Lore on the campaign screen to be able to pin Lore pages to this scene.", + "dungeonSectionTitle": "Explorable location (dungeon, crypt…)", + "dungeonHint": "If this scene represents a location to explore room by room, add them here. The scene then switches to \"dungeon\" mode in the display.", + "chatWelcome": "I can see this scene. Ask me to enrich its mood, its narration or its choices.", + "chatSuggestion1": "Suggest an immersive sensory atmosphere for this scene", + "chatSuggestion2": "Suggest an opening narration to read to the players", + "chatSuggestion3": "Come up with 2 choices with striking consequences", + "deleteTitle": "Delete scene", + "deleteMessage": "Delete the scene \"{{name}}\"?", + "deleteIrreversible": "This action is irreversible.", + "saveError": "Error while saving", + "deleteError": "Error while deleting" + }, + "sceneView": { + "subtitle": "Scene", + "deleteTitleAttr": "Delete scene", + "mapsSectionTitle": "Maps & plans", + "descriptionSectionTitle": "Description", + "empty": "Not provided", + "locationSectionTitle": "Location", + "timingSectionTitle": "Time", + "atmosphereSectionTitle": "Mood and atmosphere", + "narrationSectionTitle": "Narration for players", + "choicesSectionTitle": "Choices and consequences", + "combatDifficultySectionTitle": "Estimated difficulty", + "enemiesSectionTitle": "Enemies and creatures", + "gmNotesSectionTitle": "GM notes and secrets", + "loreSectionTitle": "Linked Lore pages", + "deletedPage": "(page deleted)", + "roomsSectionTitle": "Rooms of the location", + "roomFloor": "Floor {{floor}}", + "roomEnemies": "⚔️ Enemies", + "roomLoot": "💰 Loot", + "roomTraps": "⚠️ Traps", + "roomGmNotes": "🔒 GM notes", + "roomExits": "→ Exits", + "roomExitCondition": "(if: {{condition}})", + "deletedRoom": "(room deleted)", + "deleteTitle": "Delete scene", + "deleteMessage": "Delete the scene \"{{name}}\"?", + "deleteIrreversible": "This action is irreversible.", + "deleteError": "Error while deleting the scene" + } +} diff --git a/web/src/assets/i18n/fragments/scenes.fr.json b/web/src/assets/i18n/fragments/scenes.fr.json new file mode 100644 index 0000000..829f37f --- /dev/null +++ b/web/src/assets/i18n/fragments/scenes.fr.json @@ -0,0 +1,106 @@ +{ + "sceneCreate": { + "title": "Créer une nouvelle scène", + "chapterRef": "Chapitre : {{name}}", + "nameLabel": "Nom de la scène *", + "namePlaceholder": "Ex: Arrivée au village", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Décrivez la scène, les événements clés, les PNJ présents...", + "iconLabel": "Icône", + "createButton": "Créer la scène", + "createError": "Erreur lors de la création de la scène" + }, + "sceneEdit": { + "defaultTitle": "Scène", + "subtitle": "Scène", + "aiAssistant": "Assistant IA", + "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cette scène", + "illustrationsLabel": "Illustrations", + "illustrationsHint": "Portraits des PNJ, ambiance visuelle, scenes evocatrices...", + "mapsLabel": "Cartes & plans", + "mapsHint": "Plans du lieu, cartes tactiques, schemas utilisables a la table.", + "nameLabel": "Titre de la scène *", + "namePlaceholder": "Ex: Arrivée au village", + "descriptionLabel": "Description courte *", + "descriptionPlaceholder": "Résumé en une ou deux phrases de ce qui se passe...", + "iconLabel": "Icône", + "contextSectionTitle": "Contexte et ambiance", + "locationLabel": "Lieu", + "locationPlaceholder": "Ex: Taverne du Dragon d'Or", + "timingLabel": "Moment", + "timingPlaceholder": "Ex: Soir, à la tombée de la nuit", + "atmosphereLabel": "Ambiance et atmosphère", + "atmospherePlaceholder": "Décrivez l'ambiance générale de la scène (sons, odeurs, lumière, émotions...)", + "narrationSectionTitle": "Narration pour les joueurs", + "narrationPlaceholder": "Le texte que vous lirez aux joueurs pour planter le décor de cette scène...", + "narrationHint": "Ce texte peut être lu directement à vos joueurs.", + "gmNotesSectionTitle": "Notes et secrets du MJ", + "gmNotesPlaceholder": "Informations cachées, indices, éléments secrets que les joueurs ne doivent pas connaître...", + "gmNotesHint": "Ces notes sont privées et visibles uniquement par le MJ.", + "choicesSectionTitle": "Choix et conséquences", + "choicesPlaceholder": "Décrivez les différentes options qui s'offrent aux joueurs et leurs conséquences...", + "branchesSectionTitle": "Branches narratives", + "branchesNoSibling": "💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches. Créez d'abord d'autres scènes, puis revenez ici pour les connecter.", + "branchLabelLabel": "Libellé du choix", + "branchLabelPlaceholder": "Ex: Si les joueurs attaquent le garde", + "branchTargetLabel": "Scène de destination *", + "branchTargetPlaceholder": "— Choisir une scène —", + "branchConditionLabel": "Condition MJ (optionnel)", + "branchConditionPlaceholder": "Ex: Jet de Persuasion DD 15 réussi", + "branchRemove": "Retirer", + "branchRemoveTitle": "Supprimer cette branche", + "branchAdd": "+ Ajouter une branche", + "branchesHint": "Chaque branche représente une \"sortie\" possible depuis cette scène selon l'action des joueurs. Les cibles sont limitées aux scènes du même chapitre.", + "combatSectionTitle": "Combat ou rencontre", + "combatDifficultyLabel": "Difficulté estimée", + "combatDifficultyPlaceholder": "Ex: Moyenne, 3 gobelins niveau 2", + "bestiaryEnemiesLabel": "Ennemis du bestiaire", + "bestiaryEnemiesHint": "Référencez des fiches de votre bestiaire — cliquez sur un chip pour ouvrir la fiche.", + "enemiesLabel": "Ennemis et créatures (texte libre)", + "enemiesPlaceholder": "Liste des ennemis présents dans cette scène...", + "loreSectionTitle": "Pages Lore associées", + "loreHint": "Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page.", + "noLoreHint": "💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne pour pouvoir épingler des pages du Lore à cette scène.", + "dungeonSectionTitle": "Lieu explorable (donjon, crypte…)", + "dungeonHint": "Si cette scène représente un lieu à parcourir pièce par pièce, ajoutez-les ici. La scène bascule alors en mode « donjon » côté affichage.", + "chatWelcome": "Je vois cette scène. Demande-moi d'enrichir son ambiance, sa narration ou ses choix.", + "chatSuggestion1": "Propose une ambiance sensorielle immersive pour cette scène", + "chatSuggestion2": "Suggère une narration d'ouverture à lire aux joueurs", + "chatSuggestion3": "Imagine 2 choix avec conséquences marquantes", + "deleteTitle": "Supprimer la scène", + "deleteMessage": "Supprimer la scène \"{{name}}\" ?", + "deleteIrreversible": "Cette action est irréversible.", + "saveError": "Erreur lors de la sauvegarde", + "deleteError": "Erreur lors de la suppression" + }, + "sceneView": { + "subtitle": "Scène", + "deleteTitleAttr": "Supprimer la scène", + "mapsSectionTitle": "Cartes & plans", + "descriptionSectionTitle": "Description", + "empty": "Non renseigné", + "locationSectionTitle": "Lieu", + "timingSectionTitle": "Moment", + "atmosphereSectionTitle": "Ambiance et atmosphère", + "narrationSectionTitle": "Narration pour les joueurs", + "choicesSectionTitle": "Choix et conséquences", + "combatDifficultySectionTitle": "Difficulté estimée", + "enemiesSectionTitle": "Ennemis et créatures", + "gmNotesSectionTitle": "Notes et secrets du MJ", + "loreSectionTitle": "Pages Lore associées", + "deletedPage": "(page supprimée)", + "roomsSectionTitle": "Pièces du lieu", + "roomFloor": "Étage {{floor}}", + "roomEnemies": "⚔️ Ennemis", + "roomLoot": "💰 Loot", + "roomTraps": "⚠️ Pièges", + "roomGmNotes": "🔒 Notes MJ", + "roomExits": "→ Sorties", + "roomExitCondition": "(si : {{condition}})", + "deletedRoom": "(pièce supprimée)", + "deleteTitle": "Supprimer la scène", + "deleteMessage": "Supprimer la scène \"{{name}}\" ?", + "deleteIrreversible": "Cette action est irréversible.", + "deleteError": "Erreur lors de la suppression de la scène" + } +} diff --git a/web/src/assets/i18n/fragments/services.en.json b/web/src/assets/i18n/fragments/services.en.json new file mode 100644 index 0000000..3d05c7b --- /dev/null +++ b/web/src/assets/i18n/fragments/services.en.json @@ -0,0 +1,8 @@ +{ + "services": { + "importFailed": "Import failed.", + "importInterrupted": "The import was interrupted before completion (connection dropped or timed out). Please try again.", + "unknownServerError": "Unknown server error.", + "networkError": "Network error" + } +} diff --git a/web/src/assets/i18n/fragments/services.fr.json b/web/src/assets/i18n/fragments/services.fr.json new file mode 100644 index 0000000..3763421 --- /dev/null +++ b/web/src/assets/i18n/fragments/services.fr.json @@ -0,0 +1,8 @@ +{ + "services": { + "importFailed": "Échec de l'import.", + "importInterrupted": "L'import s'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.", + "unknownServerError": "Erreur inconnue côté serveur.", + "networkError": "Erreur réseau" + } +} diff --git a/web/src/assets/i18n/fragments/sessions.en.json b/web/src/assets/i18n/fragments/sessions.en.json new file mode 100644 index 0000000..317bc85 --- /dev/null +++ b/web/src/assets/i18n/fragments/sessions.en.json @@ -0,0 +1,93 @@ +{ + "sessionDetail": { + "backToCampaign": "Back to campaign", + "renameTitle": "Rename session", + "statusActive": "In progress", + "statusEnded": "Ended", + "startedOn": "Started on {{date}}", + "endedOn": "Ended on {{date}}", + "endSession": "End session", + "ctrlEnterHint": "Ctrl + Enter to add", + "addEntryPlaceholder": "Add a {{type}}…", + "journalTitle": "Session log", + "noEntries": "No entries yet.", + "noEntriesHint": "Type a note, an event or a roll above to start the log.", + "entryType": { + "NOTE": "Note", + "EVENT": "Event", + "DICE_ROLL": "Dice roll", + "PLAYER_ACTION": "Player action" + }, + "endConfirm": { + "title": "End session?", + "message": "Mark session \"{{name}}\" as ended?", + "detail": "You can still review its content afterwards.", + "confirmLabel": "End" + }, + "deleteConfirm": { + "title": "Delete session?", + "message": "Permanently delete session \"{{name}}\"?", + "noEntries": "No log entries for this session.", + "entriesOne": "1 log entry will also be deleted.", + "entriesMany": "{{n}} log entries will also be deleted.", + "irreversible": "This action is irreversible." + }, + "deleteEntryConfirm": { + "title": "Delete this entry?", + "message": "This log entry will be permanently deleted." + } + }, + "sessionReferencePanel": { + "tabAi": "AI", + "tabDice": "Dice", + "tabTables": "Tables", + "tabObjects": "Items", + "tabCharacters": "PCs/NPCs", + "tabScenes": "Scenes", + "playerCharacters": "Player characters", + "nonPlayerCharacters": "Non-player characters", + "emptyCharacters": "No characters in this campaign.", + "emptyScenes": "No narrative arc yet. Build your campaign scenario to find it here." + }, + "sessionDicePanel": { + "count": "Count", + "modifier": "Modifier", + "roll": "Roll", + "lastRolls": "Recent rolls", + "clearHistory": "Clear local history", + "addToJournal": "Add to log", + "sessionEnded": "Session ended", + "placeholder": "Pick a die and roll." + }, + "sessionAiChatPanel": { + "welcome": "Ask the AI a question during the game.", + "welcomeSub": "It knows your world, your campaign, the system rules and everything noted in the log.", + "saveReplyTitle": "Add this reply to the log", + "sessionEnded": "Session ended", + "toJournal": "To log", + "replying": "The AI is replying…", + "inputPlaceholder": "Ask for an idea, a twist, a description…", + "clearConversation": "Clear conversation", + "send": "Send", + "stop": "Stop", + "unknownError": "Unknown error", + "aiError": "AI error: {{message}}", + "interrupted": "[interrupted]" + }, + "sessionRandomTablesPanel": { + "empty": "No random table in this campaign. Create one from the sidebar.", + "tables": "Tables", + "roll": "Roll", + "noEntry": "no entry", + "journal": "Log", + "improvising": "…", + "aiImprovise": "AI improvise", + "noResult": "no result" + }, + "sessionItemCatalogsPanel": { + "empty": "No item catalog in this campaign.", + "catalogs": "Catalogs", + "noteTitle": "Record in log (e.g. purchase)", + "journal": "Log" + } +} diff --git a/web/src/assets/i18n/fragments/sessions.fr.json b/web/src/assets/i18n/fragments/sessions.fr.json new file mode 100644 index 0000000..9651974 --- /dev/null +++ b/web/src/assets/i18n/fragments/sessions.fr.json @@ -0,0 +1,93 @@ +{ + "sessionDetail": { + "backToCampaign": "Retour à la campagne", + "renameTitle": "Renommer la session", + "statusActive": "En cours", + "statusEnded": "Terminée", + "startedOn": "Démarrée le {{date}}", + "endedOn": "Terminée le {{date}}", + "endSession": "Terminer la session", + "ctrlEnterHint": "Ctrl + Entrée pour ajouter", + "addEntryPlaceholder": "Ajouter une {{type}}…", + "journalTitle": "Journal de session", + "noEntries": "Aucune entrée pour le moment.", + "noEntriesHint": "Saisis une note, un évènement ou un jet ci-dessus pour commencer le journal.", + "entryType": { + "NOTE": "Note", + "EVENT": "Évènement", + "DICE_ROLL": "Jet de dés", + "PLAYER_ACTION": "Action joueur" + }, + "endConfirm": { + "title": "Terminer la session ?", + "message": "Marquer la session \"{{name}}\" comme terminée ?", + "detail": "Tu pourras toujours consulter son contenu après.", + "confirmLabel": "Terminer" + }, + "deleteConfirm": { + "title": "Supprimer la session ?", + "message": "Supprimer définitivement la session \"{{name}}\" ?", + "noEntries": "Aucune entrée de journal pour cette session.", + "entriesOne": "1 entrée de journal sera également supprimée.", + "entriesMany": "{{n}} entrées de journal seront également supprimées.", + "irreversible": "Cette action est irréversible." + }, + "deleteEntryConfirm": { + "title": "Supprimer cette entrée ?", + "message": "Cette entrée du journal sera définitivement supprimée." + } + }, + "sessionReferencePanel": { + "tabAi": "IA", + "tabDice": "Dés", + "tabTables": "Tables", + "tabObjects": "Objets", + "tabCharacters": "PJ/PNJ", + "tabScenes": "Scènes", + "playerCharacters": "Personnages joueurs", + "nonPlayerCharacters": "Personnages non-joueurs", + "emptyCharacters": "Aucun personnage dans cette campagne.", + "emptyScenes": "Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici." + }, + "sessionDicePanel": { + "count": "Nombre", + "modifier": "Modificateur", + "roll": "Lancer", + "lastRolls": "Derniers jets", + "clearHistory": "Vider l'historique local", + "addToJournal": "Ajouter au journal", + "sessionEnded": "Session terminée", + "placeholder": "Choisis un dé et lance." + }, + "sessionAiChatPanel": { + "welcome": "Pose une question à l'IA pendant la partie.", + "welcomeSub": "Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal.", + "saveReplyTitle": "Ajouter cette réponse au journal", + "sessionEnded": "Session terminée", + "toJournal": "Au journal", + "replying": "L’IA répond…", + "inputPlaceholder": "Demande une idée, un rebondissement, une description…", + "clearConversation": "Effacer la conversation", + "send": "Envoyer", + "stop": "Stop", + "unknownError": "Erreur inconnue", + "aiError": "Erreur IA : {{message}}", + "interrupted": "[interrompu]" + }, + "sessionRandomTablesPanel": { + "empty": "Aucune table aléatoire dans cette campagne. Crée-en une depuis la sidebar.", + "tables": "Tables", + "roll": "Lancer", + "noEntry": "aucune entrée", + "journal": "Journal", + "improvising": "…", + "aiImprovise": "IA improvise", + "noResult": "aucun résultat" + }, + "sessionItemCatalogsPanel": { + "empty": "Aucun catalogue d'objets dans cette campagne.", + "catalogs": "Catalogues", + "noteTitle": "Consigner au journal (ex. achat)", + "journal": "Journal" + } +} diff --git a/web/src/assets/i18n/fragments/shared-chrome.en.json b/web/src/assets/i18n/fragments/shared-chrome.en.json new file mode 100644 index 0000000..a6c6098 --- /dev/null +++ b/web/src/assets/i18n/fragments/shared-chrome.en.json @@ -0,0 +1,51 @@ +{ + "sidebar": { + "subtitle": "THE DIGITAL CODEX", + "tabLore": "Lore", + "tabCampaign": "Campaign", + "toolsLabel": "TOOLS", + "globalSearch": "Global search", + "gameSystems": "RPG systems", + "vttExport": "VTT export", + "settings": "Settings", + "updateAvailable": "Update available", + "updateBadge": "NEW", + "version": "Version {{version}}" + }, + "secondarySidebar": { + "backToCampaignHome": "Back to campaign home", + "resizeHandle": "Drag to resize" + }, + "breadcrumb": { + "ariaLabel": "Breadcrumb" + }, + "globalSearch": { + "placeholder": "Search in LoreMind...", + "noResults": "No results", + "minChars": "Type at least 2 characters", + "hintNavigate": "navigate", + "hintSelect": "select", + "hintClose": "close", + "countSingular": "{{count}} result", + "countPlural": "{{count}} results", + "enemyLevel": "Lvl. {{level}}", + "tags": { + "page": "Page", + "node": "Folder", + "template": "Template", + "lore": "Lore", + "campaign": "Campaign", + "npc": "NPC", + "character": "PC", + "randomTable": "Random table", + "itemCatalog": "Item catalog", + "enemy": "Enemy" + } + }, + "updateBanner": { + "message": "A new version of LoreMind is available ({{version}}). Reload to enjoy the latest improvements.", + "reload": "Reload", + "dismissTitle": "Dismiss (will reappear on next startup)", + "dismissAria": "Dismiss" + } +} diff --git a/web/src/assets/i18n/fragments/shared-chrome.fr.json b/web/src/assets/i18n/fragments/shared-chrome.fr.json new file mode 100644 index 0000000..99d0c6d --- /dev/null +++ b/web/src/assets/i18n/fragments/shared-chrome.fr.json @@ -0,0 +1,51 @@ +{ + "sidebar": { + "subtitle": "LE CODEX NUMÉRIQUE", + "tabLore": "Lore", + "tabCampaign": "Campagne", + "toolsLabel": "OUTILS", + "globalSearch": "Recherche globale", + "gameSystems": "Systèmes de JDR", + "vttExport": "Export VTT", + "settings": "Paramètres", + "updateAvailable": "Mise à jour disponible", + "updateBadge": "MAJ", + "version": "Version {{version}}" + }, + "secondarySidebar": { + "backToCampaignHome": "Retour à l'accueil de la campagne", + "resizeHandle": "Glissez pour redimensionner" + }, + "breadcrumb": { + "ariaLabel": "Fil d'Ariane" + }, + "globalSearch": { + "placeholder": "Rechercher dans LoreMind...", + "noResults": "Aucun résultat", + "minChars": "Tape au moins 2 caractères", + "hintNavigate": "naviguer", + "hintSelect": "sélectionner", + "hintClose": "fermer", + "countSingular": "{{count}} résultat", + "countPlural": "{{count}} résultats", + "enemyLevel": "Niv. {{level}}", + "tags": { + "page": "Page", + "node": "Dossier", + "template": "Template", + "lore": "Lore", + "campaign": "Campagne", + "npc": "PNJ", + "character": "PJ", + "randomTable": "Table aléatoire", + "itemCatalog": "Catalogue d'objets", + "enemy": "Ennemi" + } + }, + "updateBanner": { + "message": "Une nouvelle version de LoreMind est disponible ({{version}}). Recharge pour profiter des dernières améliorations.", + "reload": "Recharger", + "dismissTitle": "Fermer (sera réaffiché au prochain démarrage)", + "dismissAria": "Fermer" + } +} diff --git a/web/src/assets/i18n/fragments/shared-dialogs.en.json b/web/src/assets/i18n/fragments/shared-dialogs.en.json new file mode 100644 index 0000000..594d18e --- /dev/null +++ b/web/src/assets/i18n/fragments/shared-dialogs.en.json @@ -0,0 +1,57 @@ +{ + "confirmDialog": { + "defaultTitle": "Confirmation" + }, + "aiChatDrawer": { + "title": "AI Assistant", + "resizeAria": "Resize the panel", + "resizeTitle": "Drag to resize", + "conversations": "Conversations", + "newConversation": "New conversation", + "noConversation": "No conversation", + "hideList": "Hide list", + "showList": "Show list", + "shrink": "Shrink", + "shrinkAria": "Shrink the panel", + "expand": "Expand", + "expandAria": "Expand the panel", + "gaugeTitle": "System: {{system}} · History: {{history}} · Current: {{current}} tokens", + "gaugeTitleMax": "System: {{system}} · History: {{history}} · Current: {{current}} / {{max}} tokens", + "gaugeLabel": "Context: {{total}} tokens", + "gaugeLabelMax": "Context: {{total}} / {{max}} tokens", + "quickSuggestions": "Quick suggestions:", + "inputPlaceholder": "Ask a question...", + "send": "Send", + "welcome": "Hello! I can help you develop this page. What would you like to create?", + "loadConversationError": "Unable to load the conversation.", + "deleteConversationTitle": "Delete conversation", + "deleteConversationMessage": "Delete the conversation \"{{title}}\"?", + "missingContextError": "Missing context to create a conversation.", + "createConversationError": "Unable to create the conversation.", + "unknownError": "Unknown error." + }, + "imageGallery": { + "empty": "No illustration", + "illustrationAlt": "Illustration", + "mainIllustrationAlt": "Main illustration", + "mapAlt": "Map", + "removeImage": "Remove this image", + "removeMap": "Remove this map", + "lightboxAria": "Full-screen image", + "lightboxAlt": "Enlarged image" + }, + "imageUploader": { + "addImage": "Add an image", + "uploading": "Uploading", + "uploadingHint": "Uploading...", + "dropTitle": "Drop an image here", + "dropHint": "or click to choose a file (JPEG, PNG, WebP, GIF, max 10 MB)", + "unsupportedFormat": "Unsupported format (JPEG, PNG, WebP, GIF only).", + "tooLarge": "File too large (max {{max}} MB).", + "serverRejected": "File rejected by the server (too large).", + "uploadFailed": "Upload failed. Check that the backend and MinIO are running." + }, + "singleImagePicker": { + "removeImage": "Remove the image" + } +} diff --git a/web/src/assets/i18n/fragments/shared-dialogs.fr.json b/web/src/assets/i18n/fragments/shared-dialogs.fr.json new file mode 100644 index 0000000..5041d54 --- /dev/null +++ b/web/src/assets/i18n/fragments/shared-dialogs.fr.json @@ -0,0 +1,57 @@ +{ + "confirmDialog": { + "defaultTitle": "Confirmation" + }, + "aiChatDrawer": { + "title": "Assistant IA", + "resizeAria": "Redimensionner le panneau", + "resizeTitle": "Glisser pour redimensionner", + "conversations": "Conversations", + "newConversation": "Nouvelle conversation", + "noConversation": "Aucune conversation", + "hideList": "Masquer la liste", + "showList": "Afficher la liste", + "shrink": "Réduire", + "shrinkAria": "Réduire le panneau", + "expand": "Agrandir", + "expandAria": "Agrandir le panneau", + "gaugeTitle": "Système : {{system}} · Historique : {{history}} · Courant : {{current}} tokens", + "gaugeTitleMax": "Système : {{system}} · Historique : {{history}} · Courant : {{current}} / {{max}} tokens", + "gaugeLabel": "Contexte : {{total}} tokens", + "gaugeLabelMax": "Contexte : {{total}} / {{max}} tokens", + "quickSuggestions": "Suggestions rapides :", + "inputPlaceholder": "Posez une question...", + "send": "Envoyer", + "welcome": "Bonjour ! Je peux vous aider à développer cette page. Que souhaitez-vous créer ?", + "loadConversationError": "Impossible de charger la conversation.", + "deleteConversationTitle": "Supprimer la conversation", + "deleteConversationMessage": "Supprimer la conversation « {{title}} » ?", + "missingContextError": "Contexte manquant pour créer une conversation.", + "createConversationError": "Impossible de créer la conversation.", + "unknownError": "Erreur inconnue." + }, + "imageGallery": { + "empty": "Aucune illustration", + "illustrationAlt": "Illustration", + "mainIllustrationAlt": "Illustration principale", + "mapAlt": "Carte", + "removeImage": "Retirer cette image", + "removeMap": "Retirer cette carte", + "lightboxAria": "Image en plein écran", + "lightboxAlt": "Image agrandie" + }, + "imageUploader": { + "addImage": "Ajouter une image", + "uploading": "Upload en cours", + "uploadingHint": "Upload en cours...", + "dropTitle": "Glisse une image ici", + "dropHint": "ou clique pour choisir un fichier (JPEG, PNG, WebP, GIF, max 10 Mo)", + "unsupportedFormat": "Format non supporté (JPEG, PNG, WebP, GIF uniquement).", + "tooLarge": "Fichier trop volumineux (max {{max}} Mo).", + "serverRejected": "Fichier refusé par le serveur (trop volumineux).", + "uploadFailed": "Échec de l'upload. Vérifiez que le backend et MinIO tournent." + }, + "singleImagePicker": { + "removeImage": "Retirer l'image" + } +} diff --git a/web/src/assets/i18n/fragments/shared-widgets-a.en.json b/web/src/assets/i18n/fragments/shared-widgets-a.en.json new file mode 100644 index 0000000..2602ff7 --- /dev/null +++ b/web/src/assets/i18n/fragments/shared-widgets-a.en.json @@ -0,0 +1,26 @@ +{ + "chipsInput": { + "placeholder": "Add...", + "removeTag": "Remove" + }, + "dynamicFieldsForm": { + "textPlaceholder": "Enter {{name}}…", + "noLabels": "No labels defined in the template for this field.", + "noFields": "No fields defined in this system's template. Edit the GameSystem to add fields." + }, + "loreLinkPicker": { + "searchPlaceholder": "Search for a page to link...", + "openInNewTab": "Open in a new tab", + "removeLink": "Remove link", + "noMatch": "No matching page" + }, + "enemyLinkPicker": { + "searchPlaceholder": "Search for an enemy from the bestiary...", + "openInNewTab": "Open the sheet in a new tab", + "removeLink": "Remove link", + "noMatch": "No matching enemy" + }, + "personaView": { + "empty": "This sheet is still empty." + } +} diff --git a/web/src/assets/i18n/fragments/shared-widgets-a.fr.json b/web/src/assets/i18n/fragments/shared-widgets-a.fr.json new file mode 100644 index 0000000..366bd3a --- /dev/null +++ b/web/src/assets/i18n/fragments/shared-widgets-a.fr.json @@ -0,0 +1,26 @@ +{ + "chipsInput": { + "placeholder": "Ajouter...", + "removeTag": "Retirer" + }, + "dynamicFieldsForm": { + "textPlaceholder": "Renseignez {{name}}…", + "noLabels": "Aucun label défini dans le template pour ce champ.", + "noFields": "Aucun champ défini dans le template de ce système. Éditez le GameSystem pour ajouter des champs." + }, + "loreLinkPicker": { + "searchPlaceholder": "Rechercher une page à lier...", + "openInNewTab": "Ouvrir dans un nouvel onglet", + "removeLink": "Retirer le lien", + "noMatch": "Aucune page ne correspond" + }, + "enemyLinkPicker": { + "searchPlaceholder": "Rechercher un ennemi du bestiaire...", + "openInNewTab": "Ouvrir la fiche dans un nouvel onglet", + "removeLink": "Retirer le lien", + "noMatch": "Aucun ennemi ne correspond" + }, + "personaView": { + "empty": "Cette fiche est encore vide." + } +} diff --git a/web/src/assets/i18n/fragments/shared-widgets-b.en.json b/web/src/assets/i18n/fragments/shared-widgets-b.en.json new file mode 100644 index 0000000..704a24d --- /dev/null +++ b/web/src/assets/i18n/fragments/shared-widgets-b.en.json @@ -0,0 +1,75 @@ +{ + "playthroughFlagsManager": { + "empty": "No fact referenced. Add a \"When a fact is true\" condition on at least one quest for it to appear here.", + "true": "true", + "false": "false" + }, + "prerequisiteEditor": { + "empty": "No condition — the quest is immediately available.", + "questCompletedLabel": "Quest completed:", + "noOtherQuest": "(no other quest)", + "sessionReachedLabel": "From session:", + "flagSetLabel": "When the fact is true:", + "flagNamePlaceholder": "fact_name", + "removePrerequisite": "Remove this prerequisite", + "addCondition": "Add a condition", + "menuAfterQuest": "After another quest", + "menuFromSession": "From a session", + "menuWhenFlag": "When a fact is true" + }, + "questStatusBadge": { + "locked": "Locked", + "inProgress": "In progress", + "completed": "Completed", + "available": "Available" + }, + "roomsEditor": { + "empty": "No room. The scene behaves like a classic narrative beat. Add a first room to turn it into an explorable location (dungeon, crypt…).", + "roomNamePlaceholder": "Room name", + "floorPlaceholder": "Fl.", + "floorTitle": "Floor (0 = ground floor)", + "moveUp": "Move up", + "moveDown": "Move down", + "removeRoom": "Delete room", + "descriptionLabel": "Description (read/summarized to players)", + "descriptionPlaceholder": "Atmosphere, what the PCs see when entering…", + "bestiaryLabel": "Bestiary enemies", + "enemiesLabel": "Enemies / creatures / boss (free text)", + "enemiesPlaceholder": "2 goblins, 1 ogre boss (HP 60, AC 14)…", + "lootLabel": "Loot / treasure", + "lootPlaceholder": "50 gp, healing potion, silver key…", + "trapsLabel": "Traps / hazards", + "trapsPlaceholder": "Trapdoor (DC 15 Perception), poisoned darts…", + "gmNotesLabel": "GM notes (private)", + "gmNotesPlaceholder": "The boss knows the PCs by name, a clue…", + "exitsLabel": "Exits / doors to other rooms", + "branchLabelPlaceholder": "North door", + "noOtherRoom": "(no other room)", + "branchConditionPlaceholder": "Condition (optional)", + "addExit": "Add an exit", + "addRoom": "Add a room", + "defaultRoomName": "Room {{n}}", + "defaultBranchLabel": "Door" + }, + "templateFieldsEditor": { + "moveUp": "Move up", + "moveDown": "Move down", + "fieldNamePlaceholder": "Field name (e.g. Background, HP...)", + "removeField": "Delete this field", + "labelsHeader": "Labels (fixed keys for all sheets)", + "labelPlaceholder": "E.g. STR, DEX...", + "removeLabel": "Remove this label", + "addLabel": "Add a label", + "empty": "No field yet — add some with the buttons below.", + "addPrefix": "Add:", + "typeText": "Text", + "typeNumber": "Number", + "typeImage": "Image(s)", + "typeKeyValue": "Key/value list", + "layoutGallery": "Gallery", + "layoutHero": "Banner", + "layoutMasonry": "Mosaic", + "layoutCarousel": "Carousel", + "defaultLabel": "Template fields" + } +} diff --git a/web/src/assets/i18n/fragments/shared-widgets-b.fr.json b/web/src/assets/i18n/fragments/shared-widgets-b.fr.json new file mode 100644 index 0000000..d3d6961 --- /dev/null +++ b/web/src/assets/i18n/fragments/shared-widgets-b.fr.json @@ -0,0 +1,75 @@ +{ + "playthroughFlagsManager": { + "empty": "Aucun fait référencé. Ajoutez une condition « Quand un fait est vrai » sur au moins une quête pour qu'il apparaisse ici.", + "true": "vrai", + "false": "faux" + }, + "prerequisiteEditor": { + "empty": "Aucune condition — la quête est immédiatement disponible.", + "questCompletedLabel": "Quête terminée :", + "noOtherQuest": "(aucune autre quête)", + "sessionReachedLabel": "À partir de la session :", + "flagSetLabel": "Quand le fait est vrai :", + "flagNamePlaceholder": "nom_du_fait", + "removePrerequisite": "Retirer ce prérequis", + "addCondition": "Ajouter une condition", + "menuAfterQuest": "Après une autre quête", + "menuFromSession": "À partir d'une session", + "menuWhenFlag": "Quand un fait est vrai" + }, + "questStatusBadge": { + "locked": "Verrouillée", + "inProgress": "En cours", + "completed": "Terminée", + "available": "Disponible" + }, + "roomsEditor": { + "empty": "Aucune pièce. La scène se comporte comme un beat narratif classique. Ajoutez une première pièce pour la convertir en lieu explorable (donjon, crypte…).", + "roomNamePlaceholder": "Nom de la pièce", + "floorPlaceholder": "Ét.", + "floorTitle": "Étage (0 = RdC)", + "moveUp": "Monter", + "moveDown": "Descendre", + "removeRoom": "Supprimer la pièce", + "descriptionLabel": "Description (lue/résumée aux joueurs)", + "descriptionPlaceholder": "Atmosphère, ce que voient les PJ en entrant…", + "bestiaryLabel": "Ennemis du bestiaire", + "enemiesLabel": "Ennemis / créatures / boss (texte libre)", + "enemiesPlaceholder": "2 gobelins, 1 ogre boss (PV 60, AC 14)…", + "lootLabel": "Loot / trésors", + "lootPlaceholder": "50 po, potion de soin, clé en argent…", + "trapsLabel": "Pièges / dangers", + "trapsPlaceholder": "Trappe (DC 15 Perception), flèches empoisonnées…", + "gmNotesLabel": "Notes MJ (privé)", + "gmNotesPlaceholder": "Le boss connaît les PJ par leur nom, indice…", + "exitsLabel": "Sorties / portes vers d'autres pièces", + "branchLabelPlaceholder": "Porte nord", + "noOtherRoom": "(aucune autre pièce)", + "branchConditionPlaceholder": "Condition (optionnelle)", + "addExit": "Ajouter une sortie", + "addRoom": "Ajouter une pièce", + "defaultRoomName": "Salle {{n}}", + "defaultBranchLabel": "Porte" + }, + "templateFieldsEditor": { + "moveUp": "Monter", + "moveDown": "Descendre", + "fieldNamePlaceholder": "Nom du champ (ex: Histoire, PV...)", + "removeField": "Supprimer ce champ", + "labelsHeader": "Labels (clés fixes pour toutes les fiches)", + "labelPlaceholder": "Ex: FOR, DEX...", + "removeLabel": "Retirer ce label", + "addLabel": "Ajouter un label", + "empty": "Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.", + "addPrefix": "Ajouter :", + "typeText": "Texte", + "typeNumber": "Nombre", + "typeImage": "Image(s)", + "typeKeyValue": "Liste clé/valeur", + "layoutGallery": "Galerie", + "layoutHero": "Bandeau", + "layoutMasonry": "Mosaïque", + "layoutCarousel": "Carrousel", + "defaultLabel": "Champs du template" + } +} diff --git a/web/src/assets/i18n/fragments/tables-playthrough.en.json b/web/src/assets/i18n/fragments/tables-playthrough.en.json new file mode 100644 index 0000000..627cfe5 --- /dev/null +++ b/web/src/assets/i18n/fragments/tables-playthrough.en.json @@ -0,0 +1,64 @@ +{ + "randomTableEdit": { + "titleEdit": "Edit table", + "titleNew": "New random table", + "nameLabel": "Name *", + "namePlaceholder": "E.g. Forest encounters", + "descPlaceholder": "What is this table for?", + "formulaLabel": "Dice formula *", + "formulaValid": "Valid formula", + "formulaExpected": "Expected format: NdM (e.g. 1d20, 2d6, d100)", + "aiTitle": "Generate with AI", + "aiHint": "Describe the table; the AI proposes the entries (review them before saving). Contextualized with your campaign.", + "aiPromptPlaceholder": "E.g. random encounters in a haunted forest, dark tone", + "generateWith": "Generate ({{formula}})", + "entriesTitle": "Entries", + "autoRanges": "Auto-ranges", + "autoRangesTitle": "Distribute ranges across the formula", + "colMin": "Min", + "colMax": "Max", + "colResult": "Result", + "colDetail": "Detail", + "detailPlaceholder": "Detail (optional)", + "emptyHint": "No entries — click \"Add\".", + "aiErrorPrompt": "Describe the table to generate.", + "aiErrorFormula": "Choose a valid dice formula first.", + "aiErrorGenerate": "AI generation failed. Try again or rephrase.", + "errorNameRequired": "Name is required.", + "errorFormulaInvalid": "Invalid dice formula (e.g. 1d20, 2d6, d100).", + "errorSaveFailed": "Save failed." + }, + "randomTableView": { + "die": "Die:", + "roll": "Roll {{formula}}", + "noMatch": "No entry for this result", + "empty": "No entries — edit the table to add some.", + "colRoll": "Roll", + "colResult": "Result" + }, + "playthroughDetail": { + "startSession": "Start a session", + "resumeSession": "Resume current session", + "facts": "Playthrough facts", + "charactersTitle": "Player characters", + "newCharacter": "New PC", + "noCharacters": "No PCs for this playthrough.", + "sessionsTitle": "Sessions", + "noSessions": "No sessions yet. Start the first one!", + "statusActive": "Ongoing", + "statusEnded": "Ended", + "impactSessions": "{{n}} session(s)", + "impactCharacters": "{{n}} PC(s)", + "impactFlags": "{{n}} fact(s)", + "impactProgressions": "{{n}} quest progression(s)", + "deleteCascade": "This action will also delete: {{parts}}.", + "irreversible": "This action is irreversible.", + "deleteTitle": "Delete playthrough", + "deleteMessage": "Delete \"{{name}}\"?" + }, + "playthroughFlagsPage": { + "title": "Facts — {{name}}", + "pageTitle": "{{name}} — Facts", + "subtitle": "Boolean facts specific to this playthrough. Toggle them in play to unlock the quests that depend on them." + } +} diff --git a/web/src/assets/i18n/fragments/tables-playthrough.fr.json b/web/src/assets/i18n/fragments/tables-playthrough.fr.json new file mode 100644 index 0000000..3d4b8b7 --- /dev/null +++ b/web/src/assets/i18n/fragments/tables-playthrough.fr.json @@ -0,0 +1,64 @@ +{ + "randomTableEdit": { + "titleEdit": "Éditer la table", + "titleNew": "Nouvelle table aléatoire", + "nameLabel": "Nom *", + "namePlaceholder": "Ex: Rencontres en forêt", + "descPlaceholder": "À quoi sert cette table ?", + "formulaLabel": "Formule du dé *", + "formulaValid": "Formule valide", + "formulaExpected": "Format attendu : NdM (ex. 1d20, 2d6, d100)", + "aiTitle": "Générer avec l'IA", + "aiHint": "Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.", + "aiPromptPlaceholder": "Ex: rencontres aléatoires dans une forêt hantée, ton sombre", + "generateWith": "Générer ({{formula}})", + "entriesTitle": "Entrées", + "autoRanges": "Auto-plages", + "autoRangesTitle": "Répartir les plages sur la formule", + "colMin": "Min", + "colMax": "Max", + "colResult": "Résultat", + "colDetail": "Détail", + "detailPlaceholder": "Détail (optionnel)", + "emptyHint": "Aucune entrée — clique « Ajouter ».", + "aiErrorPrompt": "Décris la table à générer.", + "aiErrorFormula": "Choisis d'abord une formule de dé valide.", + "aiErrorGenerate": "Échec de la génération IA. Réessaie ou reformule.", + "errorNameRequired": "Le nom est requis.", + "errorFormulaInvalid": "Formule de dé invalide (ex. 1d20, 2d6, d100).", + "errorSaveFailed": "Échec de l'enregistrement." + }, + "randomTableView": { + "die": "Dé :", + "roll": "Lancer {{formula}}", + "noMatch": "Aucune entrée pour ce résultat", + "empty": "Aucune entrée — édite la table pour en ajouter.", + "colRoll": "Jet", + "colResult": "Résultat" + }, + "playthroughDetail": { + "startSession": "Lancer une session", + "resumeSession": "Reprendre la session en cours", + "facts": "Faits de la partie", + "charactersTitle": "Personnages joueurs", + "newCharacter": "Nouveau PJ", + "noCharacters": "Aucun PJ pour cette partie.", + "sessionsTitle": "Sessions", + "noSessions": "Aucune session encore. Lancez la première !", + "statusActive": "En cours", + "statusEnded": "Terminée", + "impactSessions": "{{n}} session(s)", + "impactCharacters": "{{n}} PJ", + "impactFlags": "{{n}} fait(s)", + "impactProgressions": "{{n}} progression(s) de quête", + "deleteCascade": "Cette action supprimera aussi : {{parts}}.", + "irreversible": "Cette action est irréversible.", + "deleteTitle": "Supprimer la Partie", + "deleteMessage": "Supprimer \"{{name}}\" ?" + }, + "playthroughFlagsPage": { + "title": "Faits — {{name}}", + "pageTitle": "{{name}} — Faits", + "subtitle": "Faits booléens propres à cette partie. Toggle-les en jeu pour débloquer les quêtes qui en dépendent." + } +} diff --git a/web/src/main.ts b/web/src/main.ts index e795dc0..b8d7f7c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -4,7 +4,10 @@ import { PreloadAllModules, provideRouter, withPreloading } from '@angular/route import { routes } from './app/app.routes'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { APP_INITIALIZER, provideZoneChangeDetection } from '@angular/core'; +import { provideTranslateService } from '@ngx-translate/core'; +import { provideTranslateHttpLoader } from '@ngx-translate/http-loader'; import { ConfigService } from './app/services/config.service'; +import { LanguageService } from './app/services/language.service'; import { sessionExpiredInterceptor } from './app/interceptors/session-expired.interceptor'; // withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular @@ -16,11 +19,26 @@ bootstrapApplication(AppComponent, { providers: [ provideZoneChangeDetection(),provideRouter(routes, withPreloading(PreloadAllModules)), provideHttpClient(withInterceptors([sessionExpiredInterceptor])), + provideTranslateService({ + loader: provideTranslateHttpLoader({ + prefix: 'assets/i18n/', + suffix: '.json', + }), + fallbackLang: 'fr', + }), { provide: APP_INITIALIZER, useFactory: (config: ConfigService) => () => config.load(), deps: [ConfigService], multi: true, }, + { + // Applique la langue (localStorage → navigateur → fr) et précharge le JSON + // correspondant avant le premier rendu, pour éviter un flash de clés brutes. + provide: APP_INITIALIZER, + useFactory: (lang: LanguageService) => () => lang.init(), + deps: [LanguageService], + multi: true, + }, ], }).catch((err: Error) => console.error(err));