Mise en place de l'anglais comme deuxième langue pour l'application
This commit is contained in:
31
web/package-lock.json
generated
31
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
49
web/scripts/i18n-merge.mjs
Normal file
49
web/scripts/i18n-merge.mjs
Normal file
@@ -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/<feature>.<lang>.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`);
|
||||
}
|
||||
@@ -1,58 +1,58 @@
|
||||
<div class="arc-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Créer un nouvel arc narratif</h1>
|
||||
<h1>{{ 'arcCreate.title' | translate }}</h1>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="arc-form">
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-create-name">Nom de l'arc *</label>
|
||||
<label for="arc-create-name">{{ 'arcCreate.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="arc-create-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: L'Ombre du Nord"
|
||||
[placeholder]="'arcCreate.namePlaceholder' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Structure de l'arc</label>
|
||||
<label>{{ 'arcCreate.structureLabel' | translate }}</label>
|
||||
<div class="arc-type-choice">
|
||||
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
|
||||
<input type="radio" formControlName="type" value="LINEAR" />
|
||||
<span class="arc-type-title">Linéaire</span>
|
||||
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle classique.</span>
|
||||
<span class="arc-type-title">{{ 'arcCreate.linearTitle' | translate }}</span>
|
||||
<span class="arc-type-desc">{{ 'arcCreate.linearDesc' | translate }}</span>
|
||||
</label>
|
||||
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
|
||||
<input type="radio" formControlName="type" value="HUB" />
|
||||
<span class="arc-type-title">Hub</span>
|
||||
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions — type sandbox (ex : Phandalin / Icespire Peak).</span>
|
||||
<span class="arc-type-title">{{ 'arcCreate.hubTitle' | translate }}</span>
|
||||
<span class="arc-type-desc">{{ 'arcCreate.hubDesc' | translate }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-create-description">Description</label>
|
||||
<label for="arc-create-description">{{ 'common.description' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-create-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez l'arc narratif principal..."
|
||||
[placeholder]="'arcCreate.descriptionPlaceholder' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<label>{{ 'arcCreate.iconLabel' | translate }}</label>
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
Créer l'arc
|
||||
{{ 'arcCreate.submit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ arc?.name || 'Arc' }}</h1>
|
||||
<p class="subtitle">Arc narratif</p>
|
||||
<h1>{{ arc?.name || ('arcEdit.fallbackTitle' | translate) }}</h1>
|
||||
<p class="subtitle">{{ 'arcEdit.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA pour dialoguer autour de cet arc">
|
||||
[title]="'arcEdit.aiAssistantTitle' | translate">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
{{ 'arcEdit.aiAssistant' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
|
||||
Sauvegarder
|
||||
{{ 'common.save' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,118 +28,118 @@
|
||||
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>Illustrations</label>
|
||||
<label>{{ 'arcEdit.illustrationsLabel' | translate }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
|
||||
<small class="field-hint">{{ 'arcEdit.illustrationsHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Cartes & plans -->
|
||||
<div class="field">
|
||||
<label>Cartes & plans</label>
|
||||
<label>{{ 'arcEdit.mapsLabel' | translate }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="mapImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'MAPS'"
|
||||
(imageIdsChange)="mapImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">Cartes regionales et plans utiles aux joueurs pour situer l'action.</small>
|
||||
<small class="field-hint">{{ 'arcEdit.mapsHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-edit-name">Titre de l'arc *</label>
|
||||
<label for="arc-edit-name">{{ 'arcEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="arc-edit-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: L'Ombre du Nord"
|
||||
[placeholder]="'arcEdit.namePlaceholder' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-edit-description">Synopsis de l'arc</label>
|
||||
<label for="arc-edit-description">{{ 'arcEdit.synopsisLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez l'histoire principale de cet arc narratif..."
|
||||
[placeholder]="'arcEdit.synopsisPlaceholder' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Structure de l'arc</label>
|
||||
<label>{{ 'arcEdit.structureLabel' | translate }}</label>
|
||||
<div class="arc-type-choice">
|
||||
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
|
||||
<input type="radio" formControlName="type" value="LINEAR" />
|
||||
<span class="arc-type-title">Linéaire</span>
|
||||
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle.</span>
|
||||
<span class="arc-type-title">{{ 'arcEdit.linearTitle' | translate }}</span>
|
||||
<span class="arc-type-desc">{{ 'arcEdit.linearDesc' | translate }}</span>
|
||||
</label>
|
||||
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
|
||||
<input type="radio" formControlName="type" value="HUB" />
|
||||
<span class="arc-type-title">Hub</span>
|
||||
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions (sandbox).</span>
|
||||
<span class="arc-type-title">{{ 'arcEdit.hubTitle' | translate }}</span>
|
||||
<span class="arc-type-desc">{{ 'arcEdit.hubDesc' | translate }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<label>{{ 'arcEdit.iconLabel' | translate }}</label>
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="arc-edit-themes">Thèmes principaux</label>
|
||||
<label for="arc-edit-themes">{{ 'arcEdit.themesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-themes"
|
||||
formControlName="themes"
|
||||
placeholder="Quels sont les thèmes explorés dans cet arc ? (trahison, rédemption...)"
|
||||
[placeholder]="'arcEdit.themesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="arc-edit-stakes">Enjeux globaux</label>
|
||||
<label for="arc-edit-stakes">{{ 'arcEdit.stakesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-stakes"
|
||||
formControlName="stakes"
|
||||
placeholder="Quels sont les enjeux majeurs de cet arc pour les personnages ?"
|
||||
[placeholder]="'arcEdit.stakesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-edit-gm-notes">Notes et planification du MJ</label>
|
||||
<label for="arc-edit-gm-notes">{{ 'arcEdit.gmNotesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-gm-notes"
|
||||
formControlName="gmNotes"
|
||||
placeholder="Vos notes sur la direction de l'arc, les twists prévus, les révélations importantes..."
|
||||
[placeholder]="'arcEdit.gmNotesPlaceholder' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
<small class="field-hint">Ces notes sont privées et ne seront pas exportées vers FoundryVTT.</small>
|
||||
<small class="field-hint">{{ 'arcEdit.gmNotesHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-edit-rewards">Récompenses et progression</label>
|
||||
<label for="arc-edit-rewards">{{ 'arcEdit.rewardsLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-rewards"
|
||||
formControlName="rewards"
|
||||
placeholder="Quelles récompenses les joueurs obtiendront-ils ? Objets, niveaux, connaissances, contacts..."
|
||||
[placeholder]="'arcEdit.rewardsPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-edit-resolution">Dénouement prévu</label>
|
||||
<label for="arc-edit-resolution">{{ 'arcEdit.resolutionLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-resolution"
|
||||
formControlName="resolution"
|
||||
placeholder="Comment cet arc devrait-il se terminer ? Quelles sont les issues possibles ?"
|
||||
[placeholder]="'arcEdit.resolutionPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
@@ -147,7 +147,7 @@
|
||||
<!-- ===== Pages Lore associées (phase B2 cross-context) ===== -->
|
||||
@if (loreId) {
|
||||
<div class="field">
|
||||
<label>Pages Lore associées</label>
|
||||
<label>{{ 'arcEdit.relatedPagesLabel' | translate }}</label>
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="availablePages"
|
||||
@@ -155,7 +155,7 @@
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<small class="field-hint">
|
||||
Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.
|
||||
{{ 'arcEdit.relatedPagesHint' | translate }}
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
@@ -163,8 +163,7 @@
|
||||
@if (!loreId) {
|
||||
<div class="field lore-hint">
|
||||
<small class="field-hint">
|
||||
💡 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 }}
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
@@ -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">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
{{ arc.name }}
|
||||
</h1>
|
||||
<p class="view-subtitle">
|
||||
{{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }}
|
||||
{{ (arc.type === 'HUB' ? 'arcView.subtitleHub' : 'arcView.subtitleLinear') | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
<button type="button" class="btn-primary" (click)="editMode()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu">
|
||||
<button type="button" class="btn-danger" (click)="deleteArc()" [title]="'arcView.deleteTitle' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -32,7 +32,7 @@
|
||||
<!-- Cartes & plans -->
|
||||
@if ((arc.mapImageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> {{ 'arcView.mapsTitle' | translate }}</h2>
|
||||
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
|
||||
</section>
|
||||
}
|
||||
@@ -40,10 +40,10 @@
|
||||
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
|
||||
@if (arc.type === 'HUB') {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> {{ 'arcView.hubQuestsTitle' | translate }}</h2>
|
||||
@if (hubQuests.length === 0) {
|
||||
<p class="view-section-empty">
|
||||
Aucune quête pour ce hub. Créez-en une pour démarrer.
|
||||
{{ 'arcView.hubQuestsEmpty' | translate }}
|
||||
</p>
|
||||
}
|
||||
@if (hubQuests.length > 0) {
|
||||
@@ -66,7 +66,7 @@
|
||||
@if ((q.prerequisites?.length ?? 0) > 0) {
|
||||
<div class="hub-quest-card-locked-hint">
|
||||
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
|
||||
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span>
|
||||
<span>{{ 'arcView.unlockConditions' | translate:{ n: q.prerequisites!.length } }}</span>
|
||||
<ul class="hub-quest-card-prereq-list">
|
||||
@for (p of q.prerequisites; track p) {
|
||||
<li>{{ describePrerequisite(p) }}</li>
|
||||
@@ -81,45 +81,45 @@
|
||||
</section>
|
||||
}
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📜</span> {{ 'arcView.synopsisTitle' | translate }}</h2>
|
||||
@if (arc.description?.trim()) {
|
||||
<p class="view-section-body">{{ arc.description }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'arcView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<div class="view-row">
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">✨</span> Thèmes principaux</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">✨</span> {{ 'arcView.themesTitle' | translate }}</h2>
|
||||
@if (arc.themes?.trim()) {
|
||||
<p class="view-section-body">{{ arc.themes }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'arcView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> Enjeux globaux</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> {{ 'arcView.stakesTitle' | translate }}</h2>
|
||||
@if (arc.stakes?.trim()) {
|
||||
<p class="view-section-body">{{ arc.stakes }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'arcView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> {{ 'arcView.rewardsTitle' | translate }}</h2>
|
||||
@if (arc.rewards?.trim()) {
|
||||
<p class="view-section-body">{{ arc.rewards }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'arcView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> {{ 'arcView.resolutionTitle' | translate }}</h2>
|
||||
@if (arc.resolution?.trim()) {
|
||||
<p class="view-section-body">{{ arc.resolution }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'arcView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<!-- Notes MJ (bloc privé rouge discret) -->
|
||||
@@ -127,7 +127,7 @@
|
||||
<section class="view-section view-section--private">
|
||||
<h2 class="view-section-title">
|
||||
<span class="view-section-icon">🔒</span>
|
||||
Notes et planification du MJ
|
||||
{{ 'arcView.gmNotesTitle' | translate }}
|
||||
</h2>
|
||||
<p class="view-section-body">{{ arc.gmNotes }}</p>
|
||||
</section>
|
||||
@@ -135,7 +135,7 @@
|
||||
<!-- Pages Lore liées (chips cliquables) -->
|
||||
@if (loreId && (arc.relatedPageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> {{ 'arcView.relatedPagesTitle' | translate }}</h2>
|
||||
<div class="view-chips">
|
||||
@for (relId of arc.relatedPageIds; track relId) {
|
||||
<a class="view-chip"
|
||||
|
||||
@@ -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, AlertCircle } 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-arc-view',
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent],
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe],
|
||||
templateUrl: './arc-view.component.html',
|
||||
styleUrls: ['./arc-view.component.scss']
|
||||
})
|
||||
@@ -65,7 +66,8 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -111,7 +113,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
list.forEach(c => { 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;
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="modal" (click)="$event.stopPropagation()">
|
||||
|
||||
<div class="modal-header">
|
||||
<h2>Créer une nouvelle Campagne</h2>
|
||||
<h2>{{ 'campaignCreate.title' | translate }}</h2>
|
||||
<button class="btn-close" (click)="onCancel()">
|
||||
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
@@ -11,54 +11,51 @@
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
|
||||
<div class="field">
|
||||
<label for="campaign-name">Nom de la campagne *</label>
|
||||
<label for="campaign-name">{{ 'campaignCreate.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="campaign-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
|
||||
[placeholder]="'campaignCreate.namePlaceholder' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="campaign-description">Description / Pitch</label>
|
||||
<label for="campaign-description">{{ 'campaignCreate.descriptionLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="campaign-description"
|
||||
formControlName="description"
|
||||
placeholder="Résumez l'intrigue principale de votre campagne..."
|
||||
[placeholder]="'campaignCreate.descriptionPlaceholder' | translate"
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="campaign-player-count">Nombre de joueurs</label>
|
||||
<label for="campaign-player-count">{{ 'campaignCreate.playerCountLabel' | translate }}</label>
|
||||
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="campaign-lore">Univers associé</label>
|
||||
<label for="campaign-lore">{{ 'campaignCreate.loreLabel' | translate }}</label>
|
||||
<select id="campaign-lore" formControlName="loreId">
|
||||
<option value="">— Aucun univers (campagne libre) —</option>
|
||||
<option value="">{{ 'campaignCreate.noLoreOption' | translate }}</option>
|
||||
@for (lore of availableLores; track lore) {
|
||||
<option [value]="lore.id">{{ lore.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<p class="hint">
|
||||
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.
|
||||
</p>
|
||||
<p class="hint">{{ 'campaignCreate.loreHint' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="campaign-game-system">Système de JDR</label>
|
||||
<label for="campaign-game-system">{{ 'campaignCreate.gameSystemLabel' | translate }}</label>
|
||||
@if (!creatingGameSystem) {
|
||||
<select id="campaign-game-system" formControlName="gameSystemId">
|
||||
<option value="">— Aucun (campagne générique) —</option>
|
||||
<option value="">{{ 'campaignCreate.noGameSystemOption' | translate }}</option>
|
||||
@for (gs of availableGameSystems; track gs) {
|
||||
<option [value]="gs.id">{{ gs.name }}</option>
|
||||
}
|
||||
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
|
||||
<option [value]="CREATE_GAMESYSTEM_SENTINEL">{{ 'campaignCreate.createGameSystemOption' | translate }}</option>
|
||||
</select>
|
||||
}
|
||||
|
||||
@@ -69,7 +66,7 @@
|
||||
type="text"
|
||||
[(ngModel)]="newGameSystemName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
|
||||
[placeholder]="'campaignCreate.gameSystemNamePlaceholder' | translate"
|
||||
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||
(keydown.escape)="cancelCreateGameSystem()"
|
||||
autofocus
|
||||
@@ -79,50 +76,39 @@
|
||||
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
|
||||
(click)="submitCreateGameSystem()">
|
||||
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||
Créer
|
||||
{{ 'common.create' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
|
||||
Annuler
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
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.
|
||||
</p>
|
||||
<p class="hint">{{ 'campaignCreate.gameSystemCreateHint' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!creatingGameSystem) {
|
||||
<p class="hint">
|
||||
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.
|
||||
</p>
|
||||
<p class="hint">{{ 'campaignCreate.gameSystemHint' | translate }}</p>
|
||||
}
|
||||
@if (!creatingGameSystem) {
|
||||
<p class="hint hint-warning">
|
||||
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
|
||||
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.
|
||||
</p>
|
||||
<p class="hint hint-warning" [innerHTML]="'campaignCreate.gameSystemWarning' | translate"></p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
|
||||
<p [innerHTML]="'campaignCreate.orgIntro' | translate"></p>
|
||||
<ul>
|
||||
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
|
||||
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
|
||||
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
|
||||
<li [innerHTML]="'campaignCreate.orgArcs' | translate"></li>
|
||||
<li [innerHTML]="'campaignCreate.orgChapters' | translate"></li>
|
||||
<li [innerHTML]="'campaignCreate.orgScenes' | translate"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
|
||||
Créer la campagne
|
||||
{{ 'campaignCreate.submit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="onCancel()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -7,22 +7,22 @@
|
||||
<h1>{{ campaign.name }}</h1>
|
||||
<p class="description">{{ campaign.description }}</p>
|
||||
<div class="meta">
|
||||
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
|
||||
<span class="badge">{{ 'campaignDetail.players' | translate:{ n: campaign.playerCount || 0 } }}</span>
|
||||
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
|
||||
@if (linkedLore) {
|
||||
<a
|
||||
class="badge badge-lore"
|
||||
[routerLink]="['/lore', linkedLore.id]"
|
||||
title="Ouvrir l'univers associé">
|
||||
[title]="'campaignDetail.openLinkedLore' | translate">
|
||||
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
|
||||
{{ linkedLore.name }}
|
||||
</a>
|
||||
}
|
||||
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
|
||||
@if (campaign.loreId && !linkedLore) {
|
||||
<span class="badge badge-lore-missing" title="L'univers associé est introuvable">
|
||||
<span class="badge badge-lore-missing" [title]="'campaignDetail.loreMissingTitle' | translate">
|
||||
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
|
||||
Univers introuvable
|
||||
{{ 'campaignDetail.loreMissing' | translate }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@@ -30,11 +30,11 @@
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="startEdit()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteCampaign()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,34 +43,34 @@
|
||||
@if (editing) {
|
||||
<div class="detail-header edit-mode">
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<label>{{ 'common.name' | translate }}</label>
|
||||
<input type="text" [(ngModel)]="editName" name="editName" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<label>{{ 'common.description' | translate }}</label>
|
||||
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Univers associé</label>
|
||||
<label>{{ 'campaignDetail.loreLabel' | translate }}</label>
|
||||
<select [(ngModel)]="editLoreId" name="editLoreId">
|
||||
<option value="">— Aucun univers (campagne libre) —</option>
|
||||
<option value="">{{ 'campaignDetail.noLoreOption' | translate }}</option>
|
||||
@for (lore of availableLores; track lore) {
|
||||
<option [value]="lore.id">{{ lore.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Système de JDR</label>
|
||||
<label>{{ 'campaignDetail.gameSystemLabel' | translate }}</label>
|
||||
@if (!creatingGameSystem) {
|
||||
<select
|
||||
[(ngModel)]="editGameSystemId"
|
||||
name="editGameSystemId"
|
||||
(ngModelChange)="onEditGameSystemChange($event)">
|
||||
<option value="">— Aucun (générique) —</option>
|
||||
<option value="">{{ 'campaignDetail.noGameSystemOption' | translate }}</option>
|
||||
@for (gs of availableGameSystems; track gs) {
|
||||
<option [value]="gs.id">{{ gs.name }}</option>
|
||||
}
|
||||
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
|
||||
<option [value]="CREATE_GAMESYSTEM_SENTINEL">{{ 'campaignDetail.createGameSystemOption' | translate }}</option>
|
||||
</select>
|
||||
}
|
||||
@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()">
|
||||
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||
Créer
|
||||
{{ 'common.create' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
|
||||
Annuler
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,10 +100,10 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||
Sauvegarder
|
||||
{{ 'common.save' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancelEdit()">
|
||||
Annuler
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@
|
||||
@if (!editing) {
|
||||
<section class="detail-section personas-section">
|
||||
<div class="section-header">
|
||||
<h2>Personnages</h2>
|
||||
<h2>{{ 'campaignDetail.charactersTitle' | translate }}</h2>
|
||||
</div>
|
||||
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
|
||||
ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
|
||||
@@ -121,14 +121,14 @@
|
||||
<div class="subsection-header">
|
||||
<h3>
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
Personnages non-joueurs
|
||||
{{ 'campaignDetail.npcTitle' | translate }}
|
||||
@if (npcs.length > 0) {
|
||||
<span class="count-badge">{{ npcs.length }}</span>
|
||||
}
|
||||
</h3>
|
||||
<button class="btn-add" (click)="createNpc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PNJ
|
||||
{{ 'campaignDetail.newNpc' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (npcs.length > 0) {
|
||||
@@ -146,10 +146,10 @@
|
||||
}
|
||||
@if (npcs.length === 0) {
|
||||
<div class="empty-state empty-state--compact">
|
||||
<p>Aucun PNJ pour le moment.</p>
|
||||
<p>{{ 'campaignDetail.noNpc' | translate }}</p>
|
||||
<button class="btn-add-first" (click)="createNpc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier PNJ
|
||||
{{ 'campaignDetail.createFirstNpc' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -159,11 +159,11 @@
|
||||
@if (!editing) {
|
||||
<section class="detail-section arcs-section">
|
||||
<div class="section-header">
|
||||
<h2>Arcs narratifs</h2>
|
||||
<h2>{{ 'campaignDetail.arcsTitle' | translate }}</h2>
|
||||
<div class="section-header-actions">
|
||||
<button class="btn-add" (click)="createArc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouvel arc
|
||||
{{ 'campaignDetail.newArc' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,7 +173,7 @@
|
||||
<div class="arc-card" (click)="openArc(arc)">
|
||||
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
|
||||
<span class="arc-name">{{ arc.name }}</span>
|
||||
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
|
||||
<span class="arc-meta">{{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -181,10 +181,10 @@
|
||||
@if (arcs.length === 0) {
|
||||
<div class="empty-state">
|
||||
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<p>Aucun arc narratif pour le moment.</p>
|
||||
<p>{{ 'campaignDetail.noArc' | translate }}</p>
|
||||
<button class="btn-add-first" (click)="createArc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier arc
|
||||
{{ 'campaignDetail.createFirstArc' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -196,11 +196,11 @@
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
|
||||
Mes parties
|
||||
{{ 'campaignDetail.playthroughsTitle' | translate }}
|
||||
</h2>
|
||||
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouvelle partie
|
||||
{{ 'campaignDetail.newPlaythrough' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (playthroughs.length > 0) {
|
||||
@@ -221,7 +221,7 @@
|
||||
}
|
||||
@if (playthroughs.length === 0) {
|
||||
<div class="empty-state empty-state--compact">
|
||||
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
|
||||
<p>{{ 'campaignDetail.noPlaythrough' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||
@@ -28,7 +29,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-detail',
|
||||
imports: [FormsModule, LucideAngularModule, RouterLink],
|
||||
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe],
|
||||
templateUrl: './campaign-detail.component.html',
|
||||
styleUrls: ['./campaign-detail.component.scss']
|
||||
})
|
||||
@@ -101,7 +102,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private playthroughService: PlaythroughService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -216,7 +218,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
createPlaythrough(): void {
|
||||
if (!this.campaign || this.newPlaythroughInFlight) return;
|
||||
this.newPlaythroughInFlight = true;
|
||||
const defaultName = `Nouvelle partie ${this.playthroughs.length + 1}`;
|
||||
const defaultName = this.translate.instant('campaignDetail.defaultPlaythroughName', { n: this.playthroughs.length + 1 });
|
||||
this.playthroughService.create({
|
||||
campaignId: this.campaign.id!,
|
||||
name: defaultName
|
||||
@@ -275,7 +277,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!));
|
||||
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!, this.translate));
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression de la Campagne ───────────────
|
||||
@@ -352,16 +354,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
if (gameSystemChanged && hasSheets) {
|
||||
const count = this.npcs.length;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Changer le systeme de jeu ?',
|
||||
message:
|
||||
`Vous etes sur le point de changer le systeme de jeu de cette campagne. ` +
|
||||
`Cela change egalement le template des fiches de PJ et PNJ.`,
|
||||
title: this.translate.instant('campaignDetail.gameSystemChange.title'),
|
||||
message: this.translate.instant('campaignDetail.gameSystemChange.message'),
|
||||
details: [
|
||||
`${count} fiche(s) existante(s) sont liees au template du systeme actuel.`,
|
||||
`Leurs champs ne s'afficheront plus avec le nouveau systeme.`,
|
||||
`Les donnees restent stockees : revenir a l'ancien systeme les rendra a nouveau visibles.`
|
||||
this.translate.instant('campaignDetail.gameSystemChange.detailSheets', { n: count }),
|
||||
this.translate.instant('campaignDetail.gameSystemChange.detailFields'),
|
||||
this.translate.instant('campaignDetail.gameSystemChange.detailStored')
|
||||
],
|
||||
confirmLabel: 'Changer quand meme',
|
||||
confirmLabel: this.translate.instant('campaignDetail.gameSystemChange.confirm'),
|
||||
variant: 'warning'
|
||||
}).then(ok => { if (ok) this.persistEdit(newGameSystemId); });
|
||||
return;
|
||||
@@ -400,20 +400,20 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.campaignService.getCampaignDeletionImpact(campaign.id!).subscribe({
|
||||
next: impact => {
|
||||
const parts: string[] = [];
|
||||
if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`);
|
||||
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.playthroughs > 0) parts.push(`${impact.playthroughs} partie${impact.playthroughs > 1 ? 's' : ''} (avec ses PJ, sessions, faits)`);
|
||||
if (impact.arcs > 0) parts.push(this.translate.instant(impact.arcs > 1 ? 'campaignDetail.delete.arcsPlural' : 'campaignDetail.delete.arcs', { n: impact.arcs }));
|
||||
if (impact.chapters > 0) parts.push(this.translate.instant(impact.chapters > 1 ? 'campaignDetail.delete.chaptersPlural' : 'campaignDetail.delete.chapters', { n: impact.chapters }));
|
||||
if (impact.scenes > 0) parts.push(this.translate.instant(impact.scenes > 1 ? 'campaignDetail.delete.scenesPlural' : 'campaignDetail.delete.scenes', { n: impact.scenes }));
|
||||
if (impact.playthroughs > 0) parts.push(this.translate.instant(impact.playthroughs > 1 ? 'campaignDetail.delete.playthroughsPlural' : 'campaignDetail.delete.playthroughs', { n: impact.playthroughs }));
|
||||
|
||||
const details: string[] = [];
|
||||
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);
|
||||
details.push('Cette action est irreversible.');
|
||||
if (parts.length) details.push(this.translate.instant('campaignDetail.delete.alsoDeletes', { items: parts.join(', ') }));
|
||||
details.push(this.translate.instant('campaignDetail.delete.irreversible'));
|
||||
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la campagne ?',
|
||||
message: `Supprimer definitivement la campagne "${campaign.name}" ?`,
|
||||
title: this.translate.instant('campaignDetail.delete.title'),
|
||||
message: this.translate.instant('campaignDetail.delete.message', { name: campaign.name }),
|
||||
confirmLabel: this.translate.instant('common.delete'),
|
||||
details,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
<div class="page-header">
|
||||
<button type="button" class="btn-back" (click)="cancel()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour à la campagne
|
||||
{{ 'campaignImport.back' | translate }}
|
||||
</button>
|
||||
<h1>Importer un PDF de campagne</h1>
|
||||
<p class="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.
|
||||
</p>
|
||||
<h1>{{ 'campaignImport.title' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'campaignImport.subtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : upload (masqué une fois en revue) -->
|
||||
@@ -18,7 +15,7 @@
|
||||
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
|
||||
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
|
||||
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
|
||||
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
|
||||
{{ (importing ? 'campaignImport.importing' : 'campaignImport.choosePdf') | translate }}
|
||||
</button>
|
||||
<!-- Progression live -->
|
||||
@if (importing) {
|
||||
@@ -36,7 +33,7 @@
|
||||
}
|
||||
@if (importCounts) {
|
||||
<p class="import-counts">
|
||||
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 } }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -52,12 +49,7 @@
|
||||
<section class="review-area">
|
||||
<div class="review-header">
|
||||
<p class="review-summary">
|
||||
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
|
||||
<strong>{{ chapterCount }}</strong> chapitre(s),
|
||||
<strong>{{ sceneCount }}</strong> scène(s)@if (npcs.length > 0) {<span>,
|
||||
<strong>{{ npcCount }}</strong> PNJ</span>}.
|
||||
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
|
||||
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
|
||||
<span [innerHTML]="'campaignImport.summaryToCreate' | translate:{ arcs: arcCount, chapters: chapterCount, scenes: sceneCount }"></span>@if (npcs.length > 0) {<span [innerHTML]="'campaignImport.summaryNpcs' | translate:{ npcs: npcCount }"></span>}<span [innerHTML]="'campaignImport.summaryHint' | translate"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="tree">
|
||||
@@ -70,12 +62,12 @@
|
||||
</button>
|
||||
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
|
||||
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
|
||||
[readonly]="arc.existing" placeholder="Nom de l'arc" />
|
||||
[readonly]="arc.existing" [placeholder]="'campaignImport.arcNamePlaceholder' | translate" />
|
||||
@if (arc.existing) {
|
||||
<span class="badge-existing">déjà présent</span>
|
||||
<span class="badge-existing">{{ 'campaignImport.alreadyPresent' | translate }}</span>
|
||||
}
|
||||
@if (!arc.existing) {
|
||||
<button type="button" class="btn-remove" (click)="removeArc(ai)" title="Supprimer l'arc">
|
||||
<button type="button" class="btn-remove" (click)="removeArc(ai)" [title]="'campaignImport.removeArc' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -84,16 +76,16 @@
|
||||
<div class="node-body">
|
||||
@if (!arc.existing) {
|
||||
<div class="arc-type-toggle">
|
||||
<span class="toggle-label">Type :</span>
|
||||
<span class="toggle-label">{{ 'campaignImport.typeLabel' | translate }}</span>
|
||||
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
|
||||
(click)="setArcType(arc, 'LINEAR')">Linéaire</button>
|
||||
(click)="setArcType(arc, 'LINEAR')">{{ 'campaignImport.typeLinear' | translate }}</button>
|
||||
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
|
||||
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
|
||||
(click)="setArcType(arc, 'HUB')">{{ 'campaignImport.typeHub' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
@if (!arc.existing) {
|
||||
<textarea class="node-desc" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
|
||||
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
|
||||
rows="2" [placeholder]="'campaignImport.arcSynopsisPlaceholder' | translate"></textarea>
|
||||
}
|
||||
<!-- Chapitres -->
|
||||
@for (chapter of arc.chapters; track chapter; let ci = $index) {
|
||||
@@ -104,12 +96,12 @@
|
||||
</button>
|
||||
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
|
||||
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
|
||||
[readonly]="chapter.existing" placeholder="Nom du chapitre" />
|
||||
[readonly]="chapter.existing" [placeholder]="'campaignImport.chapterNamePlaceholder' | translate" />
|
||||
@if (chapter.existing) {
|
||||
<span class="badge-existing">déjà présent</span>
|
||||
<span class="badge-existing">{{ 'campaignImport.alreadyPresent' | translate }}</span>
|
||||
}
|
||||
@if (!chapter.existing) {
|
||||
<button type="button" class="btn-remove" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
|
||||
<button type="button" class="btn-remove" (click)="removeChapter(arc, ci)" [title]="'campaignImport.removeChapter' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -118,7 +110,7 @@
|
||||
<div class="node-body">
|
||||
@if (!chapter.existing) {
|
||||
<textarea class="node-desc" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
|
||||
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
|
||||
rows="2" [placeholder]="'campaignImport.chapterSynopsisPlaceholder' | translate"></textarea>
|
||||
}
|
||||
<!-- Scènes -->
|
||||
@for (scene of chapter.scenes; track scene; let si = $index) {
|
||||
@@ -129,60 +121,60 @@
|
||||
<div class="scene-fields">
|
||||
<input type="text" class="node-name" [(ngModel)]="scene.name"
|
||||
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
|
||||
placeholder="Nom de la scène" />
|
||||
[placeholder]="'campaignImport.sceneNamePlaceholder' | translate" />
|
||||
@if (!scene.existing) {
|
||||
<input type="text" class="node-desc-inline" [(ngModel)]="scene.description"
|
||||
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
|
||||
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" [placeholder]="'campaignImport.synopsisOptionalPlaceholder' | translate" />
|
||||
}
|
||||
</div>
|
||||
@if (scene.existing) {
|
||||
<span class="badge-existing">déjà présent</span>
|
||||
<span class="badge-existing">{{ 'campaignImport.alreadyPresent' | translate }}</span>
|
||||
}
|
||||
@if (!scene.existing) {
|
||||
<button type="button" class="btn-details" (click)="toggleDetails(scene)"
|
||||
title="Narration joueurs, notes MJ, pièces">
|
||||
[title]="'campaignImport.detailsTitle' | translate">
|
||||
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
|
||||
Détails@if (scene.rooms.length) {
|
||||
<span> · {{ scene.rooms.length }} pièce(s)</span>
|
||||
{{ 'campaignImport.details' | translate }}@if (scene.rooms.length) {
|
||||
<span> {{ 'campaignImport.roomsCount' | translate:{ n: scene.rooms.length } }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (!scene.existing) {
|
||||
<button type="button" class="btn-remove" (click)="removeScene(chapter, si)" title="Supprimer la scène">
|
||||
<button type="button" class="btn-remove" (click)="removeScene(chapter, si)" [title]="'campaignImport.removeScene' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (scene.detailsOpen && !scene.existing) {
|
||||
<div class="scene-details">
|
||||
<label class="field-label">À lire aux joueurs</label>
|
||||
<label class="field-label">{{ 'campaignImport.playerNarrationLabel' | translate }}</label>
|
||||
<textarea class="node-desc" [(ngModel)]="scene.playerNarration"
|
||||
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
|
||||
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
|
||||
<label class="field-label">Notes MJ</label>
|
||||
[placeholder]="'campaignImport.playerNarrationPlaceholder' | translate"></textarea>
|
||||
<label class="field-label">{{ 'campaignImport.gmNotesLabel' | translate }}</label>
|
||||
<textarea class="node-desc" [(ngModel)]="scene.gmNotes"
|
||||
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
|
||||
placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
|
||||
[placeholder]="'campaignImport.gmNotesPlaceholder' | translate"></textarea>
|
||||
<!-- Pièces (donjon) : vide = scène narrative classique -->
|
||||
<span class="field-label">Pièces (lieu explorable)</span>
|
||||
<span class="field-label">{{ 'campaignImport.roomsLabel' | translate }}</span>
|
||||
<div class="rooms">
|
||||
@for (room of scene.rooms; track room; let ri = $index) {
|
||||
<div class="room-row">
|
||||
<input type="text" class="room-name" [(ngModel)]="room.name"
|
||||
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
|
||||
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'campaignImport.roomPlaceholder' | translate" />
|
||||
<input type="text" class="room-field" [(ngModel)]="room.description"
|
||||
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" />
|
||||
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'common.description' | translate" />
|
||||
<input type="text" class="room-field" [(ngModel)]="room.enemies"
|
||||
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" />
|
||||
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'campaignImport.enemiesPlaceholder' | translate" />
|
||||
<input type="text" class="room-field" [(ngModel)]="room.loot"
|
||||
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" />
|
||||
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce">
|
||||
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'campaignImport.lootPlaceholder' | translate" />
|
||||
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" [title]="'campaignImport.removeRoom' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="btn-add-inline" (click)="addRoom(scene)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> {{ 'campaignImport.room' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,32 +182,29 @@
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="btn-add-inline" (click)="addScene(chapter)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> {{ 'campaignImport.scene' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> {{ 'campaignImport.chapter' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="btn-add" (click)="addArc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> {{ 'campaignImport.addArc' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PNJ détectés dans le PDF : revue par cases à cocher -->
|
||||
@if (npcs.length > 0) {
|
||||
<div class="npc-review">
|
||||
<h3>PNJ et créatures détectés ({{ npcs.length }})</h3>
|
||||
<p class="hint">
|
||||
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.
|
||||
</p>
|
||||
<h3>{{ 'campaignImport.npcReviewTitle' | translate:{ n: npcs.length } }}</h3>
|
||||
<p class="hint">{{ 'campaignImport.npcReviewHint' | translate }}</p>
|
||||
<ul class="npc-list">
|
||||
@for (npc of npcs; track npc.name) {
|
||||
<li class="npc-row" [class.npc-existing]="npc.existing">
|
||||
@@ -223,7 +212,7 @@
|
||||
<input type="checkbox" [(ngModel)]="npc.selected" [disabled]="npc.existing">
|
||||
<strong>{{ npc.name }}</strong>
|
||||
@if (npc.existing) {
|
||||
<em class="npc-tag">déjà présent</em>
|
||||
<em class="npc-tag">{{ 'campaignImport.alreadyPresent' | translate }}</em>
|
||||
}
|
||||
</label>
|
||||
@if (npc.description) {
|
||||
@@ -241,9 +230,9 @@
|
||||
<div class="review-actions">
|
||||
<button type="button" class="btn-primary" [disabled]="applying || !hasNewContent" (click)="apply()">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
{{ applying ? 'Création…' : 'Créer dans la campagne' }}
|
||||
{{ (applying ? 'campaignImport.creating' : 'campaignImport.createInCampaign') | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
<div class="campaigns-hero">
|
||||
<lucide-icon [img]="Map" [size]="56" class="hero-icon"></lucide-icon>
|
||||
<h1>Vos Campagnes</h1>
|
||||
<p class="hero-subtitle">Rejoignez une campagne ou créez-en de nouvelles</p>
|
||||
<h1>{{ 'campaigns.heroTitle' | translate }}</h1>
|
||||
<p class="hero-subtitle">{{ 'campaigns.heroSubtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="campaigns-grid">
|
||||
@@ -11,14 +11,14 @@
|
||||
@for (campaign of campaigns; track campaign) {
|
||||
<div class="campaign-card" (click)="navigateToDetail(campaign.id!)">
|
||||
<div class="card-header">
|
||||
<span class="status-badge en-cours">En cours</span>
|
||||
<span class="card-date">{{ campaign.playerCount }} joueurs</span>
|
||||
<span class="status-badge en-cours">{{ 'campaigns.statusInProgress' | translate }}</span>
|
||||
<span class="card-date">{{ 'campaigns.players' | translate:{ n: campaign.playerCount || 0 } }}</span>
|
||||
</div>
|
||||
<h2>{{ campaign.name }}</h2>
|
||||
<p class="card-description">{{ campaign.description }}</p>
|
||||
<div class="card-stats">
|
||||
<span>⚔️ {{ campaign.arcCount || 0 }} arcs</span>
|
||||
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
|
||||
<span>⚔️ {{ 'campaigns.arcs' | translate:{ n: campaign.arcCount || 0 } }}</span>
|
||||
<span>📖 {{ 'campaigns.chapters' | translate:{ n: campaign.chapterCount || 0 } }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -27,13 +27,13 @@
|
||||
<div class="new-icon">
|
||||
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
|
||||
</div>
|
||||
<h2>Nouvelle Campagne</h2>
|
||||
<p class="card-description">Créez une nouvelle aventure</p>
|
||||
<h2>{{ 'campaigns.newCampaign' | translate }}</h2>
|
||||
<p class="card-description">{{ 'campaigns.newCampaignSubtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="tip">💡 Astuce : Organisez vos arcs et chapitres pour ne rien oublier de vos aventures</p>
|
||||
<p class="tip">{{ 'campaigns.tip' | translate }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
<div class="chapter-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>{{ isHub ? 'Créer une nouvelle quête' : 'Créer un nouveau chapitre' }}</h1>
|
||||
<h1>{{ (isHub ? 'chapterCreate.titleQuest' : 'chapterCreate.titleChapter') | translate }}</h1>
|
||||
@if (arcName) {
|
||||
<p class="arc-ref">{{ isHub ? 'Hub' : 'Arc' }} : {{ arcName }}</p>
|
||||
<p class="arc-ref">{{ (isHub ? 'chapterCreate.hub' : 'chapterCreate.arc') | translate }} : {{ arcName }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
|
||||
|
||||
<div class="field">
|
||||
<label for="chapter-create-name">{{ isHub ? 'Nom de la quête *' : 'Nom du chapitre *' }}</label>
|
||||
<label for="chapter-create-name">{{ (isHub ? 'chapterCreate.nameQuestLabel' : 'chapterCreate.nameChapterLabel') | translate }}</label>
|
||||
<input
|
||||
id="chapter-create-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
[placeholder]="isHub ? 'Ex: Sauver le marchand disparu' : 'Ex: Chapitre 1: Les Disparitions'"
|
||||
[placeholder]="(isHub ? 'chapterCreate.namePlaceholderQuest' : 'chapterCreate.namePlaceholderChapter') | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="chapter-create-description">Description</label>
|
||||
<label for="chapter-create-description">{{ 'common.description' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-create-description"
|
||||
formControlName="description"
|
||||
[placeholder]="isHub ? 'Décrivez cette quête...' : 'Décrivez ce chapitre...'"
|
||||
[placeholder]="(isHub ? 'chapterCreate.descPlaceholderQuest' : 'chapterCreate.descPlaceholderChapter') | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<label>{{ 'chapterCreate.icon' | translate }}</label>
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
{{ isHub ? 'Créer la quête' : 'Créer le chapitre' }}
|
||||
{{ (isHub ? 'chapterCreate.createQuest' : 'chapterCreate.createChapter') | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ chapter?.name || 'Chapitre' }}</h1>
|
||||
<p class="subtitle">Chapitre</p>
|
||||
<h1>{{ chapter?.name || ('chapterEdit.chapter' | translate) }}</h1>
|
||||
<p class="subtitle">{{ 'chapterEdit.chapter' | translate }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre">
|
||||
[title]="'chapterEdit.aiAssistantTitle' | translate">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
{{ 'chapterEdit.aiAssistant' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
|
||||
Sauvegarder
|
||||
{{ 'common.save' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,13 +30,10 @@
|
||||
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
|
||||
<div class="hub-section">
|
||||
<h2 class="hub-section-title">
|
||||
{{ 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 }}
|
||||
</h2>
|
||||
<small class="field-hint">
|
||||
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 }}
|
||||
</small>
|
||||
<app-prerequisite-editor
|
||||
[prerequisites]="prerequisites"
|
||||
@@ -48,81 +45,81 @@
|
||||
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>Illustrations</label>
|
||||
<label>{{ 'chapterEdit.illustrations' | translate }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">Portraits, ambiances, scenes marquantes du chapitre.</small>
|
||||
<small class="field-hint">{{ 'chapterEdit.illustrationsHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Cartes & plans -->
|
||||
<div class="field">
|
||||
<label>Cartes & plans</label>
|
||||
<label>{{ 'chapterEdit.maps' | translate }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="mapImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'MAPS'"
|
||||
(imageIdsChange)="mapImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">Cartes regionales, plans de donjon, schemas utiles a la table.</small>
|
||||
<small class="field-hint">{{ 'chapterEdit.mapsHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="chapter-edit-name">Titre du chapitre *</label>
|
||||
<label for="chapter-edit-name">{{ 'chapterEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="chapter-edit-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Chapitre 1: Les Disparitions"
|
||||
[placeholder]="'chapterEdit.namePlaceholder' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="chapter-edit-description">Synopsis du chapitre</label>
|
||||
<label for="chapter-edit-description">{{ 'chapterEdit.synopsisLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-edit-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez brièvement ce qui se passe dans ce chapitre..."
|
||||
[placeholder]="'chapterEdit.synopsisPlaceholder' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<label>{{ 'chapterEdit.icon' | translate }}</label>
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label>
|
||||
<label for="chapter-edit-gm-notes">{{ 'chapterEdit.gmNotesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-edit-gm-notes"
|
||||
formControlName="gmNotes"
|
||||
placeholder="Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements..."
|
||||
[placeholder]="'chapterEdit.gmNotesPlaceholder' | translate"
|
||||
rows="6">
|
||||
</textarea>
|
||||
<small class="field-hint">Ces notes sont privées et ne seront pas exportées vers FoundryVTT.</small>
|
||||
<small class="field-hint">{{ 'chapterEdit.gmNotesHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="chapter-edit-player-objectives">Objectifs des joueurs</label>
|
||||
<label for="chapter-edit-player-objectives">{{ 'chapterEdit.playerObjectivesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-edit-player-objectives"
|
||||
formControlName="playerObjectives"
|
||||
placeholder="Que doivent accomplir les joueurs dans ce chapitre ?"
|
||||
[placeholder]="'chapterEdit.playerObjectivesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter-edit-narrative-stakes">Enjeux narratifs</label>
|
||||
<label for="chapter-edit-narrative-stakes">{{ 'chapterEdit.narrativeStakesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-edit-narrative-stakes"
|
||||
formControlName="narrativeStakes"
|
||||
placeholder="Quels sont les enjeux dramatiques ?"
|
||||
[placeholder]="'chapterEdit.narrativeStakesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
@@ -131,7 +128,7 @@
|
||||
<!-- ===== Pages Lore associées (B2 cross-context) ===== -->
|
||||
@if (loreId) {
|
||||
<div class="field">
|
||||
<label>Pages Lore associées</label>
|
||||
<label>{{ 'chapterEdit.relatedPages' | translate }}</label>
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="availablePages"
|
||||
@@ -139,7 +136,7 @@
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<small class="field-hint">
|
||||
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
|
||||
{{ 'chapterEdit.relatedPagesHint' | translate }}
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
@@ -147,8 +144,7 @@
|
||||
@if (!loreId) {
|
||||
<div class="field">
|
||||
<small class="field-hint">
|
||||
💡 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 }}
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
@@ -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">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ chapter?.name || 'Chapitre' }} — Carte</h1>
|
||||
<p class="subtitle">Organigramme des scènes et de leurs branches narratives</p>
|
||||
<h1>{{ 'chapterGraph.title' | translate:{ name: chapter?.name || ('chapterGraph.chapter' | translate) } }}</h1>
|
||||
<p class="subtitle">{{ 'chapterGraph.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour au chapitre
|
||||
{{ 'chapterGraph.back' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (scenes.length === 0) {
|
||||
<div class="graph-empty">
|
||||
<p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p>
|
||||
<p>{{ 'chapterGraph.empty' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</g>
|
||||
</svg>
|
||||
<small class="graph-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.
|
||||
{{ 'chapterGraph.hint' | translate }}
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,29 +9,29 @@
|
||||
{{ chapter.name }}
|
||||
</h1>
|
||||
<p class="view-subtitle">
|
||||
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
|
||||
{{ (parentArc?.type === 'HUB' ? 'chapterView.questHub' : 'chapterView.chapter') | translate }}
|
||||
@if ((chapter.prerequisites?.length ?? 0) > 0) {
|
||||
<span class="cond-badge"
|
||||
title="Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.">
|
||||
[title]="'chapterView.conditionalBadgeTitle' | translate">
|
||||
<lucide-icon [img]="Lock" [size]="12"></lucide-icon>
|
||||
Conditionnel
|
||||
{{ 'chapterView.conditional' | translate }}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||
title="Voir l'organigramme des scènes et de leurs branches">
|
||||
[title]="'chapterView.mapTitle' | translate">
|
||||
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
|
||||
Carte du chapitre
|
||||
{{ 'chapterView.map' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" (click)="editMode()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteChapter()" title="Supprimer le chapitre et ses scènes">
|
||||
<button type="button" class="btn-danger" (click)="deleteChapter()" [title]="'chapterView.deleteTitle' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -39,7 +39,7 @@
|
||||
OU dès qu'un chapitre linéaire porte des conditions. -->
|
||||
@if (parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> {{ 'chapterView.unlockConditions' | translate }}</h2>
|
||||
@if ((chapter.prerequisites?.length ?? 0) > 0) {
|
||||
<ul class="view-section-body">
|
||||
@for (p of chapter.prerequisites; track p) {
|
||||
@@ -47,17 +47,13 @@
|
||||
}
|
||||
</ul>
|
||||
<small class="view-section-empty">
|
||||
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 }}
|
||||
</small>
|
||||
} @else {
|
||||
<p class="view-section-empty">
|
||||
Aucune condition — cette quête est disponible dès le début dans chaque Partie.
|
||||
{{ 'chapterView.noConditionQuest' | translate }}
|
||||
</p>
|
||||
<p class="view-section-empty">
|
||||
Cliquez sur <strong>Modifier</strong> pour ajouter des conditions
|
||||
(« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »).
|
||||
<p class="view-section-empty" [innerHTML]="'chapterView.noConditionEdit' | translate">
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
@@ -71,33 +67,33 @@
|
||||
<!-- Cartes & plans -->
|
||||
@if ((chapter.mapImageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> {{ 'chapterView.maps' | translate }}</h2>
|
||||
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
|
||||
</section>
|
||||
}
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Synopsis</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📖</span> {{ 'chapterView.synopsis' | translate }}</h2>
|
||||
@if (chapter.description?.trim()) {
|
||||
<p class="view-section-body">{{ chapter.description }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'chapterView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<div class="view-row">
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> Objectifs des joueurs</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> {{ 'chapterView.playerObjectives' | translate }}</h2>
|
||||
@if (chapter.playerObjectives?.trim()) {
|
||||
<p class="view-section-body">{{ chapter.playerObjectives }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'chapterView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⚡</span> Enjeux narratifs</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⚡</span> {{ 'chapterView.narrativeStakes' | translate }}</h2>
|
||||
@if (chapter.narrativeStakes?.trim()) {
|
||||
<p class="view-section-body">{{ chapter.narrativeStakes }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'chapterView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
@@ -105,14 +101,14 @@
|
||||
<section class="view-section view-section--private">
|
||||
<h2 class="view-section-title">
|
||||
<span class="view-section-icon">🔒</span>
|
||||
Notes du Maître de Jeu
|
||||
{{ 'chapterView.gmNotes' | translate }}
|
||||
</h2>
|
||||
<p class="view-section-body">{{ chapter.gmNotes }}</p>
|
||||
</section>
|
||||
}
|
||||
@if (loreId && (chapter.relatedPageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> {{ 'chapterView.relatedPages' | translate }}</h2>
|
||||
<div class="view-chips">
|
||||
@for (relId of chapter.relatedPageIds; track relId) {
|
||||
<a class="view-chip"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil, Network, Trash2, Lock } 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';
|
||||
@@ -25,7 +26,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-chapter-view',
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent],
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe],
|
||||
templateUrl: './chapter-view.component.html',
|
||||
styleUrls: ['./chapter-view.component.scss']
|
||||
})
|
||||
@@ -57,7 +58,8 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -101,23 +103,26 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
list.forEach(c => { 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;
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<div class="ce-header">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour à la campagne
|
||||
{{ 'characterEdit.backToCampaign' | translate }}
|
||||
</button>
|
||||
<div class="header-row">
|
||||
<h1>
|
||||
<lucide-icon [img]="User" [size]="22"></lucide-icon>
|
||||
{{ characterId ? 'Éditer la fiche' : 'Nouveau personnage' }}
|
||||
{{ (characterId ? 'characterEdit.titleEdit' : 'characterEdit.titleNew') | translate }}
|
||||
</h1>
|
||||
@if (characterId) {
|
||||
<button
|
||||
@@ -16,9 +16,9 @@
|
||||
class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PJ">
|
||||
[title]="'characterEdit.aiAssistantTitle' | translate">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
{{ 'characterEdit.aiAssistant' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -27,32 +27,32 @@
|
||||
<div class="ce-form">
|
||||
|
||||
<div class="field">
|
||||
<label for="character-name">Nom du personnage *</label>
|
||||
<label for="character-name">{{ 'characterEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="character-name"
|
||||
type="text"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
placeholder="Ex: Thorin le Grand-Hache, Lyra l'Errante..."
|
||||
[placeholder]="'characterEdit.namePlaceholder' | translate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row image-row">
|
||||
<div class="field portrait-field">
|
||||
<label>Portrait</label>
|
||||
<label>{{ 'characterEdit.portrait' | translate }}</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="portraitImageId"
|
||||
aspectRatio="1 / 1"
|
||||
hint="Carre conseille (400×400)."
|
||||
[hint]="'characterEdit.portraitHint' | translate"
|
||||
(imageIdChange)="portraitImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
<div class="field header-field">
|
||||
<label>Bandeau / Header</label>
|
||||
<label>{{ 'characterEdit.header' | translate }}</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="headerImageId"
|
||||
aspectRatio="3 / 1"
|
||||
hint="Format paysage conseille (1200×400)."
|
||||
[hint]="'characterEdit.headerHint' | translate"
|
||||
(imageIdChange)="headerImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
@@ -73,9 +73,9 @@
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
{{ characterId ? 'Enregistrer' : 'Créer' }}
|
||||
{{ (characterId ? 'common.save' : 'common.create') | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">{{ 'common.cancel' | translate }}</button>
|
||||
<span class="spacer"></span>
|
||||
@if (characterId) {
|
||||
<button
|
||||
@@ -83,7 +83,7 @@
|
||||
class="btn-danger"
|
||||
(click)="deleteCharacter()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -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">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
<div class="cv-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour
|
||||
{{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
@if (characterId) {
|
||||
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
{{ 'characterView.aiAssistant' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button class="btn-edit" (click)="edit()">
|
||||
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
|
||||
Editer
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<div class="ne-header">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour aux ennemis
|
||||
{{ 'enemyEdit.backToEnemies' | translate }}
|
||||
</button>
|
||||
<div class="header-row">
|
||||
<h1>
|
||||
<lucide-icon [img]="Skull" [size]="22"></lucide-icon>
|
||||
{{ enemyId ? 'Éditer l\'ennemi' : 'Nouvel ennemi' }}
|
||||
{{ (enemyId ? 'enemyEdit.titleEdit' : 'enemyEdit.titleNew') | translate }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,36 +16,36 @@
|
||||
<div class="ne-form">
|
||||
|
||||
<div class="field">
|
||||
<label for="enemy-name">Nom de l'ennemi *</label>
|
||||
<label for="enemy-name">{{ 'enemyEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="enemy-name"
|
||||
type="text"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
placeholder="Ex: Balor, Gobelin chef de guerre, Liche…"
|
||||
[placeholder]="'enemyEdit.namePlaceholder' | translate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row image-row">
|
||||
<div class="field">
|
||||
<label for="enemy-level">Niveau / FP</label>
|
||||
<label for="enemy-level">{{ 'enemyEdit.level' | translate }}</label>
|
||||
<input
|
||||
id="enemy-level"
|
||||
type="text"
|
||||
[(ngModel)]="level"
|
||||
name="level"
|
||||
placeholder="Ex: 5, FP 8, Boss…"
|
||||
[placeholder]="'enemyEdit.levelPlaceholder' | translate"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="enemy-folder">Dossier</label>
|
||||
<label for="enemy-folder">{{ 'enemyEdit.folder' | translate }}</label>
|
||||
<input
|
||||
id="enemy-folder"
|
||||
type="text"
|
||||
[(ngModel)]="folder"
|
||||
name="folder"
|
||||
list="enemy-folders"
|
||||
placeholder="Ex: Démons, Humanoïdes… (vide = non classé)"
|
||||
[placeholder]="'enemyEdit.folderPlaceholder' | translate"
|
||||
/>
|
||||
<datalist id="enemy-folders">
|
||||
@for (f of existingFolders; track f) {
|
||||
@@ -57,20 +57,20 @@
|
||||
|
||||
<div class="field-row image-row">
|
||||
<div class="field portrait-field">
|
||||
<label>Portrait</label>
|
||||
<label>{{ 'enemyEdit.portrait' | translate }}</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="portraitImageId"
|
||||
aspectRatio="1 / 1"
|
||||
hint="Carre conseille (400×400)."
|
||||
[hint]="'enemyEdit.portraitHint' | translate"
|
||||
(imageIdChange)="portraitImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
<div class="field header-field">
|
||||
<label>Bandeau / Header</label>
|
||||
<label>{{ 'enemyEdit.header' | translate }}</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="headerImageId"
|
||||
aspectRatio="3 / 1"
|
||||
hint="Format paysage conseille (1200×400)."
|
||||
[hint]="'enemyEdit.headerHint' | translate"
|
||||
(imageIdChange)="headerImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
@@ -91,9 +91,9 @@
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
{{ enemyId ? 'Enregistrer' : 'Créer' }}
|
||||
{{ (enemyId ? 'common.save' : 'common.create') | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">{{ 'common.cancel' | translate }}</button>
|
||||
<span class="spacer"></span>
|
||||
@if (enemyId) {
|
||||
<button
|
||||
@@ -101,7 +101,7 @@
|
||||
class="btn-danger"
|
||||
(click)="deleteEnemy()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
<div class="sbl-page">
|
||||
<div class="sbl-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-create" (click)="create()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouvel ennemi
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> {{ 'enemyList.create' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="sbl-header">
|
||||
<h1><lucide-icon [img]="Skull" [size]="22"></lucide-icon> Ennemis</h1>
|
||||
<h1><lucide-icon [img]="Skull" [size]="22"></lucide-icon> {{ 'enemyList.title' | translate }}</h1>
|
||||
<p class="sbl-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…).
|
||||
{{ 'enemyList.hint' | translate }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="sbl-list">
|
||||
@if (total === 0) {
|
||||
<p class="empty">Aucun ennemi pour l'instant.</p>
|
||||
<p class="empty">{{ 'enemyList.empty' | translate }}</p>
|
||||
}
|
||||
@for (g of groups; track g.folder) {
|
||||
@if (g.folder) {
|
||||
@@ -29,16 +28,16 @@
|
||||
<span class="sbl-folder-count">{{ g.enemies.length }}</span>
|
||||
</h2>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">Non classés</h2>
|
||||
<h2 class="sbl-folder sbl-folder--none">{{ 'enemyList.unclassified' | translate }}</h2>
|
||||
}
|
||||
@for (e of g.enemies; track e.id) {
|
||||
<button class="sbl-item" (click)="open(e)">
|
||||
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ e.name }}</span>
|
||||
@if (e.level) {
|
||||
<span class="sbl-item-level">Niv. {{ e.level }}</span>
|
||||
<span class="sbl-item-level">{{ 'enemyList.levelShort' | translate:{ level: e.level } }}</span>
|
||||
}
|
||||
<span class="sbl-del" (click)="remove(e, $event)" title="Supprimer">
|
||||
<span class="sbl-del" (click)="remove(e, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<div class="nv-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour
|
||||
{{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-edit" (click)="edit()">
|
||||
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
|
||||
Editer
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
<div class="ice-page">
|
||||
<div class="ice-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-save" (click)="save()" [disabled]="saving">
|
||||
<lucide-icon [img]="Save" [size]="14"></lucide-icon>
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
{{ (saving ? 'common.saving' : 'common.save') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1>{{ catalogId ? 'Éditer le catalogue' : 'Nouveau catalogue d\'objets' }}</h1>
|
||||
<h1>{{ (catalogId ? 'itemCatalogEdit.editTitle' : 'itemCatalogEdit.createTitle') | translate }}</h1>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="error">{{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<div class="form-row">
|
||||
<label for="ic-name">Nom *</label>
|
||||
<input id="ic-name" type="text" [(ngModel)]="name" placeholder="Ex: Échoppe de l'armurier nain">
|
||||
<label for="ic-name">{{ 'itemCatalogEdit.nameLabel' | translate }}</label>
|
||||
<input id="ic-name" type="text" [(ngModel)]="name" [placeholder]="'itemCatalogEdit.namePlaceholder' | translate">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="ic-desc">Description</label>
|
||||
<textarea id="ic-desc" rows="2" [(ngModel)]="description" placeholder="À quoi sert ce catalogue / cette boutique ?"></textarea>
|
||||
<label for="ic-desc">{{ 'common.description' | translate }}</label>
|
||||
<textarea id="ic-desc" rows="2" [(ngModel)]="description" [placeholder]="'itemCatalogEdit.descriptionPlaceholder' | translate"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Génération IA -->
|
||||
<div class="ai-box">
|
||||
<div class="ai-title"><lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA</div>
|
||||
<p class="ai-hint">Décris la boutique/le butin ; l'IA propose les objets (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
|
||||
<div class="ai-title"><lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> {{ 'itemCatalogEdit.aiTitle' | translate }}</div>
|
||||
<p class="ai-hint">{{ 'itemCatalogEdit.aiHint' | translate }}</p>
|
||||
<textarea rows="2" [(ngModel)]="aiPrompt"
|
||||
placeholder="Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques"></textarea>
|
||||
[placeholder]="'itemCatalogEdit.aiPlaceholder' | translate"></textarea>
|
||||
<div class="ai-actions">
|
||||
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
{{ generating ? 'Génération…' : 'Générer' }}
|
||||
{{ (generating ? 'common.generating' : 'common.generate') | translate }}
|
||||
</button>
|
||||
@if (aiError) {
|
||||
<span class="ai-error">{{ aiError }}</span>
|
||||
@@ -44,33 +44,33 @@
|
||||
</div>
|
||||
|
||||
<div class="items-head">
|
||||
<h2>Objets</h2>
|
||||
<h2>{{ 'itemCatalogEdit.itemsTitle' | translate }}</h2>
|
||||
<button class="btn-mini" (click)="addItem()">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> Ajouter
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> {{ 'common.add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="item-row head">
|
||||
<span class="c-name">Nom</span>
|
||||
<span class="c-price">Prix</span>
|
||||
<span class="c-cat">Catégorie</span>
|
||||
<span class="c-desc">Description</span>
|
||||
<span class="c-name">{{ 'common.name' | translate }}</span>
|
||||
<span class="c-price">{{ 'itemCatalogEdit.priceLabel' | translate }}</span>
|
||||
<span class="c-cat">{{ 'itemCatalogEdit.categoryLabel' | translate }}</span>
|
||||
<span class="c-desc">{{ 'common.description' | translate }}</span>
|
||||
<span class="c-del"></span>
|
||||
</div>
|
||||
|
||||
@for (it of items; track it; let i = $index) {
|
||||
<div class="item-row">
|
||||
<input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet">
|
||||
<input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po">
|
||||
<input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes">
|
||||
<input class="c-desc" type="text" [(ngModel)]="it.description" placeholder="Effet / détails">
|
||||
<button class="c-del btn-del" (click)="removeItem(i)" title="Supprimer">
|
||||
<input class="c-name" type="text" [(ngModel)]="it.name" [placeholder]="'itemCatalogEdit.itemNamePlaceholder' | translate">
|
||||
<input class="c-price" type="text" [(ngModel)]="it.price" [placeholder]="'itemCatalogEdit.itemPricePlaceholder' | translate">
|
||||
<input class="c-cat" type="text" [(ngModel)]="it.category" [placeholder]="'itemCatalogEdit.itemCategoryPlaceholder' | translate">
|
||||
<input class="c-desc" type="text" [(ngModel)]="it.description" [placeholder]="'itemCatalogEdit.itemDescriptionPlaceholder' | translate">
|
||||
<button class="c-del btn-del" (click)="removeItem(i)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (items.length === 0) {
|
||||
<p class="empty-hint">Aucun objet — clique « Ajouter ».</p>
|
||||
<p class="empty-hint">{{ 'itemCatalogEdit.emptyHint' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
<div class="icl-page">
|
||||
<div class="icl-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-create" (click)="create()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouveau catalogue
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> {{ 'itemCatalogList.newCatalog' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="icl-header">
|
||||
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> Catalogues d'objets</h1>
|
||||
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> {{ 'itemCatalogList.title' | translate }}</h1>
|
||||
<p class="icl-hint">
|
||||
Boutiques, butins, trésors… à remplir à la main ou via l'IA. Consultables en session
|
||||
quand les joueurs visitent une échoppe.
|
||||
{{ 'itemCatalogList.hint' | translate }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="icl-list">
|
||||
@if (catalogs.length === 0) {
|
||||
<p class="empty">Aucun catalogue pour l'instant.</p>
|
||||
<p class="empty">{{ 'itemCatalogList.empty' | translate }}</p>
|
||||
}
|
||||
@for (c of catalogs; track c) {
|
||||
<button class="icl-item" (click)="open(c)">
|
||||
<lucide-icon [img]="Package" [size]="16"></lucide-icon>
|
||||
<span class="icl-item-name">{{ c.name }}</span>
|
||||
<span class="icl-item-count">{{ c.items.length }} objet(s)</span>
|
||||
<span class="icl-del" (click)="remove(c, $event)" title="Supprimer">
|
||||
<span class="icl-item-count">{{ 'itemCatalogList.itemCount' | translate:{ n: c.items.length } }}</span>
|
||||
<span class="icl-del" (click)="remove(c, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div class="ic-page">
|
||||
<div class="ic-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-edit" (click)="edit()">
|
||||
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> Éditer
|
||||
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> {{ 'common.edit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<header class="ic-header">
|
||||
@@ -17,7 +17,7 @@
|
||||
</header>
|
||||
@if (catalog.items.length === 0) {
|
||||
<p class="ic-empty">
|
||||
Aucun objet — édite le catalogue pour en ajouter.
|
||||
{{ 'itemCatalogView.empty' | translate }}
|
||||
</p>
|
||||
}
|
||||
@for (g of groups; track g) {
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
@if (status !== 'created' && needsArc) {
|
||||
<div class="nac-targets">
|
||||
<label>
|
||||
Arc
|
||||
{{ 'notebookActionCard.arc' | translate }}
|
||||
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
|
||||
@for (a of arcs; track a) {
|
||||
<option [value]="a.id">{{ a.name }}</option>
|
||||
@@ -22,7 +22,7 @@
|
||||
</label>
|
||||
@if (needsChapter) {
|
||||
<label>
|
||||
Chapitre
|
||||
{{ 'notebookActionCard.chapter' | translate }}
|
||||
<select [(ngModel)]="selectedChapterId">
|
||||
@for (c of targetChapters; track c) {
|
||||
<option [value]="c.id">{{ c.name }}</option>
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
@if (needsArc && arcs.length === 0) {
|
||||
<p class="nac-warn">
|
||||
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
|
||||
{{ (needsChapter ? 'notebookActionCard.warnArcChapter' : 'notebookActionCard.warnArc') | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@
|
||||
@if (status !== 'created') {
|
||||
<button class="nac-create" (click)="create()" [disabled]="!canCreate">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
|
||||
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
|
||||
{{ (status === 'creating' ? 'notebookActionCard.creating' : 'notebookActionCard.createInCampaign') | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (status === 'created') {
|
||||
<button class="nac-open" (click)="openCreated()">
|
||||
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
|
||||
<lucide-icon [img]="Check" [size]="13"></lucide-icon> {{ 'notebookActionCard.createdOpen' | translate }}
|
||||
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { LucideAngularModule, Plus, Check, Drama, Clapperboard, BookText, GitBranch, Dices, ExternalLink } from 'lucide-angular';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
@@ -15,7 +16,7 @@ import { NotebookAction } from '../../../services/notebook-action.model';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-notebook-action-card',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './notebook-action-card.component.html',
|
||||
styleUrls: ['./notebook-action-card.component.scss']
|
||||
})
|
||||
@@ -42,7 +43,8 @@ export class NotebookActionCardComponent implements OnInit {
|
||||
private campaignService: CampaignService,
|
||||
private npcService: NpcService,
|
||||
private tableService: RandomTableService,
|
||||
private router: Router
|
||||
private router: Router,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -52,11 +54,11 @@ export class NotebookActionCardComponent implements OnInit {
|
||||
|
||||
get typeLabel(): string {
|
||||
switch (this.action.type) {
|
||||
case 'npc': return 'PNJ';
|
||||
case 'scene': return 'Scène';
|
||||
case 'chapter': return 'Chapitre';
|
||||
case 'arc': return 'Arc';
|
||||
case 'table': return 'Table aléatoire';
|
||||
case 'npc': return this.translate.instant('notebookActionCard.typeNpc');
|
||||
case 'scene': return this.translate.instant('notebookActionCard.typeScene');
|
||||
case 'chapter': return this.translate.instant('notebookActionCard.typeChapter');
|
||||
case 'arc': return this.translate.instant('notebookActionCard.typeArc');
|
||||
case 'table': return this.translate.instant('notebookActionCard.typeTable');
|
||||
default: return this.action.type;
|
||||
}
|
||||
}
|
||||
@@ -200,7 +202,7 @@ export class NotebookActionCardComponent implements OnInit {
|
||||
|
||||
private fail(err: unknown): void {
|
||||
this.status = 'error';
|
||||
this.errorMsg = (err as { error?: { message?: string } })?.error?.message || 'Échec de la création.';
|
||||
this.errorMsg = (err as { error?: { message?: string } })?.error?.message || this.translate.instant('notebookActionCard.createError');
|
||||
}
|
||||
|
||||
openCreated(): void {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="nbd-page">
|
||||
<div class="nbd-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'notebookDetail.backToList' | translate }}
|
||||
</button>
|
||||
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
|
||||
</div>
|
||||
@@ -10,10 +10,10 @@
|
||||
<!-- Sources -->
|
||||
<aside class="nbd-sources">
|
||||
<div class="nbd-sources-head">
|
||||
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3>
|
||||
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> {{ 'notebookDetail.sources' | translate }}</h3>
|
||||
<label class="btn-upload" [class.disabled]="uploading">
|
||||
<lucide-icon [img]="Upload" [size]="13"></lucide-icon>
|
||||
{{ uploading ? 'Indexation…' : 'Ajouter un PDF' }}
|
||||
{{ (uploading ? 'notebookDetail.indexing' : 'notebookDetail.addPdf') | translate }}
|
||||
<input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading">
|
||||
</label>
|
||||
</div>
|
||||
@@ -23,7 +23,7 @@
|
||||
@if (readySourceCount > 1) {
|
||||
<p class="nbd-sources-hint"
|
||||
[class.partial]="selectedSourceIds.size < readySourceCount">
|
||||
{{ selectedSourceIds.size }}/{{ readySourceCount }} source(s) utilisée(s) par le chat et l'analyse approfondie
|
||||
{{ 'notebookDetail.sourcesUsed' | translate:{ selected: selectedSourceIds.size, total: readySourceCount } }}
|
||||
</p>
|
||||
}
|
||||
@for (s of sources; track s) {
|
||||
@@ -34,9 +34,9 @@
|
||||
class="nbd-source-check"
|
||||
[checked]="isSelected(s)"
|
||||
(change)="toggleSource(s)"
|
||||
[title]="isSelected(s)
|
||||
? 'Source utilisée par le chat — décocher pour l\'exclure'
|
||||
: 'Source ignorée par le chat — cocher pour l\'inclure'" />
|
||||
[title]="(isSelected(s)
|
||||
? 'notebookDetail.sourceIncludedTitle'
|
||||
: 'notebookDetail.sourceExcludedTitle') | translate" />
|
||||
} @else {
|
||||
<lucide-icon
|
||||
[img]="s.status === 'FAILED' ? AlertCircle : Loader"
|
||||
@@ -48,24 +48,24 @@
|
||||
<div class="nbd-source-name">{{ s.filename }}</div>
|
||||
<div class="nbd-source-meta">
|
||||
@if (s.status === 'READY') {
|
||||
<span>{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
|
||||
<span>{{ 'notebookDetail.sourceMeta' | translate:{ pages: s.pageCount, chunks: s.chunkCount } }}</span>
|
||||
}
|
||||
@if (s.status === 'INDEXING') {
|
||||
<span>indexation en cours…</span>
|
||||
<span>{{ 'notebookDetail.indexingInProgress' | translate }}</span>
|
||||
}
|
||||
@if (s.status === 'FAILED') {
|
||||
<span>échec de l'indexation</span>
|
||||
<span>{{ 'notebookDetail.indexingFailed' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
|
||||
<button class="nbd-source-del" (click)="removeSource(s)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (sources.length === 0 && !uploading) {
|
||||
<p class="nbd-empty">
|
||||
Ajoute un PDF source pour commencer à discuter avec.
|
||||
{{ 'notebookDetail.sourcesEmpty' | translate }}
|
||||
</p>
|
||||
}
|
||||
</aside>
|
||||
@@ -76,32 +76,32 @@
|
||||
@if (viewingArchive) {
|
||||
<span class="nbd-archive-banner">
|
||||
<lucide-icon [img]="History" [size]="13"></lucide-icon>
|
||||
Archive du {{ archiveLabel(viewingArchive) }} — lecture seule
|
||||
{{ 'notebookDetail.archiveBanner' | translate:{ label: archiveLabel(viewingArchive) } }}
|
||||
</span>
|
||||
<button type="button" class="nbd-chat-btn" (click)="closeArchive()">
|
||||
<lucide-icon [img]="X" [size]="13"></lucide-icon>
|
||||
Revenir au chat
|
||||
{{ 'notebookDetail.backToChat' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
@if (referencedArchiveIds.size > 0) {
|
||||
<span class="nbd-ref-badge"
|
||||
title="Le contenu de ces archives est injecté comme référence dans chaque question">
|
||||
[title]="'notebookDetail.refBadgeTitle' | translate">
|
||||
<lucide-icon [img]="History" [size]="12"></lucide-icon>
|
||||
{{ referencedArchiveIds.size }} archive(s) en référence
|
||||
{{ 'notebookDetail.refBadge' | translate:{ n: referencedArchiveIds.size } }}
|
||||
</span>
|
||||
}
|
||||
<span class="nbd-chat-head-spacer"></span>
|
||||
<button type="button" class="nbd-chat-btn" (click)="toggleArchives()"
|
||||
[class.active]="archivesOpen"
|
||||
title="Conversations archivées (lecture seule + référence)">
|
||||
[title]="'notebookDetail.archivesBtnTitle' | translate">
|
||||
<lucide-icon [img]="History" [size]="13"></lucide-icon>
|
||||
Archives
|
||||
{{ 'notebookDetail.archives' | translate }}
|
||||
</button>
|
||||
<button type="button" class="nbd-chat-btn nbd-chat-btn--danger" (click)="clearChat()"
|
||||
[disabled]="sending || messages.length === 0"
|
||||
title="Vider la conversation (le fil actuel est archivé, pas supprimé)">
|
||||
[title]="'notebookDetail.clearBtnTitle' | translate">
|
||||
<lucide-icon [img]="Eraser" [size]="13"></lucide-icon>
|
||||
Vider
|
||||
{{ 'notebookDetail.clear' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -109,12 +109,9 @@
|
||||
@if (archivesOpen && !viewingArchive) {
|
||||
<div class="nbd-archives">
|
||||
@if (archives.length === 0) {
|
||||
<p class="nbd-empty">Aucune conversation archivée pour l'instant — « Vider » archive le fil courant ici.</p>
|
||||
<p class="nbd-empty">{{ 'notebookDetail.archivesEmpty' | translate }}</p>
|
||||
} @else {
|
||||
<p class="nbd-archives-help">
|
||||
Cochez une archive pour l'utiliser comme <strong>référence</strong> dans le chat ;
|
||||
cliquez sur son nom pour la relire.
|
||||
</p>
|
||||
<p class="nbd-archives-help" [innerHTML]="'notebookDetail.archivesHelp' | translate"></p>
|
||||
}
|
||||
@for (a of archives; track a.archivedAt) {
|
||||
<div class="nbd-archive-row" [class.referenced]="isReferenced(a)">
|
||||
@@ -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" />
|
||||
<button type="button" class="nbd-archive-item" (click)="openArchive(a)">
|
||||
<lucide-icon [img]="History" [size]="13"></lucide-icon>
|
||||
{{ archiveLabel(a) }}
|
||||
@@ -143,7 +140,7 @@
|
||||
@if (m.role === 'assistant') {
|
||||
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
|
||||
}
|
||||
{{ m.role === 'user' ? 'Vous' : 'IA' }}
|
||||
{{ (m.role === 'user' ? 'notebookDetail.roleUser' : 'notebookDetail.roleAi') | translate }}
|
||||
</div>
|
||||
@if (m.role === 'assistant') {
|
||||
<div class="nbd-msg-content md" [innerHTML]="m.content | markdown"></div>
|
||||
@@ -155,9 +152,9 @@
|
||||
} @else {
|
||||
@if (messages.length === 0) {
|
||||
<p class="nbd-empty">
|
||||
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
|
||||
{{ 'notebookDetail.chatEmpty' | translate }}
|
||||
@if (!hasReadySource()) {
|
||||
<span><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
|
||||
<span [innerHTML]="'notebookDetail.chatEmptyNoSource' | translate"></span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@@ -167,14 +164,14 @@
|
||||
@if (m.role === 'assistant') {
|
||||
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
|
||||
}
|
||||
{{ m.role === 'user' ? 'Vous' : 'IA' }}
|
||||
{{ (m.role === 'user' ? 'notebookDetail.roleUser' : 'notebookDetail.roleAi') | translate }}
|
||||
</div>
|
||||
@if (m.role === 'assistant') {
|
||||
@if (parsedOf(m); as p) {
|
||||
@if (sending && deepProgress && !p.text) {
|
||||
<div class="nbd-deep-progress">
|
||||
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
|
||||
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
|
||||
{{ 'notebookDetail.deepProgress' | translate:{ current: deepProgress.current, total: deepProgress.total } }}
|
||||
</div>
|
||||
}
|
||||
@if (p.text) {
|
||||
@@ -192,7 +189,7 @@
|
||||
</app-notebook-action-card>
|
||||
}
|
||||
@if (sourcesLabel(m); as label) {
|
||||
<div class="nbd-msg-sources" title="Pages de la source utilisées pour ancrer cette réponse">
|
||||
<div class="nbd-msg-sources" [title]="'notebookDetail.sourcesPagesTitle' | translate">
|
||||
📖 {{ label }}
|
||||
</div>
|
||||
}
|
||||
@@ -207,14 +204,14 @@
|
||||
@if (!viewingArchive) {
|
||||
<div class="nbd-input">
|
||||
<textarea [(ngModel)]="draft" rows="2"
|
||||
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
|
||||
[placeholder]="'notebookDetail.draftPlaceholder' | translate"
|
||||
(keydown.enter)="$event.preventDefault(); send()"></textarea>
|
||||
<button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()"
|
||||
title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »">
|
||||
[title]="'notebookDetail.deepBtnTitle' | translate">
|
||||
<lucide-icon [img]="Layers" [size]="15"></lucide-icon>
|
||||
</button>
|
||||
<button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()"
|
||||
title="Réponse rapide (recherche ciblée dans le document)">
|
||||
[title]="'notebookDetail.sendBtnTitle' | translate">
|
||||
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
<div class="nbl-page">
|
||||
<div class="nbl-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="nbl-header">
|
||||
<h1><lucide-icon [img]="BookOpen" [size]="22"></lucide-icon> Ateliers d'adaptation</h1>
|
||||
<h1><lucide-icon [img]="BookOpen" [size]="22"></lucide-icon> {{ 'notebookList.title' | translate }}</h1>
|
||||
<p class="nbl-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.
|
||||
{{ 'notebookList.hint' | translate }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="nbl-create">
|
||||
<input type="text" [(ngModel)]="newName" placeholder="Nom de l'atelier (ex: Adaptation du module X)"
|
||||
<input type="text" [(ngModel)]="newName" [placeholder]="'notebookList.namePlaceholder' | translate"
|
||||
(keyup.enter)="create()">
|
||||
<button class="btn-create" (click)="create()" [disabled]="creating">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ creating ? 'Création…' : 'Nouvel atelier' }}
|
||||
{{ (creating ? 'common.creating' : 'notebookList.newNotebook') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nbl-list">
|
||||
@if (notebooks.length === 0) {
|
||||
<p class="empty">Aucun atelier pour l'instant.</p>
|
||||
<p class="empty">{{ 'notebookList.empty' | translate }}</p>
|
||||
}
|
||||
@for (nb of notebooks; track nb) {
|
||||
<button class="nbl-item" (click)="open(nb)">
|
||||
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
|
||||
<span class="nbl-item-name">{{ nb.name }}</span>
|
||||
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
|
||||
<span class="nbl-del" (click)="remove(nb, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<div class="ne-header">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour à la campagne
|
||||
{{ 'npcEdit.backToCampaign' | translate }}
|
||||
</button>
|
||||
<div class="header-row">
|
||||
<h1>
|
||||
<lucide-icon [img]="Drama" [size]="22"></lucide-icon>
|
||||
{{ npcId ? 'Éditer le PNJ' : 'Nouveau PNJ' }}
|
||||
{{ (npcId ? 'npcEdit.titleEdit' : 'npcEdit.titleNew') | translate }}
|
||||
</h1>
|
||||
@if (npcId) {
|
||||
<button
|
||||
@@ -16,9 +16,9 @@
|
||||
class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ">
|
||||
[title]="'npcEdit.aiAssistantTitle' | translate">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
{{ 'npcEdit.aiAssistant' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -27,25 +27,25 @@
|
||||
<div class="ne-form">
|
||||
|
||||
<div class="field">
|
||||
<label for="npc-name">Nom du PNJ *</label>
|
||||
<label for="npc-name">{{ 'npcEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="npc-name"
|
||||
type="text"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
placeholder="Ex: Borin le forgeron, Dame Elara, Kael l'aubergiste..."
|
||||
[placeholder]="'npcEdit.namePlaceholder' | translate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="npc-folder">Dossier</label>
|
||||
<label for="npc-folder">{{ 'npcEdit.folder' | translate }}</label>
|
||||
<input
|
||||
id="npc-folder"
|
||||
type="text"
|
||||
[(ngModel)]="folder"
|
||||
name="folder"
|
||||
list="npc-folders"
|
||||
placeholder="Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)"
|
||||
[placeholder]="'npcEdit.folderPlaceholder' | translate"
|
||||
/>
|
||||
<datalist id="npc-folders">
|
||||
@for (f of existingFolders; track f) {
|
||||
@@ -56,20 +56,20 @@
|
||||
|
||||
<div class="field-row image-row">
|
||||
<div class="field portrait-field">
|
||||
<label>Portrait</label>
|
||||
<label>{{ 'npcEdit.portrait' | translate }}</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="portraitImageId"
|
||||
aspectRatio="1 / 1"
|
||||
hint="Carre conseille (400×400)."
|
||||
[hint]="'npcEdit.portraitHint' | translate"
|
||||
(imageIdChange)="portraitImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
<div class="field header-field">
|
||||
<label>Bandeau / Header</label>
|
||||
<label>{{ 'npcEdit.header' | translate }}</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="headerImageId"
|
||||
aspectRatio="3 / 1"
|
||||
hint="Format paysage conseille (1200×400)."
|
||||
[hint]="'npcEdit.headerHint' | translate"
|
||||
(imageIdChange)="headerImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
@@ -90,23 +90,23 @@
|
||||
<!-- Pages de Lore liées (uniquement si la campagne est rattachée à un Lore) -->
|
||||
@if (loreId) {
|
||||
<div class="field">
|
||||
<label>Pages de Lore liées</label>
|
||||
<label>{{ 'npcEdit.relatedPages' | translate }}</label>
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="lorePages"
|
||||
[loreId]="loreId"
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<p class="hint">Reliez ce PNJ aux pages de l'univers (sa ville, sa faction, sa région…). Ces liens apparaissent dans le graphe du Lore.</p>
|
||||
<p class="hint">{{ 'npcEdit.relatedPagesHint' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
{{ npcId ? 'Enregistrer' : 'Créer' }}
|
||||
{{ (npcId ? 'common.save' : 'common.create') | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">{{ 'common.cancel' | translate }}</button>
|
||||
<span class="spacer"></span>
|
||||
@if (npcId) {
|
||||
<button
|
||||
@@ -114,7 +114,7 @@
|
||||
class="btn-danger"
|
||||
(click)="deleteNpc()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -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">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
<div class="sbl-page">
|
||||
<div class="sbl-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-create" (click)="create()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouveau PNJ
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> {{ 'npcList.create' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="sbl-header">
|
||||
<h1><lucide-icon [img]="Drama" [size]="22"></lucide-icon> PNJ</h1>
|
||||
<h1><lucide-icon [img]="Drama" [size]="22"></lucide-icon> {{ 'npcList.title' | translate }}</h1>
|
||||
<p class="sbl-hint">
|
||||
Tous les personnages non-joueurs de la campagne, classés par dossier.
|
||||
Le champ « Dossier » de chaque fiche pilote le classement.
|
||||
{{ 'npcList.hint' | translate }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="sbl-list">
|
||||
@if (total === 0) {
|
||||
<p class="empty">Aucun PNJ pour l'instant.</p>
|
||||
<p class="empty">{{ 'npcList.empty' | translate }}</p>
|
||||
}
|
||||
@for (g of groups; track g.folder) {
|
||||
@if (g.folder) {
|
||||
@@ -29,13 +28,13 @@
|
||||
<span class="sbl-folder-count">{{ g.npcs.length }}</span>
|
||||
</h2>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">Non classés</h2>
|
||||
<h2 class="sbl-folder sbl-folder--none">{{ 'npcList.unclassified' | translate }}</h2>
|
||||
}
|
||||
@for (n of g.npcs; track n.id) {
|
||||
<button class="sbl-item" (click)="open(n)">
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ n.name }}</span>
|
||||
<span class="sbl-del" (click)="remove(n, $event)" title="Supprimer">
|
||||
<span class="sbl-del" (click)="remove(n, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
<div class="nv-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour
|
||||
{{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
@if (npcId) {
|
||||
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
{{ 'npcView.aiAssistant' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button class="btn-edit" (click)="edit()">
|
||||
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
|
||||
Editer
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<section class="nv-lore-links">
|
||||
<h2 class="nv-lore-links-title">
|
||||
<lucide-icon [img]="Link2" [size]="14"></lucide-icon>
|
||||
Pages de Lore liées
|
||||
{{ 'npcView.relatedPages' | translate }}
|
||||
</h2>
|
||||
<div class="nv-lore-chips">
|
||||
@for (pageId of npc!.relatedPageIds; track pageId) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles, Link2 } 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';
|
||||
@@ -20,7 +21,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-npc-view',
|
||||
imports: [LucideAngularModule, RouterLink, PersonaViewComponent, AiChatDrawerComponent],
|
||||
imports: [LucideAngularModule, TranslatePipe, RouterLink, PersonaViewComponent, AiChatDrawerComponent],
|
||||
templateUrl: './npc-view.component.html',
|
||||
styleUrls: ['./npc-view.component.scss']
|
||||
})
|
||||
@@ -52,7 +53,8 @@ export class NpcViewComponent implements OnInit, OnDestroy {
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService,
|
||||
private pageService: PageService,
|
||||
private campaignSidebar: CampaignSidebarService
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -103,7 +105,7 @@ export class NpcViewComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Titre d'une page de lore liée (pour les chips). */
|
||||
titleOfPage(pageId: string): string {
|
||||
return this.lorePagesById.get(pageId)?.title ?? '(page supprimée)';
|
||||
return this.lorePagesById.get(pageId)?.title ?? this.translate.instant('npcView.deletedPage');
|
||||
}
|
||||
|
||||
edit(): void {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<header class="page-header">
|
||||
<button type="button" class="btn-secondary" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour
|
||||
{{ 'common.back' | translate }}
|
||||
</button>
|
||||
<div class="header-info">
|
||||
<h1>{{ playthrough.name }}</h1>
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-danger" (click)="delete()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -25,32 +25,32 @@
|
||||
[disabled]="startingSession"
|
||||
(click)="startSession()">
|
||||
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
|
||||
Lancer une session
|
||||
{{ 'playthroughDetail.startSession' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (activeOnThis) {
|
||||
<button type="button" class="btn-primary big"
|
||||
(click)="openSession(activeOnThis)">
|
||||
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
|
||||
Reprendre la session en cours
|
||||
{{ 'playthroughDetail.resumeSession' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn-secondary" (click)="openFlags()">
|
||||
<lucide-icon [img]="Flag" [size]="14"></lucide-icon>
|
||||
Faits de la partie
|
||||
{{ 'playthroughDetail.facts' | translate }}
|
||||
</button>
|
||||
</section>
|
||||
<!-- PJ -->
|
||||
<section class="block">
|
||||
<div class="block-header">
|
||||
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2>
|
||||
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> {{ 'playthroughDetail.charactersTitle' | translate }}</h2>
|
||||
<button type="button" class="btn-add" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PJ
|
||||
{{ 'playthroughDetail.newCharacter' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (characters.length === 0) {
|
||||
<p class="empty">Aucun PJ pour cette partie.</p>
|
||||
<p class="empty">{{ 'playthroughDetail.noCharacters' | translate }}</p>
|
||||
}
|
||||
@if (characters.length > 0) {
|
||||
<ul class="character-list">
|
||||
@@ -64,16 +64,16 @@
|
||||
</section>
|
||||
<!-- Sessions -->
|
||||
<section class="block">
|
||||
<h2>Sessions</h2>
|
||||
<h2>{{ 'playthroughDetail.sessionsTitle' | translate }}</h2>
|
||||
@if (sessions.length === 0) {
|
||||
<p class="empty">Aucune session encore. Lancez la première !</p>
|
||||
<p class="empty">{{ 'playthroughDetail.noSessions' | translate }}</p>
|
||||
}
|
||||
@if (sessions.length > 0) {
|
||||
<ul class="session-list">
|
||||
@for (s of sessions; track s) {
|
||||
<li (click)="openSession(s)">
|
||||
<span class="session-name">{{ s.name }}</span>
|
||||
<span class="session-status" [class.active]="s.active">{{ s.active ? 'En cours' : 'Terminée' }}</span>
|
||||
<span class="session-status" [class.active]="s.active">{{ (s.active ? 'playthroughDetail.statusActive' : 'playthroughDetail.statusEnded') | translate }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
<header class="page-header">
|
||||
<button type="button" class="btn-secondary" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour
|
||||
{{ 'common.back' | translate }}
|
||||
</button>
|
||||
<div>
|
||||
<h1>Faits — {{ playthrough.name }}</h1>
|
||||
<h1>{{ 'playthroughFlagsPage.title' | translate:{ name: playthrough.name } }}</h1>
|
||||
<p class="subtitle">
|
||||
Faits booléens propres à cette partie. Toggle-les en jeu pour débloquer
|
||||
les quêtes qui en dépendent.
|
||||
{{ 'playthroughFlagsPage.subtitle' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,52 +2,52 @@
|
||||
<div class="rte-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour
|
||||
{{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-save" (click)="save()" [disabled]="saving">
|
||||
<lucide-icon [img]="Save" [size]="14"></lucide-icon>
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
{{ (saving ? 'common.saving' : 'common.save') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1>{{ tableId ? 'Éditer la table' : 'Nouvelle table aléatoire' }}</h1>
|
||||
<h1>{{ (tableId ? 'randomTableEdit.titleEdit' : 'randomTableEdit.titleNew') | translate }}</h1>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="error">{{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<div class="form-row">
|
||||
<label for="rt-name">Nom *</label>
|
||||
<input id="rt-name" type="text" [(ngModel)]="name" placeholder="Ex: Rencontres en forêt">
|
||||
<label for="rt-name">{{ 'randomTableEdit.nameLabel' | translate }}</label>
|
||||
<input id="rt-name" type="text" [(ngModel)]="name" [placeholder]="'randomTableEdit.namePlaceholder' | translate">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="rt-desc">Description</label>
|
||||
<textarea id="rt-desc" rows="2" [(ngModel)]="description" placeholder="À quoi sert cette table ?"></textarea>
|
||||
<label for="rt-desc">{{ 'common.description' | translate }}</label>
|
||||
<textarea id="rt-desc" rows="2" [(ngModel)]="description" [placeholder]="'randomTableEdit.descPlaceholder' | translate"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="rt-formula">Formule du dé *</label>
|
||||
<label for="rt-formula">{{ 'randomTableEdit.formulaLabel' | translate }}</label>
|
||||
<input id="rt-formula" type="text" [(ngModel)]="diceFormula" placeholder="1d20, 2d6, d100…"
|
||||
[class.invalid]="diceFormula && !formulaValid">
|
||||
<small class="hint" [class.bad]="diceFormula && !formulaValid">
|
||||
{{ formulaValid ? 'Formule valide' : 'Format attendu : NdM (ex. 1d20, 2d6, d100)' }}
|
||||
{{ (formulaValid ? 'randomTableEdit.formulaValid' : 'randomTableEdit.formulaExpected') | translate }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Génération IA -->
|
||||
<div class="ai-box">
|
||||
<div class="ai-title">
|
||||
<lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA
|
||||
<lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> {{ 'randomTableEdit.aiTitle' | translate }}
|
||||
</div>
|
||||
<p class="ai-hint">Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
|
||||
<p class="ai-hint">{{ 'randomTableEdit.aiHint' | translate }}</p>
|
||||
<textarea rows="2" [(ngModel)]="aiPrompt"
|
||||
placeholder="Ex: rencontres aléatoires dans une forêt hantée, ton sombre"></textarea>
|
||||
[placeholder]="'randomTableEdit.aiPromptPlaceholder' | translate"></textarea>
|
||||
<div class="ai-actions">
|
||||
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
{{ generating ? 'Génération…' : 'Générer (' + diceFormula + ')' }}
|
||||
{{ generating ? ('common.generating' | translate) : ('randomTableEdit.generateWith' | translate:{ formula: diceFormula }) }}
|
||||
</button>
|
||||
@if (aiError) {
|
||||
<span class="ai-error">{{ aiError }}</span>
|
||||
@@ -56,22 +56,22 @@
|
||||
</div>
|
||||
|
||||
<div class="entries-head">
|
||||
<h2>Entrées</h2>
|
||||
<h2>{{ 'randomTableEdit.entriesTitle' | translate }}</h2>
|
||||
<div class="entries-actions">
|
||||
<button class="btn-mini" (click)="autoRanges()" title="Répartir les plages sur la formule">
|
||||
<lucide-icon [img]="Wand2" [size]="13"></lucide-icon> Auto-plages
|
||||
<button class="btn-mini" (click)="autoRanges()" [title]="'randomTableEdit.autoRangesTitle' | translate">
|
||||
<lucide-icon [img]="Wand2" [size]="13"></lucide-icon> {{ 'randomTableEdit.autoRanges' | translate }}
|
||||
</button>
|
||||
<button class="btn-mini" (click)="addEntry()">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> Ajouter
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> {{ 'common.add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry-row head">
|
||||
<span class="c-range">Min</span>
|
||||
<span class="c-range">Max</span>
|
||||
<span class="c-label">Résultat</span>
|
||||
<span class="c-detail">Détail</span>
|
||||
<span class="c-range">{{ 'randomTableEdit.colMin' | translate }}</span>
|
||||
<span class="c-range">{{ 'randomTableEdit.colMax' | translate }}</span>
|
||||
<span class="c-label">{{ 'randomTableEdit.colResult' | translate }}</span>
|
||||
<span class="c-detail">{{ 'randomTableEdit.colDetail' | translate }}</span>
|
||||
<span class="c-del"></span>
|
||||
</div>
|
||||
|
||||
@@ -79,15 +79,15 @@
|
||||
<div class="entry-row">
|
||||
<input class="c-range" type="number" [(ngModel)]="e.minRoll">
|
||||
<input class="c-range" type="number" [(ngModel)]="e.maxRoll">
|
||||
<input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat">
|
||||
<input class="c-detail" type="text" [(ngModel)]="e.detail" placeholder="Détail (optionnel)">
|
||||
<button class="c-del btn-del" (click)="removeEntry(i)" title="Supprimer">
|
||||
<input class="c-label" type="text" [(ngModel)]="e.label" [placeholder]="'randomTableEdit.colResult' | translate">
|
||||
<input class="c-detail" type="text" [(ngModel)]="e.detail" [placeholder]="'randomTableEdit.detailPlaceholder' | translate">
|
||||
<button class="c-del btn-del" (click)="removeEntry(i)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (entries.length === 0) {
|
||||
<p class="empty-hint">Aucune entrée — clique « Ajouter ».</p>
|
||||
<p class="empty-hint">{{ 'randomTableEdit.emptyHint' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<div class="rt-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour
|
||||
{{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-edit" (click)="edit()">
|
||||
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
|
||||
Éditer
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<header class="rt-header">
|
||||
@@ -16,13 +16,13 @@
|
||||
@if (table.description) {
|
||||
<p class="rt-desc">{{ table.description }}</p>
|
||||
}
|
||||
<span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span>
|
||||
<span class="rt-formula">{{ 'randomTableView.die' | translate }} <code>{{ table.diceFormula }}</code></span>
|
||||
</header>
|
||||
<!-- Zone de jet -->
|
||||
<section class="rt-roll">
|
||||
<button class="btn-roll" (click)="roll()">
|
||||
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
|
||||
Lancer {{ table.diceFormula }}
|
||||
{{ 'randomTableView.roll' | translate:{ formula: table.diceFormula } }}
|
||||
</button>
|
||||
@if (lastRoll) {
|
||||
<div class="rt-result">
|
||||
@@ -35,7 +35,7 @@
|
||||
<span class="rt-matched">{{ matched.label }}</span>
|
||||
}
|
||||
@if (!matched) {
|
||||
<span class="rt-nomatch">Aucune entrée pour ce résultat</span>
|
||||
<span class="rt-nomatch">{{ 'randomTableView.noMatch' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -47,13 +47,13 @@
|
||||
<section class="rt-entries">
|
||||
@if (table.entries.length === 0) {
|
||||
<div class="rt-empty">
|
||||
Aucune entrée — édite la table pour en ajouter.
|
||||
{{ 'randomTableView.empty' | translate }}
|
||||
</div>
|
||||
}
|
||||
@if (table.entries.length > 0) {
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th class="col-range">Jet</th><th>Résultat</th></tr>
|
||||
<tr><th class="col-range">{{ 'randomTableView.colRoll' | translate }}</th><th>{{ 'randomTableView.colResult' | translate }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (e of table.entries; track e) {
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
<div class="scene-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Créer une nouvelle scène</h1>
|
||||
<h1>{{ 'sceneCreate.title' | translate }}</h1>
|
||||
@if (chapterName) {
|
||||
<p class="chapter-ref">Chapitre : {{ chapterName }}</p>
|
||||
<p class="chapter-ref">{{ 'sceneCreate.chapterRef' | translate:{ name: chapterName } }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
|
||||
|
||||
<div class="field">
|
||||
<label for="scene-create-name">Nom de la scène *</label>
|
||||
<label for="scene-create-name">{{ 'sceneCreate.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="scene-create-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Arrivée au village"
|
||||
[placeholder]="'sceneCreate.namePlaceholder' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="scene-create-description">Description</label>
|
||||
<label for="scene-create-description">{{ 'sceneCreate.descriptionLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="scene-create-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez la scène, les événements clés, les PNJ présents..."
|
||||
[placeholder]="'sceneCreate.descriptionPlaceholder' | translate"
|
||||
rows="6">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<label>{{ 'sceneCreate.iconLabel' | translate }}</label>
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
Créer la scène
|
||||
{{ 'sceneCreate.createButton' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ scene?.name || 'Scène' }}</h1>
|
||||
<p class="subtitle">Scène</p>
|
||||
<h1>{{ scene?.name || ('sceneEdit.defaultTitle' | translate) }}</h1>
|
||||
<p class="subtitle">{{ 'sceneEdit.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA pour dialoguer autour de cette scène">
|
||||
[title]="'sceneEdit.aiAssistantTitle' | translate">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
{{ 'sceneEdit.aiAssistant' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
|
||||
Sauvegarder
|
||||
{{ 'common.save' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,119 +28,118 @@
|
||||
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>Illustrations</label>
|
||||
<label>{{ 'sceneEdit.illustrationsLabel' | translate }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">Portraits des PNJ, ambiance visuelle, scenes evocatrices...</small>
|
||||
<small class="field-hint">{{ 'sceneEdit.illustrationsHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Cartes & plans (galerie editable, rendu maps) -->
|
||||
<div class="field">
|
||||
<label>Cartes & plans</label>
|
||||
<label>{{ 'sceneEdit.mapsLabel' | translate }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="mapImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'MAPS'"
|
||||
(imageIdsChange)="mapImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">Plans du lieu, cartes tactiques, schemas utilisables a la table.</small>
|
||||
<small class="field-hint">{{ 'sceneEdit.mapsHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="scene-edit-name">Titre de la scène *</label>
|
||||
<label for="scene-edit-name">{{ 'sceneEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="scene-edit-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Arrivée au village"
|
||||
[placeholder]="'sceneEdit.namePlaceholder' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="scene-edit-description">Description courte *</label>
|
||||
<label for="scene-edit-description">{{ 'sceneEdit.descriptionLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="scene-edit-description"
|
||||
formControlName="description"
|
||||
placeholder="Résumé en une ou deux phrases de ce qui se passe..."
|
||||
[placeholder]="'sceneEdit.descriptionPlaceholder' | translate"
|
||||
rows="3">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<label>{{ 'sceneEdit.iconLabel' | translate }}</label>
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<!-- Section : Contexte et ambiance -->
|
||||
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
|
||||
<app-expandable-section [title]="'sceneEdit.contextSectionTitle' | translate" icon="📍" [initiallyOpen]="true">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="scene-edit-location">Lieu</label>
|
||||
<input id="scene-edit-location" type="text" formControlName="location" placeholder="Ex: Taverne du Dragon d'Or" />
|
||||
<label for="scene-edit-location">{{ 'sceneEdit.locationLabel' | translate }}</label>
|
||||
<input id="scene-edit-location" type="text" formControlName="location" [placeholder]="'sceneEdit.locationPlaceholder' | translate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="scene-edit-timing">Moment</label>
|
||||
<input id="scene-edit-timing" type="text" formControlName="timing" placeholder="Ex: Soir, à la tombée de la nuit" />
|
||||
<label for="scene-edit-timing">{{ 'sceneEdit.timingLabel' | translate }}</label>
|
||||
<input id="scene-edit-timing" type="text" formControlName="timing" [placeholder]="'sceneEdit.timingPlaceholder' | translate" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="scene-edit-atmosphere">Ambiance et atmosphère</label>
|
||||
<label for="scene-edit-atmosphere">{{ 'sceneEdit.atmosphereLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="scene-edit-atmosphere"
|
||||
formControlName="atmosphere"
|
||||
placeholder="Décrivez l'ambiance générale de la scène (sons, odeurs, lumière, émotions...)"
|
||||
[placeholder]="'sceneEdit.atmospherePlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Narration pour les joueurs -->
|
||||
<app-expandable-section title="Narration pour les joueurs" icon="📖">
|
||||
<app-expandable-section [title]="'sceneEdit.narrationSectionTitle' | translate" icon="📖">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="playerNarration"
|
||||
placeholder="Le texte que vous lirez aux joueurs pour planter le décor de cette scène..."
|
||||
[placeholder]="'sceneEdit.narrationPlaceholder' | translate"
|
||||
rows="6">
|
||||
</textarea>
|
||||
<small class="field-hint">Ce texte peut être lu directement à vos joueurs.</small>
|
||||
<small class="field-hint">{{ 'sceneEdit.narrationHint' | translate }}</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Notes et secrets du MJ (privé) -->
|
||||
<app-expandable-section title="Notes et secrets du MJ" icon="🔒" variant="private">
|
||||
<app-expandable-section [title]="'sceneEdit.gmNotesSectionTitle' | translate" icon="🔒" variant="private">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="gmSecretNotes"
|
||||
placeholder="Informations cachées, indices, éléments secrets que les joueurs ne doivent pas connaître..."
|
||||
[placeholder]="'sceneEdit.gmNotesPlaceholder' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
<small class="field-hint">Ces notes sont privées et visibles uniquement par le MJ.</small>
|
||||
<small class="field-hint">{{ 'sceneEdit.gmNotesHint' | translate }}</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Choix et conséquences -->
|
||||
<app-expandable-section title="Choix et conséquences" icon="🔀">
|
||||
<app-expandable-section [title]="'sceneEdit.choicesSectionTitle' | translate" icon="🔀">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="choicesConsequences"
|
||||
placeholder="Décrivez les différentes options qui s'offrent aux joueurs et leurs conséquences..."
|
||||
[placeholder]="'sceneEdit.choicesPlaceholder' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Branches narratives (graphe intra-chapitre) -->
|
||||
<app-expandable-section title="Branches narratives" icon="🌿">
|
||||
<app-expandable-section [title]="'sceneEdit.branchesSectionTitle' | translate" icon="🌿">
|
||||
@if (siblingScenes.length === 0) {
|
||||
<div class="branches-hint">
|
||||
<small class="field-hint">
|
||||
💡 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 }}
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
@@ -150,18 +149,18 @@
|
||||
@for (branch of branches; track $index; let i = $index) {
|
||||
<div class="branch-item">
|
||||
<div class="field">
|
||||
<label>Libellé du choix</label>
|
||||
<label>{{ 'sceneEdit.branchLabelLabel' | translate }}</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="branch.label"
|
||||
(input)="updateBranchLabel(i, $any($event.target).value)"
|
||||
placeholder="Ex: Si les joueurs attaquent le garde" />
|
||||
[placeholder]="'sceneEdit.branchLabelPlaceholder' | translate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Scène de destination *</label>
|
||||
<label>{{ 'sceneEdit.branchTargetLabel' | translate }}</label>
|
||||
<select
|
||||
(change)="updateBranchTarget(i, $any($event.target).value)">
|
||||
<option value="" [selected]="!branch.targetSceneId">— Choisir une scène —</option>
|
||||
<option value="" [selected]="!branch.targetSceneId">{{ 'sceneEdit.branchTargetPlaceholder' | translate }}</option>
|
||||
@for (s of siblingScenes; track s) {
|
||||
<option
|
||||
[value]="s.id"
|
||||
@@ -170,39 +169,38 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Condition MJ (optionnel)</label>
|
||||
<label>{{ 'sceneEdit.branchConditionLabel' | translate }}</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="branch.condition || ''"
|
||||
(input)="updateBranchCondition(i, $any($event.target).value)"
|
||||
placeholder="Ex: Jet de Persuasion DD 15 réussi" />
|
||||
[placeholder]="'sceneEdit.branchConditionPlaceholder' | translate" />
|
||||
</div>
|
||||
<button type="button" class="btn-remove-branch" (click)="removeBranch(i)"
|
||||
title="Supprimer cette branche">
|
||||
[title]="'sceneEdit.branchRemoveTitle' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Retirer
|
||||
{{ 'sceneEdit.branchRemove' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="btn-add-branch" (click)="addBranch()">
|
||||
+ Ajouter une branche
|
||||
{{ 'sceneEdit.branchAdd' | translate }}
|
||||
</button>
|
||||
<small class="field-hint">
|
||||
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 }}
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Combat ou rencontre -->
|
||||
<app-expandable-section title="Combat ou rencontre" icon="⚔️">
|
||||
<app-expandable-section [title]="'sceneEdit.combatSectionTitle' | translate" icon="⚔️">
|
||||
<div class="field">
|
||||
<label for="scene-edit-combat-difficulty">Difficulté estimée</label>
|
||||
<input id="scene-edit-combat-difficulty" type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
|
||||
<label for="scene-edit-combat-difficulty">{{ 'sceneEdit.combatDifficultyLabel' | translate }}</label>
|
||||
<input id="scene-edit-combat-difficulty" type="text" formControlName="combatDifficulty" [placeholder]="'sceneEdit.combatDifficultyPlaceholder' | translate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ennemis du bestiaire</label>
|
||||
<label>{{ 'sceneEdit.bestiaryEnemiesLabel' | translate }}</label>
|
||||
<app-enemy-link-picker
|
||||
[value]="enemyIds"
|
||||
[availableEnemies]="availableEnemies"
|
||||
@@ -210,15 +208,15 @@
|
||||
(valueChange)="enemyIds = $event">
|
||||
</app-enemy-link-picker>
|
||||
<small class="field-hint">
|
||||
Référencez des fiches de votre bestiaire — cliquez sur un chip pour ouvrir la fiche.
|
||||
{{ 'sceneEdit.bestiaryEnemiesHint' | translate }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="scene-edit-enemies">Ennemis et créatures (texte libre)</label>
|
||||
<label for="scene-edit-enemies">{{ 'sceneEdit.enemiesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="scene-edit-enemies"
|
||||
formControlName="enemies"
|
||||
placeholder="Liste des ennemis présents dans cette scène..."
|
||||
[placeholder]="'sceneEdit.enemiesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
@@ -226,7 +224,7 @@
|
||||
|
||||
<!-- Section : Pages Lore associées (B2 cross-context) -->
|
||||
@if (loreId) {
|
||||
<app-expandable-section title="Pages Lore associées" icon="🔗">
|
||||
<app-expandable-section [title]="'sceneEdit.loreSectionTitle' | translate" icon="🔗">
|
||||
<div class="field">
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
@@ -235,7 +233,7 @@
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<small class="field-hint">
|
||||
Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page.
|
||||
{{ 'sceneEdit.loreHint' | translate }}
|
||||
</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
@@ -244,17 +242,15 @@
|
||||
@if (!loreId) {
|
||||
<div class="field">
|
||||
<small class="field-hint">
|
||||
💡 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 }}
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lieu explorable : pièces / donjon -->
|
||||
<app-expandable-section title="Lieu explorable (donjon, crypte…)" icon="🏰" [initiallyOpen]="rooms.length > 0">
|
||||
<app-expandable-section [title]="'sceneEdit.dungeonSectionTitle' | translate" icon="🏰" [initiallyOpen]="rooms.length > 0">
|
||||
<small class="field-hint">
|
||||
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 }}
|
||||
</small>
|
||||
<app-rooms-editor
|
||||
[rooms]="rooms"
|
||||
@@ -274,7 +270,7 @@
|
||||
entityType="scene"
|
||||
[entityId]="sceneId"
|
||||
[isOpen]="chatOpen"
|
||||
welcomeMessage="Je vois cette scène. Demande-moi d'enrichir son ambiance, sa narration ou ses choix."
|
||||
[welcomeMessage]="'sceneEdit.chatWelcome' | translate"
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
(close)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
}
|
||||
{{ scene.name }}
|
||||
</h1>
|
||||
<p class="view-subtitle">Scène</p>
|
||||
<p class="view-subtitle">{{ 'sceneView.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
<button type="button" class="btn-primary" (click)="editMode()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteScene()" title="Supprimer la scène">
|
||||
<button type="button" class="btn-danger" (click)="deleteScene()" [title]="'sceneView.deleteTitleAttr' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -30,17 +30,17 @@
|
||||
<!-- Cartes & plans -->
|
||||
@if ((scene.mapImageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> {{ 'sceneView.mapsSectionTitle' | translate }}</h2>
|
||||
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
|
||||
</section>
|
||||
}
|
||||
<!-- Description courte -->
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📝</span> Description</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📝</span> {{ 'sceneView.descriptionSectionTitle' | translate }}</h2>
|
||||
@if (scene.description?.trim()) {
|
||||
<p class="view-section-body">{{ scene.description }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'sceneView.empty' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<!-- Contexte et ambiance -->
|
||||
@@ -48,13 +48,13 @@
|
||||
<div class="view-row">
|
||||
@if (scene.location?.trim()) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📍</span> Lieu</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📍</span> {{ 'sceneView.locationSectionTitle' | translate }}</h2>
|
||||
<p class="view-section-body">{{ scene.location }}</p>
|
||||
</section>
|
||||
}
|
||||
@if (scene.timing?.trim()) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⏰</span> Moment</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⏰</span> {{ 'sceneView.timingSectionTitle' | translate }}</h2>
|
||||
<p class="view-section-body">{{ scene.timing }}</p>
|
||||
</section>
|
||||
}
|
||||
@@ -62,21 +62,21 @@
|
||||
}
|
||||
@if (scene.atmosphere?.trim()) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🌫️</span> Ambiance et atmosphère</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🌫️</span> {{ 'sceneView.atmosphereSectionTitle' | translate }}</h2>
|
||||
<p class="view-section-body">{{ scene.atmosphere }}</p>
|
||||
</section>
|
||||
}
|
||||
<!-- Narration pour les joueurs -->
|
||||
@if (scene.playerNarration?.trim()) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Narration pour les joueurs</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📖</span> {{ 'sceneView.narrationSectionTitle' | translate }}</h2>
|
||||
<p class="view-section-body">{{ scene.playerNarration }}</p>
|
||||
</section>
|
||||
}
|
||||
<!-- Choix et conséquences -->
|
||||
@if (scene.choicesConsequences?.trim()) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔀</span> Choix et conséquences</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔀</span> {{ 'sceneView.choicesSectionTitle' | translate }}</h2>
|
||||
<p class="view-section-body">{{ scene.choicesConsequences }}</p>
|
||||
</section>
|
||||
}
|
||||
@@ -84,13 +84,13 @@
|
||||
@if (scene.combatDifficulty?.trim() || scene.enemies?.trim() || linkedEnemies.length > 0) {
|
||||
@if (scene.combatDifficulty?.trim()) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> {{ 'sceneView.combatDifficultySectionTitle' | translate }}</h2>
|
||||
<p class="view-section-body">{{ scene.combatDifficulty }}</p>
|
||||
</section>
|
||||
}
|
||||
@if (scene.enemies?.trim() || linkedEnemies.length > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🐲</span> {{ 'sceneView.enemiesSectionTitle' | translate }}</h2>
|
||||
@if (linkedEnemies.length > 0) {
|
||||
<div class="view-chips">
|
||||
@for (e of linkedEnemies; track e.id) {
|
||||
@@ -112,7 +112,7 @@
|
||||
<section class="view-section view-section--private">
|
||||
<h2 class="view-section-title">
|
||||
<span class="view-section-icon">🔒</span>
|
||||
Notes et secrets du MJ
|
||||
{{ 'sceneView.gmNotesSectionTitle' | translate }}
|
||||
</h2>
|
||||
<p class="view-section-body">{{ scene.gmSecretNotes }}</p>
|
||||
</section>
|
||||
@@ -120,7 +120,7 @@
|
||||
<!-- Pages Lore liées -->
|
||||
@if (loreId && (scene.relatedPageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> {{ 'sceneView.loreSectionTitle' | translate }}</h2>
|
||||
<div class="view-chips">
|
||||
@for (relId of scene.relatedPageIds; track relId) {
|
||||
<a class="view-chip"
|
||||
@@ -134,7 +134,7 @@
|
||||
<!-- Lieu explorable : pièces (mode donjon) -->
|
||||
@if ((scene.rooms?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🏰</span> Pièces du lieu</h2>
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🏰</span> {{ 'sceneView.roomsSectionTitle' | translate }}</h2>
|
||||
<div class="rooms-readonly">
|
||||
@for (r of scene.rooms; track r; let i = $index) {
|
||||
<article class="room-readonly">
|
||||
@@ -143,7 +143,7 @@
|
||||
<span class="room-readonly-name">{{ r.name }}</span>
|
||||
@if (r.floor !== null && r.floor !== undefined) {
|
||||
<span class="room-readonly-floor">
|
||||
Étage {{ r.floor }}
|
||||
{{ 'sceneView.roomFloor' | translate:{ floor: r.floor } }}
|
||||
</span>
|
||||
}
|
||||
</header>
|
||||
@@ -153,7 +153,7 @@
|
||||
<div class="room-readonly-grid">
|
||||
@if (r.enemies?.trim() || roomLinkedEnemies(r).length > 0) {
|
||||
<div>
|
||||
<strong>⚔️ Ennemis</strong>
|
||||
<strong>{{ 'sceneView.roomEnemies' | translate }}</strong>
|
||||
@if (roomLinkedEnemies(r).length > 0) {
|
||||
<div class="view-chips">
|
||||
@for (e of roomLinkedEnemies(r); track e.id) {
|
||||
@@ -171,32 +171,32 @@
|
||||
}
|
||||
@if (r.loot?.trim()) {
|
||||
<div>
|
||||
<strong>💰 Loot</strong>
|
||||
<strong>{{ 'sceneView.roomLoot' | translate }}</strong>
|
||||
<p>{{ r.loot }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (r.traps?.trim()) {
|
||||
<div>
|
||||
<strong>⚠️ Pièges</strong>
|
||||
<strong>{{ 'sceneView.roomTraps' | translate }}</strong>
|
||||
<p>{{ r.traps }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (r.gmNotes?.trim()) {
|
||||
<div class="room-readonly-private">
|
||||
<strong>🔒 Notes MJ</strong>
|
||||
<strong>{{ 'sceneView.roomGmNotes' | translate }}</strong>
|
||||
<p>{{ r.gmNotes }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if ((r.branches?.length ?? 0) > 0) {
|
||||
<div class="room-readonly-branches">
|
||||
<strong>→ Sorties</strong>
|
||||
<strong>{{ 'sceneView.roomExits' | translate }}</strong>
|
||||
<ul>
|
||||
@for (b of r.branches; track b) {
|
||||
<li>
|
||||
<em>{{ b.label }}</em> → {{ roomNameById(scene, b.targetRoomId) }}
|
||||
@if (b.condition?.trim()) {
|
||||
<span class="branch-cond">(si : {{ b.condition }})</span>
|
||||
<span class="branch-cond">{{ 'sceneView.roomExitCondition' | translate:{ condition: b.condition } }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,38 +3,35 @@
|
||||
<div class="gse-header">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour à la liste
|
||||
{{ 'gameSystemEdit.backToList' | translate }}
|
||||
</button>
|
||||
<h1>
|
||||
<lucide-icon [img]="Dices" [size]="22"></lucide-icon>
|
||||
{{ id ? 'Éditer le système' : 'Nouveau système de JDR' }}
|
||||
{{ (id ? 'gameSystemEdit.editTitle' : 'gameSystemEdit.createTitle') | translate }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="gse-form">
|
||||
|
||||
<div class="field">
|
||||
<label for="gs-name">Nom *</label>
|
||||
<input id="gs-name" type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
|
||||
<label for="gs-name">{{ 'gameSystemEdit.nameLabel' | translate }}</label>
|
||||
<input id="gs-name" type="text" [(ngModel)]="name" name="name" [placeholder]="'gameSystemEdit.namePlaceholder' | translate" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="gs-description">Description courte</label>
|
||||
<textarea id="gs-description" [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
|
||||
<label for="gs-description">{{ 'gameSystemEdit.descriptionLabel' | translate }}</label>
|
||||
<textarea id="gs-description" [(ngModel)]="description" name="description" rows="2" [placeholder]="'gameSystemEdit.descriptionPlaceholder' | translate"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="gs-author">Auteur</label>
|
||||
<input id="gs-author" type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
|
||||
<label for="gs-author">{{ 'gameSystemEdit.authorLabel' | translate }}</label>
|
||||
<input id="gs-author" type="text" [(ngModel)]="author" name="author" [placeholder]="'gameSystemEdit.authorPlaceholder' | translate" />
|
||||
</div>
|
||||
|
||||
<!-- Sections de règles -->
|
||||
<div class="sections-area">
|
||||
<h2 class="sections-title">Règles du système</h2>
|
||||
<p class="sections-hint">
|
||||
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).
|
||||
</p>
|
||||
<h2 class="sections-title">{{ 'gameSystemEdit.rulesTitle' | translate }}</h2>
|
||||
<p class="sections-hint">{{ 'gameSystemEdit.rulesHint' | translate }}</p>
|
||||
|
||||
<!-- Import d'un PDF de règles : l'IA propose un découpage en sections,
|
||||
que l'utilisateur révise ci-dessous avant d'enregistrer. -->
|
||||
@@ -42,10 +39,10 @@
|
||||
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
|
||||
<button type="button" class="btn-import" [disabled]="importing" (click)="pdfInput.click()">
|
||||
<lucide-icon [img]="Upload" [size]="14"></lucide-icon>
|
||||
{{ importing ? 'Import en cours…' : 'Importer un PDF de règles' }}
|
||||
{{ (importing ? 'gameSystemEdit.importInProgress' : 'gameSystemEdit.importButton') | translate }}
|
||||
</button>
|
||||
@if (!importing) {
|
||||
<span class="import-hint">L'IA propose un découpage en sections — vous révisez avant d'enregistrer.</span>
|
||||
<span class="import-hint">{{ 'gameSystemEdit.importHint' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -65,7 +62,7 @@
|
||||
}
|
||||
@if (importFound.length) {
|
||||
<p class="import-found">
|
||||
Sections trouvées : {{ importFound.join(' · ') }}
|
||||
{{ 'gameSystemEdit.sectionsFound' | translate:{ titles: importFound.join(' · ') } }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -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"
|
||||
/>
|
||||
<button type="button" class="btn-remove" (click)="removeSection(i)" title="Supprimer cette section">
|
||||
<button type="button" class="btn-remove" (click)="removeSection(i)" [title]="'gameSystemEdit.removeSection' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -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"
|
||||
></textarea>
|
||||
}
|
||||
</div>
|
||||
@@ -110,13 +107,13 @@
|
||||
|
||||
@if (sections.length === 0) {
|
||||
<div class="empty-hint">
|
||||
Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous.
|
||||
{{ 'gameSystemEdit.noSections' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="add-row">
|
||||
<span class="add-label">Ajouter une section :</span>
|
||||
<span class="add-label">{{ 'gameSystemEdit.addSection' | translate }}</span>
|
||||
@for (name of suggestedSections; track name) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -130,39 +127,35 @@
|
||||
}
|
||||
<button type="button" class="chip chip-custom" (click)="addBlank()">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
Autre…
|
||||
{{ 'gameSystemEdit.addOther' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates de fiches PJ/PNJ -->
|
||||
<div class="templates-area">
|
||||
<h2 class="sections-title">Fiches de personnages</h2>
|
||||
<p class="sections-hint">
|
||||
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…).
|
||||
</p>
|
||||
<h2 class="sections-title">{{ 'gameSystemEdit.charactersTitle' | translate }}</h2>
|
||||
<p class="sections-hint">{{ 'gameSystemEdit.charactersHint' | translate }}</p>
|
||||
|
||||
<app-template-fields-editor
|
||||
label="Champs de la fiche PJ"
|
||||
hint="Affiches lors de la creation/edition d'un personnage joueur."
|
||||
[label]="'gameSystemEdit.pcFieldsLabel' | translate"
|
||||
[hint]="'gameSystemEdit.pcFieldsHint' | translate"
|
||||
[fields]="characterTemplate"
|
||||
[suggestions]="characterFieldSuggestions"
|
||||
(fieldsChange)="characterTemplate = $event">
|
||||
</app-template-fields-editor>
|
||||
|
||||
<app-template-fields-editor
|
||||
label="Champs de la fiche PNJ"
|
||||
hint="Affiches lors de la creation/edition d'un personnage non-joueur."
|
||||
[label]="'gameSystemEdit.npcFieldsLabel' | translate"
|
||||
[hint]="'gameSystemEdit.npcFieldsHint' | translate"
|
||||
[fields]="npcTemplate"
|
||||
[suggestions]="npcFieldSuggestions"
|
||||
(fieldsChange)="npcTemplate = $event">
|
||||
</app-template-fields-editor>
|
||||
|
||||
<app-template-fields-editor
|
||||
label="Champs de la fiche Ennemi"
|
||||
hint="Affiches lors de la creation/edition d'un ennemi (bestiaire). Nom, niveau, dossier, portrait et bandeau sont automatiques."
|
||||
[label]="'gameSystemEdit.enemyFieldsLabel' | translate"
|
||||
[hint]="'gameSystemEdit.enemyFieldsHint' | translate"
|
||||
[fields]="enemyTemplate"
|
||||
[suggestions]="enemyFieldSuggestions"
|
||||
(fieldsChange)="enemyTemplate = $event">
|
||||
@@ -172,9 +165,9 @@
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
{{ id ? 'Enregistrer' : 'Créer' }}
|
||||
{{ (id ? 'common.save' : 'common.create') | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
<div class="gs-hero">
|
||||
<lucide-icon [img]="Dices" [size]="56" class="hero-icon"></lucide-icon>
|
||||
<h1>Systèmes de JDR</h1>
|
||||
<p class="hero-subtitle">Les règles que l'IA connaîtra pour vos campagnes</p>
|
||||
<h1>{{ 'gameSystems.heroTitle' | translate }}</h1>
|
||||
<p class="hero-subtitle">{{ 'gameSystems.heroSubtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="gs-grid">
|
||||
@@ -13,18 +13,18 @@
|
||||
<div class="card-header">
|
||||
<h2>{{ gs.name }}</h2>
|
||||
<div class="card-actions">
|
||||
<button class="icon-btn" (click)="delete(gs, $event)" title="Supprimer">
|
||||
<button class="icon-btn" (click)="delete(gs, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-description">{{ gs.description || '(Pas de description)' }}</p>
|
||||
<p class="card-description">{{ gs.description || ('gameSystems.noDescription' | translate) }}</p>
|
||||
<div class="card-footer">
|
||||
@if (gs.author) {
|
||||
<span class="author">par {{ gs.author }}</span>
|
||||
<span class="author">{{ 'gameSystems.byAuthor' | translate:{ author: gs.author } }}</span>
|
||||
}
|
||||
@if (gs.isPublic) {
|
||||
<span class="badge-public">public</span>
|
||||
<span class="badge-public">{{ 'gameSystems.public' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,12 +34,12 @@
|
||||
<div class="new-icon">
|
||||
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
|
||||
</div>
|
||||
<h2>Nouveau système</h2>
|
||||
<p class="card-description">Saisir les règles d'un JDR (markdown)</p>
|
||||
<h2>{{ 'gameSystems.newSystem' | translate }}</h2>
|
||||
<p class="card-description">{{ 'gameSystems.newSystemSubtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="tip">💡 Astuce : organisez vos règles par sections avec des titres <code>## Combat</code>, <code>## Classes</code>, <code>## Lore</code>… Le système injectera les sections pertinentes selon ce que l'IA doit générer.</p>
|
||||
<p class="tip" [innerHTML]="'gameSystems.tip' | translate"></p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@if (node) {
|
||||
<div class="folder-view">
|
||||
<!-- Fil d'Ariane : Lore → ancêtres → dossier courant -->
|
||||
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
||||
<nav class="breadcrumb" [attr.aria-label]="'folderView.breadcrumb' | translate">
|
||||
@if (lore) {
|
||||
<button type="button" class="crumb" (click)="navigateToLoreRoot()">
|
||||
{{ lore.name }}
|
||||
@@ -24,27 +24,27 @@
|
||||
{{ node.name }}
|
||||
</h1>
|
||||
<p class="description">
|
||||
{{ subfolders.length }} sous-dossier(s) · {{ pages.length }} page(s)
|
||||
{{ 'folderView.summary' | translate:{ folders: subfolders.length, pages: pages.length } }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="navigateToEdit()" title="Modifier le dossier">
|
||||
<button type="button" class="btn-secondary" (click)="navigateToEdit()" [title]="'folderView.editTitle' | translate">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()" title="Supprimer le dossier et tout son contenu">
|
||||
<button type="button" class="btn-danger" (click)="delete()" [title]="'folderView.deleteTitle' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sous-dossiers -->
|
||||
<section class="detail-section">
|
||||
<div class="section-header">
|
||||
<h2>Sous-dossiers</h2>
|
||||
<h2>{{ 'folderView.subfolders' | translate }}</h2>
|
||||
<button class="btn-add" (click)="navigateToCreateSubfolder()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau sous-dossier
|
||||
{{ 'folderView.newSubfolder' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (subfolders.length > 0) {
|
||||
@@ -59,17 +59,17 @@
|
||||
}
|
||||
@if (subfolders.length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>Aucun sous-dossier.</p>
|
||||
<p>{{ 'folderView.noSubfolders' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
<!-- Pages -->
|
||||
<section class="detail-section">
|
||||
<div class="section-header">
|
||||
<h2>Pages</h2>
|
||||
<h2>{{ 'folderView.pages' | translate }}</h2>
|
||||
<button class="btn-add" (click)="navigateToCreatePage()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouvelle page
|
||||
{{ 'folderView.newPage' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (pages.length > 0) {
|
||||
@@ -84,7 +84,7 @@
|
||||
}
|
||||
@if (pages.length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>Aucune page dans ce dossier.</p>
|
||||
<p>{{ 'folderView.noPages' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="modal" (click)="$event.stopPropagation()">
|
||||
|
||||
<div class="modal-header">
|
||||
<h2>Créer un nouveau Lore</h2>
|
||||
<h2>{{ 'loreCreate.title' | translate }}</h2>
|
||||
<button class="btn-close" (click)="onCancel()">
|
||||
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
@@ -11,43 +11,43 @@
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
|
||||
<div class="field">
|
||||
<label for="lore-name">Nom de l'univers *</label>
|
||||
<label for="lore-name">{{ 'loreCreate.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="lore-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Royaume des Ombres, Cyberpunk 2157..."
|
||||
[placeholder]="'loreCreate.namePlaceholder' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="lore-description">Description</label>
|
||||
<label for="lore-description">{{ 'common.description' | translate }}</label>
|
||||
<textarea
|
||||
id="lore-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez brièvement votre univers, son ambiance, son genre..."
|
||||
[placeholder]="'loreCreate.descriptionPlaceholder' | translate"
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 Astuce :</strong> Votre lore sera créé avec quelques templates par défaut :</p>
|
||||
<p [innerHTML]="'loreCreate.infoIntro' | translate"></p>
|
||||
<ul>
|
||||
<li>PNJ - Pour vos personnages</li>
|
||||
<li>Lieu - Pour vos villes et régions</li>
|
||||
<li>Faction - Pour vos organisations</li>
|
||||
<li>Objet - Pour vos artefacts</li>
|
||||
<li>{{ 'loreCreate.infoNpc' | translate }}</li>
|
||||
<li>{{ 'loreCreate.infoPlace' | translate }}</li>
|
||||
<li>{{ 'loreCreate.infoFaction' | translate }}</li>
|
||||
<li>{{ 'loreCreate.infoItem' | translate }}</li>
|
||||
</ul>
|
||||
<p class="info-footer">Vous pourrez créer vos propres templates ensuite !</p>
|
||||
<p class="info-footer">{{ 'loreCreate.infoFooter' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
|
||||
Créer le lore
|
||||
{{ 'loreCreate.submit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="onCancel()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Component, EventEmitter, Output } from '@angular/core';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-create',
|
||||
imports: [ReactiveFormsModule, LucideAngularModule],
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './lore-create.component.html',
|
||||
styleUrls: ['./lore-create.component.scss']
|
||||
})
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||
title="Visualiser le graphe des pages et de leurs liens (PNJ inclus)">
|
||||
[title]="'loreDetail.graphTitle' | translate">
|
||||
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
|
||||
Graphe
|
||||
{{ 'loreDetail.graph' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="startEdit()" title="Modifier le Lore">
|
||||
<button type="button" class="btn-secondary" (click)="startEdit()" [title]="'loreDetail.editTitle' | translate">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteLore()" title="Supprimer le Lore">
|
||||
<button type="button" class="btn-danger" (click)="deleteLore()" [title]="'loreDetail.deleteTitle' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,19 +28,19 @@
|
||||
@if (editing) {
|
||||
<div class="detail-header edit-mode">
|
||||
<div class="field">
|
||||
<label for="lore-detail-edit-name">Nom</label>
|
||||
<label for="lore-detail-edit-name">{{ 'common.name' | translate }}</label>
|
||||
<input id="lore-detail-edit-name" type="text" [(ngModel)]="editName" name="editName" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="lore-detail-edit-description">Description</label>
|
||||
<label for="lore-detail-edit-description">{{ 'common.description' | translate }}</label>
|
||||
<textarea id="lore-detail-edit-description" [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||
Sauvegarder
|
||||
{{ 'common.save' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancelEdit()">
|
||||
Annuler
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,10 +49,10 @@
|
||||
@if (!editing) {
|
||||
<section class="detail-section nodes-section">
|
||||
<div class="section-header">
|
||||
<h2>Dossiers</h2>
|
||||
<h2>{{ 'loreDetail.folders' | translate }}</h2>
|
||||
<button class="btn-add" (click)="navigateToCreateNode()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau dossier
|
||||
{{ 'loreDetail.newFolder' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- rootNodes : uniquement les dossiers racine (pas les sous-dossiers,
|
||||
@@ -70,10 +70,10 @@
|
||||
@if (rootNodes.length === 0) {
|
||||
<div class="empty-state">
|
||||
<lucide-icon [img]="Folder" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<p>Aucun dossier pour le moment.</p>
|
||||
<p>{{ 'loreDetail.noFolders' | translate }}</p>
|
||||
<button class="btn-add-first" (click)="navigateToCreateNode()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier dossier
|
||||
{{ 'loreDetail.createFirstFolder' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, Folder, Plus, Pencil, Trash2, Network } 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';
|
||||
@@ -14,7 +15,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-detail',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './lore-detail.component.html',
|
||||
styleUrls: ['./lore-detail.component.scss']
|
||||
})
|
||||
@@ -44,7 +45,8 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -130,27 +132,32 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
this.loreService.getLoreDeletionImpact(lore.id!).subscribe({
|
||||
next: impact => {
|
||||
const deleted: string[] = [];
|
||||
if (impact.folders > 0) deleted.push(`${impact.folders} dossier${impact.folders > 1 ? 's' : ''}`);
|
||||
if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||
if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`);
|
||||
if (impact.folders > 0) deleted.push(this.translate.instant(
|
||||
impact.folders > 1 ? 'loreDetail.impact.foldersPlural' : 'loreDetail.impact.folders',
|
||||
{ n: impact.folders }));
|
||||
if (impact.pages > 0) deleted.push(this.translate.instant(
|
||||
impact.pages > 1 ? 'loreDetail.impact.pagesPlural' : 'loreDetail.impact.pages',
|
||||
{ n: impact.pages }));
|
||||
if (impact.templates > 0) deleted.push(this.translate.instant(
|
||||
impact.templates > 1 ? 'loreDetail.impact.templatesPlural' : 'loreDetail.impact.templates',
|
||||
{ n: impact.templates }));
|
||||
|
||||
const details: string[] = [];
|
||||
if (deleted.length) {
|
||||
details.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
||||
details.push(this.translate.instant('loreDetail.impact.alsoDeletes', { items: deleted.join(', ') }));
|
||||
}
|
||||
if (impact.detachedCampaigns > 0) {
|
||||
details.push(
|
||||
`${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` +
|
||||
`mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.`
|
||||
);
|
||||
details.push(this.translate.instant(
|
||||
impact.detachedCampaigns > 1 ? 'loreDetail.impact.detachedCampaignsPlural' : 'loreDetail.impact.detachedCampaigns',
|
||||
{ n: impact.detachedCampaigns }));
|
||||
}
|
||||
details.push('Cette action est irréversible.');
|
||||
details.push(this.translate.instant('loreDetail.impact.irreversible'));
|
||||
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le Lore',
|
||||
message: `Supprimer définitivement le Lore "${lore.name}" ?`,
|
||||
title: this.translate.instant('loreDetail.deleteConfirmTitle'),
|
||||
message: this.translate.instant('loreDetail.deleteConfirmMessage', { name: lore.name }),
|
||||
details,
|
||||
confirmLabel: 'Supprimer',
|
||||
confirmLabel: this.translate.instant('common.delete'),
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
|
||||
@@ -3,28 +3,27 @@
|
||||
<header class="graph-header">
|
||||
<button type="button" class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour au Lore
|
||||
{{ 'loreGraph.back' | translate }}
|
||||
</button>
|
||||
<div class="graph-title">
|
||||
<h1>
|
||||
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
|
||||
{{ lore.name }} — Graphe
|
||||
{{ 'loreGraph.title' | translate:{ name: lore.name } }}
|
||||
</h1>
|
||||
<p class="graph-subtitle">
|
||||
{{ nodes.length - npcCount }} page(s) · {{ npcCount }} PNJ · {{ edgeCount }} lien(s).
|
||||
Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.
|
||||
{{ 'loreGraph.subtitle' | translate:{ pages: (nodes.length - npcCount), npcs: npcCount, edges: edgeCount } }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="graph-legend">
|
||||
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> Page de Lore</span>
|
||||
<span class="legend-item"><span class="legend-dot legend-dot--npc"></span> PNJ</span>
|
||||
<span class="legend-item"><span class="legend-line legend-line--npc"></span> Lien PNJ → page</span>
|
||||
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> {{ 'loreGraph.legendPage' | translate }}</span>
|
||||
<span class="legend-item"><span class="legend-dot legend-dot--npc"></span> {{ 'loreGraph.legendNpc' | translate }}</span>
|
||||
<span class="legend-item"><span class="legend-line legend-line--npc"></span> {{ 'loreGraph.legendLink' | translate }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (nodes.length === 0) {
|
||||
<div class="graph-empty">
|
||||
Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.
|
||||
{{ 'loreGraph.empty' | translate }}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="graph-scroll">
|
||||
@@ -62,8 +61,7 @@
|
||||
</div>
|
||||
@if (edgeCount === 0) {
|
||||
<p class="graph-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.
|
||||
{{ 'loreGraph.hint' | translate }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, Network } 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';
|
||||
@@ -45,7 +46,7 @@ interface GraphEdge {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lore-graph',
|
||||
imports: [RouterModule, LucideAngularModule],
|
||||
imports: [RouterModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './lore-graph.component.html',
|
||||
styleUrls: ['./lore-graph.component.scss']
|
||||
})
|
||||
@@ -86,7 +87,8 @@ export class LoreGraphComponent implements OnInit, OnDestroy {
|
||||
private pageService: PageService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -97,7 +99,7 @@ export class LoreGraphComponent implements OnInit, OnDestroy {
|
||||
}).subscribe(({ sidebar, npcs }) => {
|
||||
this.lore = sidebar.lore;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.pageTitleService.set(`${sidebar.lore.name} — Graphe`);
|
||||
this.pageTitleService.set(this.translate.instant('loreGraph.title', { name: sidebar.lore.name }));
|
||||
this.buildGraph(sidebar.pages, npcs);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
<div class="node-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Créer un nouveau dossier</h1>
|
||||
<p class="subtitle">Les dossiers permettent d'organiser vos pages par catégorie</p>
|
||||
<h1>{{ 'loreNodeCreate.title' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'loreNodeCreate.subtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="node-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du dossier *</label>
|
||||
<label>{{ 'loreNodeCreate.nameLabel' | translate }}</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Personnages, Créatures..."
|
||||
[placeholder]="'loreNodeCreate.namePlaceholder' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier parent <span class="optional">(optionnel)</span></label>
|
||||
<label>{{ 'loreNodeCreate.parentLabel' | translate }} <span class="optional">{{ 'common.optional' | translate }}</span></label>
|
||||
<select formControlName="parentId">
|
||||
<option value="">— Racine du Lore —</option>
|
||||
<option value="">{{ 'loreNodeCreate.rootOption' | translate }}</option>
|
||||
@for (parent of availableParents; track parent) {
|
||||
<option [value]="parent.id">{{ parent.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<p class="hint">Laissez vide pour créer un dossier à la racine du lore</p>
|
||||
<p class="hint">{{ 'loreNodeCreate.parentHint' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<label>{{ 'loreNodeCreate.iconLabel' | translate }}</label>
|
||||
<div class="icon-grid">
|
||||
@for (option of iconOptions; track option) {
|
||||
<button
|
||||
@@ -44,10 +44,10 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description <span class="optional">(optionnel)</span></label>
|
||||
<label>{{ 'common.description' | translate }} <span class="optional">{{ 'common.optional' | translate }}</span></label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez le type de contenu que ce dossier contiendra..."
|
||||
[placeholder]="'loreNodeCreate.descriptionPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
@@ -55,9 +55,9 @@
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>
|
||||
Créer le dossier
|
||||
{{ 'loreNodeCreate.submit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, LucideIconData } 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';
|
||||
@@ -14,7 +15,7 @@ import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-node-create',
|
||||
imports: [ReactiveFormsModule, LucideAngularModule],
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './lore-node-create.component.html',
|
||||
styleUrls: ['./lore-node-create.component.scss']
|
||||
})
|
||||
|
||||
@@ -2,39 +2,39 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Éditer le dossier</h1>
|
||||
<h1>{{ 'loreNodeEdit.title' | translate }}</h1>
|
||||
<p class="subtitle">
|
||||
{{ childFolderCount }} sous-dossier(s) · {{ pageCount }} page(s)
|
||||
{{ 'loreNodeEdit.summary' | translate:{ folders: childFolderCount, pages: pageCount } }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
[disabled]="form.invalid"
|
||||
(click)="save()">
|
||||
Sauvegarder
|
||||
{{ 'common.save' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<form [formGroup]="form" class="edit-form">
|
||||
<div class="field">
|
||||
<label>Nom du dossier *</label>
|
||||
<label>{{ 'loreNodeEdit.nameLabel' | translate }}</label>
|
||||
<input type="text" formControlName="name" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Dossier parent <span class="optional">(optionnel)</span></label>
|
||||
<label>{{ 'loreNodeEdit.parentLabel' | translate }} <span class="optional">{{ 'common.optional' | translate }}</span></label>
|
||||
<select formControlName="parentId">
|
||||
<option value="">— Racine du Lore —</option>
|
||||
<option value="">{{ 'loreNodeEdit.rootOption' | translate }}</option>
|
||||
@for (parent of availableParents; track parent) {
|
||||
<option [value]="parent.id">{{ parent.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<p class="hint">Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)</p>
|
||||
<p class="hint">{{ 'loreNodeEdit.parentHint' | translate }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<label>{{ 'loreNodeEdit.iconLabel' | translate }}</label>
|
||||
<div class="icon-grid">
|
||||
@for (option of iconOptions; track option) {
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, LucideIconData } 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';
|
||||
@@ -32,7 +33,7 @@ import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lore-node-edit',
|
||||
imports: [ReactiveFormsModule, LucideAngularModule],
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './lore-node-edit.component.html',
|
||||
styleUrls: ['./lore-node-edit.component.scss']
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
<div class="lore-hero">
|
||||
<lucide-icon [img]="BookOpen" [size]="56" class="hero-icon"></lucide-icon>
|
||||
<h1>Vos Univers</h1>
|
||||
<p class="hero-subtitle">Sélectionnez un lore existant ou créez un nouvel univers</p>
|
||||
<h1>{{ 'lore.heroTitle' | translate }}</h1>
|
||||
<p class="hero-subtitle">{{ 'lore.heroSubtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="lore-grid">
|
||||
@@ -12,13 +12,13 @@
|
||||
<div class="lore-card" (click)="navigateToDetail(lore.id!)">
|
||||
<div class="card-header">
|
||||
<lucide-icon [img]="Folder" [size]="20" class="card-icon"></lucide-icon>
|
||||
<span class="card-date">Il y a 2h</span>
|
||||
<span class="card-date">{{ 'lore.cardDate' | translate }}</span>
|
||||
</div>
|
||||
<h2>{{ lore.name }}</h2>
|
||||
<p class="card-description">{{ lore.description }}</p>
|
||||
<div class="card-stats">
|
||||
<span>📄 {{ lore.pageCount || 0 }} pages</span>
|
||||
<span>🌳 {{ lore.nodeCount || 0 }} dossiers</span>
|
||||
<span>📄 {{ 'lore.statPages' | translate:{ n: (lore.pageCount || 0) } }}</span>
|
||||
<span>🌳 {{ 'lore.statFolders' | translate:{ n: (lore.nodeCount || 0) } }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -27,13 +27,13 @@
|
||||
<div class="new-icon">
|
||||
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
|
||||
</div>
|
||||
<h2>Nouveau Lore</h2>
|
||||
<p class="card-description">Créez un nouvel univers</p>
|
||||
<h2>{{ 'lore.newLore' | translate }}</h2>
|
||||
<p class="card-description">{{ 'lore.newLoreSubtitle' | translate }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="tip">💡 Astuce : Utilisez les templates pour structurer votre univers de manière cohérente</p>
|
||||
<p class="tip">{{ 'lore.tip' | translate }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LoreService } from '../services/lore.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { Lore } from '../services/lore.model';
|
||||
@@ -9,7 +10,7 @@ import { LoreCreateComponent } from './lore-create/lore-create.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore',
|
||||
imports: [LucideAngularModule, LoreCreateComponent],
|
||||
imports: [LucideAngularModule, TranslatePipe, LoreCreateComponent],
|
||||
templateUrl: './lore.component.html',
|
||||
styleUrls: ['./lore.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<h1>Créer une nouvelle Page</h1>
|
||||
<p class="subtitle">Créez une page à partir d'un template existant</p>
|
||||
<h1>{{ 'pageCreate.title' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'pageCreate.subtitle' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="page-form">
|
||||
|
||||
<!-- Titre -->
|
||||
<div class="field">
|
||||
<label for="page-title">Titre de la page *</label>
|
||||
<input id="page-title" type="text" formControlName="title" placeholder="Ex: Maître Eldrin, La Cité d'Argent..." />
|
||||
<label for="page-title">{{ 'pageCreate.pageTitleLabel' | translate }}</label>
|
||||
<input id="page-title" type="text" formControlName="title" [placeholder]="'pageCreate.pageTitlePlaceholder' | translate" />
|
||||
</div>
|
||||
|
||||
<!-- Template -->
|
||||
<div class="field">
|
||||
<label>Template *</label>
|
||||
<label>{{ 'pageCreate.templateLabel' | translate }}</label>
|
||||
|
||||
@if (templates.length) {
|
||||
<div class="templates-grid">
|
||||
@@ -39,20 +39,20 @@
|
||||
[routerLink]="['/lore', loreId, 'templates', 'create']"
|
||||
[queryParams]="{ returnTo: 'page-create' }"
|
||||
(click)="saveDraft()"
|
||||
title="Créer un nouveau template pour ce Lore">
|
||||
[title]="'pageCreate.createTemplateTitle' | translate">
|
||||
<div class="template-card-head">
|
||||
<lucide-icon [img]="Plus" [size]="16"></lucide-icon>
|
||||
<span class="template-name">Créer un template</span>
|
||||
<span class="template-name">{{ 'pageCreate.createTemplate' | translate }}</span>
|
||||
</div>
|
||||
<p class="template-description">
|
||||
Vous reviendrez ici automatiquement, votre saisie sera conservée.
|
||||
{{ 'pageCreate.createTemplateHint' | translate }}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="empty-hint">
|
||||
Aucun template défini pour ce Lore.
|
||||
<a [routerLink]="['/lore', loreId, 'templates', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un template</a> d'abord.
|
||||
{{ 'pageCreate.noTemplates' | translate }}
|
||||
<a [routerLink]="['/lore', loreId, 'templates', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">{{ 'pageCreate.createTemplate' | translate }}</a> {{ 'pageCreate.firstSuffix' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -60,30 +60,27 @@
|
||||
|
||||
<!-- Dossier de destination -->
|
||||
<div class="field">
|
||||
<label for="page-node">Dossier de destination *</label>
|
||||
<label for="page-node">{{ 'pageCreate.nodeLabel' | translate }}</label>
|
||||
|
||||
@if (nodes.length) {
|
||||
<select id="page-node" formControlName="nodeId">
|
||||
<option value="" disabled>Sélectionnez un dossier</option>
|
||||
<option value="" disabled>{{ 'pageCreate.nodePlaceholder' | translate }}</option>
|
||||
@for (node of nodes; track node) {
|
||||
<option [value]="node.id">{{ node.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<p class="hint">La page sera créée dans ce dossier</p>
|
||||
<p class="hint">{{ 'pageCreate.nodeHint' | translate }}</p>
|
||||
} @else {
|
||||
<p class="empty-hint">
|
||||
Aucun dossier dans ce Lore.
|
||||
<a [routerLink]="['/lore', loreId, 'nodes', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un dossier</a> d'abord.
|
||||
{{ 'pageCreate.noNodes' | translate }}
|
||||
<a [routerLink]="['/lore', loreId, 'nodes', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">{{ 'pageCreate.createNode' | translate }}</a> {{ 'pageCreate.firstSuffix' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Aide contextuelle -->
|
||||
<div class="info-box">
|
||||
💡 Option 1 : <strong>Créer la page</strong> vide, puis remplir les champs manuellement.<br>
|
||||
💡 Option 2 : <strong>Créer avec l'IA</strong> pour dialoguer avec un assistant qui pré-remplira les champs.
|
||||
</div>
|
||||
<div class="info-box" [innerHTML]="'pageCreate.infoBox' | translate"></div>
|
||||
|
||||
<!-- Erreur wizard (parsing <values> ou échec HTTP) -->
|
||||
@if (wizardError) {
|
||||
@@ -92,13 +89,13 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions-row">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
<button type="button" class="btn-ai" (click)="openWizard()" [disabled]="!canSubmit"
|
||||
title="Ouvrir l'assistant IA pour pré-remplir les champs">
|
||||
[title]="'pageCreate.aiTitle' | translate">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Créer avec l'IA
|
||||
{{ 'pageCreate.createWithAi' | translate }}
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="!canSubmit">Créer la page</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="!canSubmit">{{ 'pageCreate.createPage' | translate }}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -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 <values> — 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,48 +4,48 @@
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p class="subtitle">{{ template?.name || 'Page' }}</p>
|
||||
<p class="subtitle">{{ template?.name || ('pageEdit.pageFallback' | translate) }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[disabled]="aiLoading"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA (chat ou remplissage automatique)">
|
||||
[title]="'pageEdit.aiTitle' | translate">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
{{ aiLoading ? 'Génération…' : 'Assistant IA' }}
|
||||
{{ (aiLoading ? 'pageEdit.generating' : 'pageEdit.aiAssistant') | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" [routerLink]="['/lore', loreId, 'pages', pageId]">Annuler</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
||||
<button type="button" class="btn-secondary" [routerLink]="['/lore', loreId, 'pages', pageId]">{{ 'common.cancel' | translate }}</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">{{ 'common.delete' | translate }}</button>
|
||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
|
||||
Sauvegarder
|
||||
{{ 'common.save' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@if (aiError) {
|
||||
<div class="ai-error-banner" role="alert">
|
||||
<span>{{ aiError }}</span>
|
||||
<button type="button" class="ai-error-dismiss" (click)="aiError = null" aria-label="Fermer">×</button>
|
||||
<button type="button" class="ai-error-dismiss" (click)="aiError = null" [attr.aria-label]="'common.close' | translate">×</button>
|
||||
</div>
|
||||
}
|
||||
<form class="edit-form">
|
||||
<!-- Identité ----------------------------------------------------- -->
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<label>{{ 'common.name' | translate }}</label>
|
||||
<input type="text" [(ngModel)]="title" name="title" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Dossier</label>
|
||||
<label>{{ 'pageEdit.folder' | translate }}</label>
|
||||
<select [(ngModel)]="nodeId" name="nodeId">
|
||||
@for (node of nodes; track node) {
|
||||
<option [value]="node.id">{{ node.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<p class="hint">Déplacez cette page dans un autre dossier</p>
|
||||
<p class="hint">{{ 'pageEdit.folderHint' | translate }}</p>
|
||||
</div>
|
||||
<!-- Champs dynamiques du template -------------------------------- -->
|
||||
@if (template?.fields?.length) {
|
||||
<h2 class="section-title">Champs</h2>
|
||||
<h2 class="section-title">{{ 'pageEdit.fields' | translate }}</h2>
|
||||
@for (field of template!.fields; track field) {
|
||||
<!-- Champ TEXT : textarea editable -->
|
||||
@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 }">
|
||||
</textarea>
|
||||
</div>
|
||||
}
|
||||
@@ -87,7 +87,7 @@
|
||||
</div>
|
||||
}
|
||||
@if (!(field.labels ?? []).length) {
|
||||
<p class="kv-empty">Aucun libellé défini dans le template pour ce champ.</p>
|
||||
<p class="kv-empty">{{ 'pageEdit.noLabels' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@
|
||||
<td class="table-edit-actions-col">
|
||||
<button type="button" class="btn-row-delete"
|
||||
(click)="removeTableRow(field.name, ri)"
|
||||
[attr.aria-label]="'Supprimer la ligne ' + (ri + 1)" title="Supprimer la ligne">
|
||||
[attr.aria-label]="'pageEdit.deleteRowN' | translate:{ n: (ri + 1) }" [title]="'pageEdit.deleteRow' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
|
||||
</button>
|
||||
</td>
|
||||
@@ -132,28 +132,28 @@
|
||||
</table>
|
||||
<button type="button" class="btn-row-add" (click)="addTableRow(field.name, field.labels)">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
|
||||
Ajouter une ligne
|
||||
{{ 'pageEdit.addRow' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="kv-empty">Aucune colonne définie dans le template pour ce tableau.</p>
|
||||
<p class="kv-empty">{{ 'pageEdit.noColumns' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
<!-- Tags --------------------------------------------------------- -->
|
||||
<h2 class="section-title">Tags</h2>
|
||||
<h2 class="section-title">{{ 'pageEdit.tags' | translate }}</h2>
|
||||
<div class="field">
|
||||
<app-chips-input
|
||||
[value]="tags"
|
||||
(valueChange)="tags = $event"
|
||||
placeholder="Ajouter un tag (Entrée pour valider)...">
|
||||
[placeholder]="'pageEdit.tagsPlaceholder' | translate">
|
||||
</app-chips-input>
|
||||
<p class="hint">Mots-clés libres pour classer et retrouver cette page</p>
|
||||
<p class="hint">{{ 'pageEdit.tagsHint' | translate }}</p>
|
||||
</div>
|
||||
<!-- Liens vers d'autres pages ----------------------------------- -->
|
||||
<h2 class="section-title">Pages liées</h2>
|
||||
<h2 class="section-title">{{ 'pageEdit.relatedPages' | translate }}</h2>
|
||||
<div class="field">
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
@@ -162,18 +162,18 @@
|
||||
[loreId]="loreId"
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<p class="hint">Cliquez sur un lien pour ouvrir la page associée</p>
|
||||
<p class="hint">{{ 'pageEdit.relatedPagesHint' | translate }}</p>
|
||||
</div>
|
||||
<!-- Notes privées ----------------------------------------------- -->
|
||||
<h2 class="section-title">Notes privées</h2>
|
||||
<h2 class="section-title">{{ 'pageEdit.privateNotes' | translate }}</h2>
|
||||
<div class="field">
|
||||
<textarea
|
||||
[(ngModel)]="notes"
|
||||
name="notes"
|
||||
rows="4"
|
||||
placeholder="Notes personnelles (non exportées vers FoundryVTT)">
|
||||
[placeholder]="'pageEdit.privateNotesPlaceholder' | translate">
|
||||
</textarea>
|
||||
<p class="hint">Visibles uniquement par vous. Utiles pour préparer vos sessions.</p>
|
||||
<p class="hint">{{ 'pageEdit.privateNotesHint' | translate }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
<header class="view-header">
|
||||
<div>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p class="view-subtitle">{{ template?.name || 'Page' }}</p>
|
||||
<p class="view-subtitle">{{ template?.name || ('pageView.pageFallback' | translate) }}</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
<button type="button" class="btn-primary" (click)="editMode()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deletePage()" title="Supprimer la page">
|
||||
<button type="button" class="btn-danger" (click)="deletePage()" [title]="'pageView.deleteTitle' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -27,7 +27,7 @@
|
||||
@if (valueOf(field.name)) {
|
||||
<p class="view-section-body">{{ valueOf(field.name) }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'pageView.notFilled' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'pageView.notFilled' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
<p class="view-section-empty">{{ 'pageView.notFilled' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
<!-- Tags -->
|
||||
@if ((page.tags?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title">Tags</h2>
|
||||
<h2 class="view-section-title">{{ 'pageView.tags' | translate }}</h2>
|
||||
<div class="view-chips">
|
||||
@for (tag of page.tags; track tag) {
|
||||
<span class="view-chip view-chip--tag">{{ tag }}</span>
|
||||
@@ -100,7 +100,7 @@
|
||||
<!-- Pages liées -->
|
||||
@if ((page.relatedPageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title">Pages liées</h2>
|
||||
<h2 class="view-section-title">{{ 'pageView.relatedPages' | translate }}</h2>
|
||||
<div class="view-chips">
|
||||
@for (relId of page.relatedPageIds; track relId) {
|
||||
<a class="view-chip"
|
||||
@@ -116,7 +116,7 @@
|
||||
<section class="view-section view-section--private">
|
||||
<h2 class="view-section-title">
|
||||
<span class="view-section-icon">🔒</span>
|
||||
Notes privées
|
||||
{{ 'pageView.privateNotes' | translate }}
|
||||
</h2>
|
||||
<p class="view-section-body">{{ page.notes }}</p>
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<h1>Créer un nouveau Template</h1>
|
||||
<p class="subtitle">Définissez un gabarit personnalisé pour créer des pages cohérentes</p>
|
||||
<h1>{{ 'templateCreate.title' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'templateCreate.subtitle' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="template-form">
|
||||
@@ -11,32 +11,32 @@
|
||||
<div class="col-left">
|
||||
|
||||
<div class="field">
|
||||
<label for="template-name">Nom du template *</label>
|
||||
<input id="template-name" type="text" formControlName="name" placeholder="Ex: Auberge, Artefact, Monstre..." />
|
||||
<label for="template-name">{{ 'templateCreate.nameLabel' | translate }}</label>
|
||||
<input id="template-name" type="text" formControlName="name" [placeholder]="'templateCreate.namePlaceholder' | translate" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="template-description">Description</label>
|
||||
<textarea id="template-description" formControlName="description" rows="4" placeholder="À quoi sert ce template ?"></textarea>
|
||||
<label for="template-description">{{ 'common.description' | translate }}</label>
|
||||
<textarea id="template-description" formControlName="description" rows="4" [placeholder]="'templateCreate.descriptionPlaceholder' | translate"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="template-default-node">Dossier par défaut *</label>
|
||||
<label for="template-default-node">{{ 'templateCreate.defaultNodeLabel' | translate }}</label>
|
||||
|
||||
@if (nodes.length) {
|
||||
<select id="template-default-node" formControlName="defaultNodeId">
|
||||
<option value="" disabled>Sélectionnez un dossier</option>
|
||||
<option value="" disabled>{{ 'templateCreate.nodePlaceholder' | translate }}</option>
|
||||
@for (node of nodes; track node) {
|
||||
<option [value]="node.id">{{ node.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p>
|
||||
<p class="hint">{{ 'templateCreate.defaultNodeHint' | translate }}</p>
|
||||
} @else {
|
||||
<p class="empty-hint">
|
||||
Aucun dossier dans ce Lore.
|
||||
{{ 'templateCreate.noNodes' | translate }}
|
||||
<a [routerLink]="['/lore', loreId, 'nodes', 'create']"
|
||||
[queryParams]="{ returnTo: nodeCreateReturnTo }"
|
||||
(click)="saveDraft()">Créer un dossier</a> d'abord.
|
||||
(click)="saveDraft()">{{ 'templateCreate.createNode' | translate }}</a> {{ 'templateCreate.firstSuffix' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<!-- Colonne droite --------------------------------------------- -->
|
||||
<div class="col-right">
|
||||
|
||||
<label class="section-label">Champs du template *</label>
|
||||
<label class="section-label">{{ 'templateCreate.fieldsLabel' | translate }}</label>
|
||||
|
||||
<ul class="fields-list">
|
||||
@for (f of fields; track f; let i = $index; let first = $first; let last = $last) {
|
||||
@@ -56,13 +56,13 @@
|
||||
<button type="button" class="btn-icon btn-reorder"
|
||||
(click)="moveField(i, -1)"
|
||||
[disabled]="first"
|
||||
aria-label="Monter d'un cran" title="Monter">
|
||||
[attr.aria-label]="'templateCreate.moveUp' | translate" [title]="'templateCreate.moveUp' | translate">
|
||||
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="btn-icon btn-reorder"
|
||||
(click)="moveField(i, 1)"
|
||||
[disabled]="last"
|
||||
aria-label="Descendre d'un cran" title="Descendre">
|
||||
[attr.aria-label]="'templateCreate.moveDown' | translate" [title]="'templateCreate.moveDown' | translate">
|
||||
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -78,11 +78,11 @@
|
||||
[ngModel]="f.type"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldType(i, $event)"
|
||||
title="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||
<option value="TABLE">Tableau</option>
|
||||
[title]="'templateCreate.fieldTypeTitle' | translate">
|
||||
<option value="TEXT">{{ 'templateCreate.typeText' | translate }}</option>
|
||||
<option value="IMAGE">{{ 'templateCreate.typeImage' | translate }}</option>
|
||||
<option value="KEY_VALUE_LIST">{{ 'templateCreate.typeKeyValue' | translate }}</option>
|
||||
<option value="TABLE">{{ 'templateCreate.typeTable' | translate }}</option>
|
||||
</select>
|
||||
@if (f.type === 'IMAGE') {
|
||||
<select
|
||||
@@ -90,14 +90,14 @@
|
||||
[ngModel]="f.layout ?? 'GALLERY'"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldLayout(i, $event)"
|
||||
title="Mise en page des images">
|
||||
<option value="GALLERY">Grille</option>
|
||||
<option value="HERO">Heros</option>
|
||||
<option value="MASONRY">Mosaique</option>
|
||||
<option value="CAROUSEL">Carrousel</option>
|
||||
[title]="'templateCreate.layoutTitle' | translate">
|
||||
<option value="GALLERY">{{ 'templateCreate.layoutGallery' | translate }}</option>
|
||||
<option value="HERO">{{ 'templateCreate.layoutHero' | translate }}</option>
|
||||
<option value="MASONRY">{{ 'templateCreate.layoutMasonry' | translate }}</option>
|
||||
<option value="CAROUSEL">{{ 'templateCreate.layoutCarousel' | translate }}</option>
|
||||
</select>
|
||||
}
|
||||
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
|
||||
<button type="button" class="btn-icon" (click)="removeField(i)" [attr.aria-label]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
@@ -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)" />
|
||||
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" aria-label="Retirer">
|
||||
[placeholder]="(f.type === 'TABLE' ? 'templateCreate.column' : 'templateCreate.label') | translate"
|
||||
[attr.aria-label]="(f.type === 'TABLE' ? 'templateCreate.columnN' : 'templateCreate.labelN') | translate:{ n: (li + 1) }" />
|
||||
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" [attr.aria-label]="'common.remove' | translate">
|
||||
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
{{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
|
||||
{{ (f.type === 'TABLE' ? 'templateCreate.column' : 'templateCreate.label') | translate }}
|
||||
</button>
|
||||
@if (!(f.labels ?? []).length) {
|
||||
<span class="kv-labels-hint">
|
||||
{{ 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 }}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
@@ -139,31 +137,31 @@
|
||||
type="text"
|
||||
[(ngModel)]="newFieldName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
placeholder="+ Ajouter un champ"
|
||||
[placeholder]="'templateCreate.addFieldPlaceholder' | translate"
|
||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||
<select
|
||||
class="type-select"
|
||||
[(ngModel)]="newFieldType"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||
<option value="TABLE">Tableau</option>
|
||||
[attr.aria-label]="'templateCreate.fieldTypeTitle' | translate">
|
||||
<option value="TEXT">{{ 'templateCreate.typeText' | translate }}</option>
|
||||
<option value="IMAGE">{{ 'templateCreate.typeImage' | translate }}</option>
|
||||
<option value="KEY_VALUE_LIST">{{ 'templateCreate.typeKeyValue' | translate }}</option>
|
||||
<option value="TABLE">{{ 'templateCreate.typeTable' | translate }}</option>
|
||||
</select>
|
||||
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
|
||||
<button type="button" class="btn-add" (click)="addField()" [title]="'templateCreate.addFieldTitle' | translate">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Texte = libre + utilisable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).</p>
|
||||
<p class="hint">{{ 'templateCreate.fieldsHelp' | translate }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Actions ---------------------------------------------------- -->
|
||||
<div class="actions-row">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">Créer le template</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">{{ 'templateCreate.createTemplate' | translate }}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -3,38 +3,38 @@
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{{ template.name }}</h1>
|
||||
<p class="subtitle">Template</p>
|
||||
<p class="subtitle">{{ 'templateEdit.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="form.invalid">Sauvegarder</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">{{ 'common.delete' | translate }}</button>
|
||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="form.invalid">{{ 'common.save' | translate }}</button>
|
||||
</div>
|
||||
</header>
|
||||
<form [formGroup]="form" class="template-form">
|
||||
<!-- Colonne gauche ---------------------------------------------- -->
|
||||
<div class="col-left">
|
||||
<div class="field">
|
||||
<label for="template-edit-name">Nom</label>
|
||||
<label for="template-edit-name">{{ 'common.name' | translate }}</label>
|
||||
<input id="template-edit-name" type="text" formControlName="name" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="template-edit-default-node">Dossier par défaut</label>
|
||||
<label for="template-edit-default-node">{{ 'templateEdit.defaultNodeLabel' | translate }}</label>
|
||||
<select id="template-edit-default-node" formControlName="defaultNodeId">
|
||||
<option value="">-- Aucun --</option>
|
||||
<option value="">{{ 'templateEdit.noneOption' | translate }}</option>
|
||||
@for (node of nodes; track node) {
|
||||
<option [value]="node.id">{{ node.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier par défaut</p>
|
||||
<p class="hint">{{ 'templateEdit.defaultNodeHint' | translate }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="template-edit-description">Description</label>
|
||||
<label for="template-edit-description">{{ 'common.description' | translate }}</label>
|
||||
<textarea id="template-edit-description" formControlName="description" rows="6"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Colonne droite --------------------------------------------- -->
|
||||
<div class="col-right">
|
||||
<label class="section-label">Champs du template</label>
|
||||
<label class="section-label">{{ 'templateEdit.fieldsLabel' | translate }}</label>
|
||||
<ul class="fields-list">
|
||||
@for (f of fields; track f; let i = $index; let first = $first; let last = $last) {
|
||||
<li class="field-row">
|
||||
@@ -42,13 +42,13 @@
|
||||
<button type="button" class="btn-icon-ghost btn-reorder"
|
||||
(click)="moveField(i, -1)"
|
||||
[disabled]="first"
|
||||
aria-label="Monter d'un cran" title="Monter">
|
||||
[attr.aria-label]="'templateEdit.moveUp' | translate" [title]="'templateEdit.moveUp' | translate">
|
||||
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="btn-icon-ghost btn-reorder"
|
||||
(click)="moveField(i, 1)"
|
||||
[disabled]="last"
|
||||
aria-label="Descendre d'un cran" title="Descendre">
|
||||
[attr.aria-label]="'templateEdit.moveDown' | translate" [title]="'templateEdit.moveDown' | translate">
|
||||
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -66,11 +66,11 @@
|
||||
[ngModel]="f.type"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldType(i, $event)"
|
||||
title="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||
<option value="TABLE">Tableau</option>
|
||||
[title]="'templateEdit.fieldTypeTitle' | translate">
|
||||
<option value="TEXT">{{ 'templateEdit.typeText' | translate }}</option>
|
||||
<option value="IMAGE">{{ 'templateEdit.typeImage' | translate }}</option>
|
||||
<option value="KEY_VALUE_LIST">{{ 'templateEdit.typeKeyValue' | translate }}</option>
|
||||
<option value="TABLE">{{ 'templateEdit.typeTable' | translate }}</option>
|
||||
</select>
|
||||
@if (f.type === 'IMAGE') {
|
||||
<select
|
||||
@@ -78,14 +78,14 @@
|
||||
[ngModel]="f.layout ?? 'GALLERY'"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldLayout(i, $event)"
|
||||
title="Mise en page des images">
|
||||
<option value="GALLERY">Grille</option>
|
||||
<option value="HERO">Heros</option>
|
||||
<option value="MASONRY">Mosaique</option>
|
||||
<option value="CAROUSEL">Carrousel</option>
|
||||
[title]="'templateEdit.layoutTitle' | translate">
|
||||
<option value="GALLERY">{{ 'templateEdit.layoutGallery' | translate }}</option>
|
||||
<option value="HERO">{{ 'templateEdit.layoutHero' | translate }}</option>
|
||||
<option value="MASONRY">{{ 'templateEdit.layoutMasonry' | translate }}</option>
|
||||
<option value="CAROUSEL">{{ 'templateEdit.layoutCarousel' | translate }}</option>
|
||||
</select>
|
||||
}
|
||||
<button type="button" class="btn-icon-danger" (click)="removeField(i)" aria-label="Supprimer">
|
||||
<button type="button" class="btn-icon-danger" (click)="removeField(i)" [attr.aria-label]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
@@ -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)" />
|
||||
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" aria-label="Retirer">
|
||||
[placeholder]="(f.type === 'TABLE' ? 'templateEdit.column' : 'templateEdit.label') | translate"
|
||||
[attr.aria-label]="(f.type === 'TABLE' ? 'templateEdit.columnN' : 'templateEdit.labelN') | translate:{ n: (li + 1) }" />
|
||||
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" [attr.aria-label]="'common.remove' | translate">
|
||||
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
{{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
|
||||
{{ (f.type === 'TABLE' ? 'templateEdit.column' : 'templateEdit.label') | translate }}
|
||||
</button>
|
||||
@if (!(f.labels ?? []).length) {
|
||||
<span class="kv-labels-hint">
|
||||
{{ 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 }}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
@@ -126,23 +124,23 @@
|
||||
type="text"
|
||||
[(ngModel)]="newFieldName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
placeholder="+ Ajouter un champ"
|
||||
[placeholder]="'templateEdit.addFieldPlaceholder' | translate"
|
||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||
<select
|
||||
class="type-select"
|
||||
[(ngModel)]="newFieldType"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||
<option value="TABLE">Tableau</option>
|
||||
[attr.aria-label]="'templateEdit.fieldTypeTitle' | translate">
|
||||
<option value="TEXT">{{ 'templateEdit.typeText' | translate }}</option>
|
||||
<option value="IMAGE">{{ 'templateEdit.typeImage' | translate }}</option>
|
||||
<option value="KEY_VALUE_LIST">{{ 'templateEdit.typeKeyValue' | translate }}</option>
|
||||
<option value="TABLE">{{ 'templateEdit.typeTable' | translate }}</option>
|
||||
</select>
|
||||
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
|
||||
<button type="button" class="btn-add" (click)="addField()" [title]="'templateEdit.addFieldTitle' | translate">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">Texte = libre + generable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).</p>
|
||||
<p class="hint">{{ 'templateEdit.fieldsHelp' | translate }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CampaignImportStreamEvent> {
|
||||
return new Observable<CampaignImportStreamEvent>((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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GameSystem[]> {
|
||||
return this.http.get<GameSystem[]>(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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user