diff --git a/web/src/app/app.component.ts b/web/src/app/app.component.ts index ce89324..d49295f 100644 --- a/web/src/app/app.component.ts +++ b/web/src/app/app.component.ts @@ -1,5 +1,5 @@ import { Component, HostListener } from '@angular/core'; -import { AsyncPipe, NgIf } from '@angular/common'; +import { AsyncPipe } from '@angular/common'; import { RouterOutlet } from '@angular/router'; import { SidebarComponent } from './sidebar/sidebar.component'; import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component'; @@ -11,20 +11,18 @@ import { GlobalSearchService } from './services/global-search.service'; import { VersionCheckerService } from './services/version-checker.service'; @Component({ - selector: 'app-root', - standalone: true, - imports: [ + selector: 'app-root', + imports: [ RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, UpdateBannerComponent, ConfirmDialogHostComponent, - AsyncPipe, - NgIf, - ], - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'] + AsyncPipe +], + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] }) export class AppComponent { readonly sidebarConfig$ = this.layoutService.secondarySidebar$; diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index b23e94e..f93cb81 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -4,6 +4,7 @@ import { hiddenInDemoGuard } from './guards/demo-mode.guard'; export const routes: Routes = [ { path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) }, { path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) }, + { path: 'lore/:loreId/graph', loadComponent: () => import('./lore/lore-graph/lore-graph.component').then(m => m.LoreGraphComponent) }, { path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) }, @@ -16,12 +17,29 @@ export const routes: Routes = [ { path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) }, { path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, - { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, - { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, - { path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) }, + { path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, + { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) }, + { path: 'campaigns/:campaignId/npcs', loadComponent: () => import('./campaigns/npc/npc-list/npc-list.component').then(m => m.NpcListComponent) }, { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) }, + { path: 'campaigns/:campaignId/notebooks', loadComponent: () => import('./campaigns/notebook/notebook-list/notebook-list.component').then(m => m.NotebookListComponent) }, + { path: 'campaigns/:campaignId/notebooks/:notebookId', loadComponent: () => import('./campaigns/notebook/notebook-detail/notebook-detail.component').then(m => m.NotebookDetailComponent) }, + { path: 'campaigns/:campaignId/item-catalogs', loadComponent: () => import('./campaigns/item-catalog/item-catalog-list/item-catalog-list.component').then(m => m.ItemCatalogListComponent) }, + { path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) }, + { path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) }, + { path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) }, + { path: 'campaigns/:campaignId/enemies', loadComponent: () => import('./campaigns/enemy/enemy-list/enemy-list.component').then(m => m.EnemyListComponent) }, + { path: 'campaigns/:campaignId/enemies/create', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) }, + { path: 'campaigns/:campaignId/enemies/:enemyId/edit', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) }, + { path: 'campaigns/:campaignId/enemies/:enemyId', loadComponent: () => import('./campaigns/enemy/enemy-view/enemy-view.component').then(m => m.EnemyViewComponent) }, + { path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, + { path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, + { path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) }, { path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, @@ -32,6 +50,7 @@ export const routes: Routes = [ { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) }, + { path: 'sessions/:id', loadComponent: () => import('./sessions/session-detail/session-detail.component').then(m => m.SessionDetailComponent) }, { path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) }, { path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, { path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.html b/web/src/app/campaigns/arc/arc-create/arc-create.component.html index 3e8a2b2..975be8e 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.html +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.html @@ -1,42 +1,58 @@
- +
- + +
+ + +
+
+ +
+
- +
- +
diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.scss b/web/src/app/campaigns/arc/arc-create/arc-create.component.scss index 285fc54..356e3ec 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.scss +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.scss @@ -3,6 +3,44 @@ max-width: 640px; } +.arc-type-choice { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.arc-type-option { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.85rem 0.95rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 10px; + cursor: pointer; + background: var(--color-surface, #fff); + transition: border-color 120ms ease, background 120ms ease; + + &:hover { border-color: var(--color-primary, #2c6cd6); } + + &.selected { + border-color: var(--color-primary, #2c6cd6); + background: rgba(66, 133, 244, 0.06); + } + + input[type="radio"] { display: none; } +} + +.arc-type-title { + font-weight: 600; + font-size: 0.95rem; +} + +.arc-type-desc { + font-size: 0.78rem; + color: var(--color-text-muted, #666); + line-height: 1.3; +} + // Override local : titre en violet (pas en blanc comme le .page-header global). .page-header h1 { color: #a5b4fc; } diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts index 11a88bc..f929644 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts @@ -1,12 +1,15 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; 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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { LayoutService } from '../../../services/layout.service'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; @@ -18,11 +21,10 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; * le nombre d'arcs existants dans la campagne courante. */ @Component({ - selector: 'app-arc-create', - standalone: true, - imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent], - templateUrl: './arc-create.component.html', - styleUrls: ['./arc-create.component.scss'] + selector: 'app-arc-create', + imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent, TranslatePipe], + templateUrl: './arc-create.component.html', + styleUrls: ['./arc-create.component.scss'] }) export class ArcCreateComponent implements OnInit, OnDestroy { readonly BookOpen = BookOpen; @@ -40,11 +42,16 @@ export class ArcCreateComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, - private layoutService: LayoutService + private randomTableService: RandomTableService, + private enemyService: EnemyService, + private layoutService: LayoutService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], - description: [''] + description: [''], + // Type structurel : LINEAR (séquentiel) par défaut, HUB (sandbox/quêtes parallèles). + type: ['LINEAR', Validators.required] }); } @@ -57,11 +64,11 @@ export class ArcCreateComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).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)); }); } @@ -72,6 +79,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy { description: this.form.value.description, campaignId: this.campaignId, order: this.existingArcCount + 1, + type: this.form.value.type, icon: this.selectedIcon }).subscribe({ next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]), diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html index 29a4d94..19e7ee3 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html @@ -2,24 +2,24 @@ @@ -28,126 +28,145 @@
- + - Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max. + {{ 'arcEdit.illustrationsHint' | translate }}
- + - Cartes regionales et plans utiles aux joueurs pour situer l'action. + {{ 'arcEdit.mapsHint' | translate }}
- + + />
- +
- + +
+ + +
+
+ +
+
- +
- +
- + - Ces notes sont privées et ne seront pas exportées vers FoundryVTT. + {{ 'arcEdit.gmNotesHint' | translate }}
- +
- +
-
- - - - - Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée. - -
+ @if (loreId) { +
+ + + + + {{ 'arcEdit.relatedPagesHint' | translate }} + +
+ } -
- - 💡 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.). - -
+ @if (!loreId) { +
+ + {{ 'arcEdit.noLoreHint' | translate }} + +
+ } @@ -159,7 +178,7 @@ entityType="arc" [entityId]="arcId" [isOpen]="chatOpen" - welcomeMessage="Je vois cet arc. Demande-moi d'enrichir ses thèmes, ses enjeux ou son dénouement." + [welcomeMessage]="'arcEdit.chatWelcome' | translate" [quickSuggestions]="chatQuickSuggestions" (close)="chatOpen = false"> diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss index bc29f28..2033914 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.scss @@ -3,6 +3,44 @@ max-width: 640px; } +.arc-type-choice { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.arc-type-option { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.85rem 0.95rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 10px; + cursor: pointer; + background: var(--color-surface, #fff); + transition: border-color 120ms ease, background 120ms ease; + + &:hover { border-color: var(--color-primary, #2c6cd6); } + + &.selected { + border-color: var(--color-primary, #2c6cd6); + background: rgba(66, 133, 244, 0.06); + } + + input[type="radio"] { display: none; } +} + +.arc-type-title { + font-weight: 600; + font-size: 0.95rem; +} + +.arc-type-desc { + font-size: 0.78rem; + color: var(--color-text-muted, #666); + line-height: 1.3; +} + // Header local : titre à gauche, actions (Assistant IA) à droite. .page-header { display: flex; diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index a661990..eb6ac97 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -1,13 +1,16 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; 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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -31,11 +34,10 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia * personnages / lieux / objets du Lore. */ @Component({ - selector: 'app-arc-edit', - standalone: true, - imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent], - templateUrl: './arc-edit.component.html', - styleUrls: ['./arc-edit.component.scss'] + selector: 'app-arc-edit', + imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, TranslatePipe], + templateUrl: './arc-edit.component.html', + styleUrls: ['./arc-edit.component.scss'] }) export class ArcEditComponent implements OnInit, OnDestroy { readonly Trash2 = Trash2; @@ -45,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; } @@ -77,14 +81,18 @@ export class ArcEditComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, 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], description: [''], + type: ['LINEAR', Validators.required], themes: [''], stakes: [''], gmNotes: [''], @@ -115,7 +123,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), arc: this.campaignService.getArcById(this.arcId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; @@ -137,6 +145,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { this.form.patchValue({ name: arc.name, description: arc.description ?? '', + type: arc.type ?? 'LINEAR', themes: arc.themes ?? '', stakes: arc.stakes ?? '', gmNotes: arc.gmNotes ?? '', @@ -144,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)); }); } @@ -155,6 +164,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { description: this.form.value.description, campaignId: this.campaignId, order: this.arc.order ?? 1, + type: this.form.value.type, themes: this.form.value.themes, stakes: this.form.value.stakes, gmNotes: this.form.value.gmNotes, @@ -172,10 +182,10 @@ export class ArcEditComponent implements OnInit, OnDestroy { delete(): void { this.confirmDialog.confirm({ - title: 'Supprimer l\'arc', - message: `Supprimer l'arc "${this.arc?.name}" ?`, - details: ['Cette action est irréversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('arcEdit.deleteTitle'), + message: this.translate.instant('arcEdit.deleteMessage', { name: this.arc?.name }), + details: [this.translate.instant('arcEdit.irreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.html b/web/src/app/campaigns/arc/arc-view/arc-view.component.html index 639fe17..4b8338a 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.html +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.html @@ -1,87 +1,150 @@ -
- -
-
-

- - {{ arc.name }} -

-

Arc narratif

-
-
- - -
-
- - -
- -
- - -
-

🗺️ Cartes & plans

- -
- -
-

📜 Synopsis

-

{{ arc.description }}

-

Non renseigné

-
- -
+@if (arc) { +
+
+
+

+ @if (arc.icon) { + + } + {{ arc.name }} +

+

+ {{ (arc.type === 'HUB' ? 'arcView.subtitleHub' : 'arcView.subtitleLinear') | translate }} +

+
+
+ + +
+
+ + @if ((arc.illustrationImageIds?.length ?? 0) > 0) { +
+ +
+ } + + @if ((arc.mapImageIds?.length ?? 0) > 0) { +
+

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

+ +
+ } + + @if (arc.type === 'HUB') { +
+

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

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

+ {{ 'arcView.hubQuestsEmpty' | translate }} +

+ } + @if (hubQuests.length > 0) { +
+ @for (q of hubQuests; track q) { + + } +
+ } +
+ }
-

Thèmes principaux

-

{{ arc.themes }}

-

Non renseigné

+

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

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

{{ arc.description }}

+ } @else { +

{{ 'arcView.notProvided' | translate }}

+ }
- +
+
+

{{ 'arcView.themesTitle' | translate }}

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

{{ arc.themes }}

+ } @else { +

{{ 'arcView.notProvided' | translate }}

+ } +
+
+

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

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

{{ arc.stakes }}

+ } @else { +

{{ 'arcView.notProvided' | translate }}

+ } +
+
-

⚖️ Enjeux globaux

-

{{ arc.stakes }}

-

Non renseigné

+

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

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

{{ arc.rewards }}

+ } @else { +

{{ 'arcView.notProvided' | translate }}

+ }
+
+

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

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

{{ arc.resolution }}

+ } @else { +

{{ 'arcView.notProvided' | translate }}

+ } +
+ + @if (arc.gmNotes?.trim()) { +
+

+ 🔒 + {{ 'arcView.gmNotesTitle' | translate }} +

+

{{ arc.gmNotes }}

+
+ } + + @if (loreId && (arc.relatedPageIds?.length ?? 0) > 0) { +
+

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

+
+ @for (relId of arc.relatedPageIds; track relId) { + + {{ titleOfRelated(relId) }} + + } +
+
+ }
- -
-

🎁 Récompenses et progression

-

{{ arc.rewards }}

-

Non renseigné

-
- -
-

🎬 Dénouement prévu

-

{{ arc.resolution }}

-

Non renseigné

-
- - -
-

- 🔒 - Notes et planification du MJ -

-

{{ arc.gmNotes }}

-
- - -
-

🔗 Pages Lore associées

- -
- -
+} diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.scss b/web/src/app/campaigns/arc/arc-view/arc-view.component.scss index 9c48f33..09d94e5 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.scss +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.scss @@ -1 +1,112 @@ // Styles partagés via styles/_view.scss + +// === Vue Hub === +.hub-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; + + .view-section-title { margin: 0; } +} + +.hub-quest-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.85rem; +} + +.hub-quest-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.85rem 0.9rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 10px; + background: var(--color-surface, #fff); + text-align: left; + cursor: pointer; + transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease; + min-height: 110px; + + &:hover { + border-color: var(--color-primary, #2c6cd6); + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06); + } + + &.locked { + opacity: 0.7; + background: rgba(128, 128, 128, 0.04); + border-style: dashed; + + &:hover { + // Pas d'illusion d'interactivité pour les quêtes verrouillées + transform: none; + border-color: var(--color-border, #e2e2e2); + box-shadow: none; + } + } +} + +.hub-quest-card-head { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.hub-quest-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-primary, #2c6cd6); +} + +.hub-quest-card-name { + font-weight: 600; + font-size: 0.95rem; + line-height: 1.2; +} + +.hub-quest-card-desc { + font-size: 0.82rem; + color: var(--color-text-muted, #666); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.hub-quest-card-footer { + margin-top: auto; +} + +.hub-quest-card-locked-hint { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.4rem; + padding-top: 0.4rem; + border-top: 1px dashed rgba(128, 128, 128, 0.25); + font-size: 0.72rem; + color: var(--color-text-muted, #777); + + > lucide-icon, + > span { + display: inline-flex; + align-items: center; + gap: 0.3rem; + } +} + +.hub-quest-card-prereq-list { + list-style: disc; + padding-left: 1rem; + margin: 0.15rem 0 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts index e6ab623..b619c75 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts @@ -1,17 +1,20 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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 { 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'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Arc } from '../../../services/campaign.model'; +import { Arc, Chapter, Prerequisite } from '../../../services/campaign.model'; import { Page } from '../../../services/page.model'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; @@ -23,21 +26,30 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia * Bouton "Modifier" → /campaigns/:campaignId/arcs/:arcId/edit */ @Component({ - selector: 'app-arc-view', - standalone: true, - imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent], - templateUrl: './arc-view.component.html', - styleUrls: ['./arc-view.component.scss'] + selector: 'app-arc-view', + imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe], + templateUrl: './arc-view.component.html', + styleUrls: ['./arc-view.component.scss'] }) export class ArcViewComponent implements OnInit, OnDestroy { readonly Pencil = Pencil; readonly Trash2 = Trash2; + readonly AlertCircle = AlertCircle; readonly resolveCampaignIcon = resolveCampaignIcon; campaignId = ''; arcId = ''; arc: Arc | null = null; + /** Chapitres de l'arc courant — exploités pour le rendu HUB (grille de quêtes). */ + hubQuests: Chapter[] = []; + + /** + * Indexe les chapitres de toute la campagne par id pour résoudre les libellés + * des prérequis QUEST_COMPLETED quand on les affiche dans les tooltips de verrouillage. + */ + private allChaptersById: Record = {}; + /** ID du Lore associé à la campagne (null si pas d'univers lié). */ loreId: string | null = null; /** Pages du Lore — pour résoudre relatedPageIds en titres. */ @@ -49,10 +61,13 @@ export class ArcViewComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -72,7 +87,7 @@ export class ArcViewComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), arc: this.campaignService.getArcById(this.arcId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; @@ -85,12 +100,44 @@ export class ArcViewComponent implements OnInit, OnDestroy { this.availablePages = pages; this.pageTitleService.set(arc.name); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + // Quêtes du Hub : chapitres de l'arc courant, triés par order puis par nom. + this.hubQuests = [...(treeData.chaptersByArc[this.arcId] ?? [])].sort((a, b) => { + const oa = a.order ?? 0; + const ob = b.order ?? 0; + if (oa !== ob) return oa - ob; + return a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' }); + }); + // Index global pour résoudre les noms de quêtes référencées par les prérequis. + this.allChaptersById = {}; + Object.values(treeData.chaptersByArc).forEach(list => + list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; }) + ); + + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } + /** Construit un libellé lisible pour un prérequis (tooltip de verrouillage). */ + describePrerequisite(p: Prerequisite): string { + switch (p.kind) { + case 'QUEST_COMPLETED': + return this.translate.instant('arcView.prereqQuestCompleted', { name: this.allChaptersById[p.questId]?.name ?? '?' }); + case 'SESSION_REACHED': + return this.translate.instant('arcView.prereqSessionReached', { n: p.minSessionNumber }); + case 'FLAG_SET': + return this.translate.instant('arcView.prereqFlagSet', { flag: p.flagName }); + } + } + + openQuest(q: Chapter): void { + if (!q.id) return; + this.router.navigate([ + '/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', q.id + ]); + } + 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 { @@ -108,20 +155,24 @@ export class ArcViewComponent implements OnInit, OnDestroy { this.campaignService.getArcDeletionImpact(arc.id!).subscribe({ next: impact => { const parts: string[] = []; - if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`); - if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`); + if (impact.chapters > 0) { + parts.push(this.translate.instant(impact.chapters > 1 ? 'arcView.chaptersPlural' : 'arcView.chaptersSingular', { n: impact.chapters })); + } + if (impact.scenes > 0) { + parts.push(this.translate.instant(impact.scenes > 1 ? 'arcView.scenesPlural' : 'arcView.scenesSingular', { n: impact.scenes })); + } const details: string[] = []; if (parts.length) { - details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`); + details.push(this.translate.instant('arcView.deleteCascade', { parts: parts.join(', ') })); } - details.push('Cette action est irréversible.'); + details.push(this.translate.instant('arcView.irreversible')); this.confirmDialog.confirm({ - title: 'Supprimer l\'arc', - message: `Supprimer l'arc "${arc.name}" ?`, + title: this.translate.instant('arcView.deleteConfirmTitle'), + message: this.translate.instant('arcView.deleteConfirmMessage', { name: arc.name }), details, - confirmLabel: 'Supprimer', + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index d4059a7..658cc81 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -1,12 +1,18 @@ 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'; +import { RandomTableService } from '../services/random-table.service'; +import { EnemyService } from '../services/enemy.service'; import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service'; import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model'; import { Character } from '../services/character.model'; import { Npc } from '../services/npc.model'; +import { RandomTable } from '../services/random-table.model'; +import { Enemy } from '../services/enemy.model'; +import { catchError } from 'rxjs/operators'; /** * Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes) @@ -22,22 +28,39 @@ export interface CampaignTreeData { scenesByChapter: Record; characters: Character[]; npcs: Npc[]; + randomTables: RandomTable[]; + enemies: Enemy[]; } export function loadCampaignTreeData( service: CampaignService, campaignId: string, characterService: CharacterService, - npcService: NpcService + npcService: NpcService, + // Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les + // tables aléatoires sont chargées et apparaissent dans la sidebar. + randomTableService?: RandomTableService, + // Optionnel (même principe) : si fourni, les ennemis sont chargés et le nœud + // « Ennemis » devient dépliable (dossiers → fiches) en plus du lien. + enemyService?: EnemyService ): Observable { + // Note refonte Playthrough : les PJ appartiennent désormais à une Partie, + // pas à la campagne — on ne les charge plus ici (les vues qui les affichent + // doivent passer par PlaythroughService et appeler characterService.getByPlaythrough). return forkJoin({ arcs: service.getArcs(campaignId), - characters: characterService.getByCampaign(campaignId), - npcs: npcService.getByCampaign(campaignId) + characters: of([] as Character[]), + npcs: npcService.getByCampaign(campaignId), + randomTables: randomTableService + ? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[]))) + : of([] as RandomTable[]), + enemies: enemyService + ? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[]))) + : of([] as Enemy[]) }).pipe( - switchMap(({ arcs, characters, npcs }) => { + switchMap(({ arcs, characters, npcs, randomTables, enemies }) => { if (arcs.length === 0) { - return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs }); + return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables, enemies }); } const chapterCalls = arcs.map(a => service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters }))) @@ -52,7 +75,7 @@ export function loadCampaignTreeData( }); if (allChapters.length === 0) { - return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs }); + return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables, enemies }); } const sceneCalls = allChapters.map(c => service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes }))) @@ -61,7 +84,7 @@ export function loadCampaignTreeData( map(sceneResults => { const scenesByChapter: Record = {}; sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; }); - return { arcs, chaptersByArc, scenesByChapter, characters, npcs }; + return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables, enemies }; }) ); }) @@ -70,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 }) => @@ -79,52 +102,97 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T // IDs préfixés par type pour éviter les collisions dans LayoutService.expanded // (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1 // peuvent coexister et se marchaient sur les pieds dans le Set global). - const sortedCharacters = [...data.characters].sort(byName); - const characterItems: TreeItem[] = sortedCharacters.map(ch => ({ - id: `character-${ch.id}`, - label: ch.name, - route: `/campaigns/${campaignId}/characters/${ch.id}` - })); - - const charactersNode: TreeItem = { - id: 'characters-root', - label: 'PJ', - iconKey: 'users', - children: characterItems, - meta: characterItems.length ? String(characterItems.length) : undefined, - sectionHeaderBefore: 'Personnages', - // Note : le section header "Personnages" est porté par le premier nœud (PJ). - // Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar. - createActions: [{ - id: 'new-character', - label: 'Nouveau PJ', - route: `/campaigns/${campaignId}/characters/create`, - actionIcon: 'plus' - }] - }; - + // Note refonte Playthrough : les PJ ne sont plus rattachés à la campagne mais + // à une Partie (Playthrough). On ne les affiche donc plus dans la sidebar de + // campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages". const sortedNpcs = [...data.npcs].sort(byName); - const npcItems: TreeItem[] = sortedNpcs.map(n => ({ + const npcItem = (n: Npc): TreeItem => ({ id: `npc-${n.id}`, label: n.name, route: `/campaigns/${campaignId}/npcs/${n.id}` - })); + }); + + // Regroupement par DOSSIER : un sous-nœud (dépliable) par dossier, puis les PNJ + // non classés directement sous « PNJ ». + const npcsByFolder = new Map(); + const ungroupedNpcs: Npc[] = []; + for (const n of sortedNpcs) { + const f = (n.folder ?? '').trim(); + if (f) { + if (!npcsByFolder.has(f)) npcsByFolder.set(f, []); + npcsByFolder.get(f)!.push(n); + } else { + ungroupedNpcs.push(n); + } + } + const npcFolderNodes: TreeItem[] = [...npcsByFolder.keys()] + .sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' })) + .map(folder => { + const items = npcsByFolder.get(folder)!.map(npcItem); + return { + id: `npc-folder-${folder}`, + label: folder, + iconKey: 'folder', + children: items, + meta: String(items.length) + }; + }); + const npcChildren: TreeItem[] = [...npcFolderNodes, ...ungroupedNpcs.map(npcItem)]; const npcsNode: TreeItem = { id: 'npcs-root', - label: 'PNJ', + label: translate.instant('campaignTree.npcs'), iconKey: 'c-drama', - children: npcItems, - meta: npcItems.length ? String(npcItems.length) : undefined, - // Pas de sectionHeaderBefore : on reste sous le header "Personnages" du nœud PJ. + children: npcChildren, + meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined, + // Cliquer le LIBELLÉ ouvre la page de liste (vue d'ensemble par dossiers) ; + // cliquer le CHEVRON déplie l'arbre dans la sidebar — les deux coexistent. + 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: translate.instant('campaignTree.sectionCharacters'), createActions: [{ id: 'new-npc', - label: 'Nouveau PNJ', + label: translate.instant('campaignTree.newNpc'), route: `/campaigns/${campaignId}/npcs/create`, actionIcon: 'plus' }] }; + // --- Ennemis (bestiaire) : même structure que les PNJ — dossiers dépliables + // dans la sidebar + libellé cliquable vers la page de liste. + const sortedEnemies = [...(data.enemies ?? [])].sort(byName); + const enemyItem = (e: Enemy): TreeItem => ({ + id: `enemy-${e.id}`, + label: e.name, + route: `/campaigns/${campaignId}/enemies/${e.id}`, + meta: e.level ? `Niv. ${e.level}` : undefined + }); + const enemiesByFolder = new Map(); + const ungroupedEnemies: Enemy[] = []; + for (const e of sortedEnemies) { + const f = (e.folder ?? '').trim(); + if (f) { + if (!enemiesByFolder.has(f)) enemiesByFolder.set(f, []); + enemiesByFolder.get(f)!.push(e); + } else { + ungroupedEnemies.push(e); + } + } + const enemyFolderNodes: TreeItem[] = [...enemiesByFolder.keys()] + .sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' })) + .map(folder => { + const items = enemiesByFolder.get(folder)!.map(enemyItem); + return { + id: `enemy-folder-${folder}`, + label: folder, + iconKey: 'folder', + children: items, + meta: String(items.length) + }; + }); + const enemyChildren: TreeItem[] = [...enemyFolderNodes, ...ungroupedEnemies.map(enemyItem)]; + const sortedArcs = [...data.arcs].sort(byName); const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => { @@ -143,11 +211,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T id: `chapter-${ch.id}`, label: ch.name, iconKey: ch.icon ?? undefined, + // Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire). + meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined, children: sceneItems, 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' }] @@ -159,18 +229,84 @@ 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}`, - label: 'Nouveau chapitre', + // Dans un arc hub, un "chapitre" est présenté comme une "quête". + label: arc.type === 'HUB' ? translate.instant('campaignTree.newQuest') : translate.instant('campaignTree.newChapter'), route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`, actionIcon: 'plus' }] }; }); - return [...arcNodes, charactersNode, npcsNode]; + const sortedTables = [...(data.randomTables ?? [])].sort(byName); + const tableItems: TreeItem[] = sortedTables.map(t => ({ + id: `random-table-${t.id}`, + label: t.name, + iconKey: t.icon ?? 'dice', + route: `/campaigns/${campaignId}/random-tables/${t.id}` + })); + + const tablesNode: TreeItem = { + id: 'random-tables-root', + label: translate.instant('campaignTree.randomTables'), + iconKey: 'dice', + children: tableItems, + meta: tableItems.length ? String(tableItems.length) : undefined, + sectionHeaderBefore: translate.instant('campaignTree.sectionTools'), + createActions: [{ + id: 'new-random-table', + label: translate.instant('campaignTree.newTable'), + route: `/campaigns/${campaignId}/random-tables/create`, + actionIcon: 'plus' + }] + }; + + // Lien simple vers les ateliers (la liste se charge sur sa page — pas de fetch ici). + const notebooksNode: TreeItem = { + id: 'notebooks-root', + label: translate.instant('campaignTree.notebooks'), + iconKey: 'book-open', + route: `/campaigns/${campaignId}/notebooks` + }; + + // Catalogues d'objets (boutiques, butins…) → page de liste (outil). + const catalogsNode: TreeItem = { + id: 'item-catalogs-root', + label: translate.instant('campaignTree.itemCatalogs'), + iconKey: 'package', + route: `/campaigns/${campaignId}/item-catalogs` + }; + + // Ennemis (bestiaire, fiches pilotées par le template Ennemi du GameSystem, + // classées par dossier) — rangé avec les PERSONNAGES, comme les PNJ. + // Libellé → page de liste ; chevron → arbre dépliable (dossiers → fiches). + const enemiesNode: TreeItem = { + id: 'enemies-root', + 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: translate.instant('campaignTree.newEnemy'), + route: `/campaigns/${campaignId}/enemies/create`, + actionIcon: 'plus' + }] + }; + + // Importer un PDF de campagne → arborescence (outil, comme tables & ateliers). + const importNode: TreeItem = { + id: 'import-pdf-root', + label: translate.instant('campaignTree.importPdf'), + iconKey: 'file-up', + route: `/campaigns/${campaignId}/import` + }; + + return [...arcNodes, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode]; } /** @@ -187,20 +323,23 @@ 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}` })); return { title: campaign.name, - items: buildCampaignTree(campaignId, treeData), - footerLabel: 'Toutes les campagnes', + // Titre cliquable → accueil de la campagne (raccourci depuis n'importe quelle sous-page). + titleRoute: `/campaigns/${campaignId}`, + items: buildCampaignTree(campaignId, treeData, translate), + footerLabel: translate.instant('campaignTree.allCampaigns'), createActions: [ - { id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` } + { id: 'create-arc', label: translate.instant('campaignTree.newArc'), variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` } ], globalItems, - globalBackLabel: 'Toutes les campagnes', + globalBackLabel: translate.instant('campaignTree.allCampaigns'), globalBackRoute: '/campaigns' }; } diff --git a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html index 02156ce..231838c 100644 --- a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html +++ b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html @@ -2,7 +2,7 @@ +
diff --git a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts index b9edbe5..abf5760 100644 --- a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts +++ b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts @@ -1,8 +1,9 @@ import { Component, EventEmitter, OnInit, Output } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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'; @@ -21,11 +22,10 @@ export interface CampaignCreatePayload { } @Component({ - selector: 'app-campaign-create', - standalone: true, - imports: [CommonModule, ReactiveFormsModule, FormsModule, LucideAngularModule], - templateUrl: './campaign-create.component.html', - styleUrls: ['./campaign-create.component.scss'] + selector: 'app-campaign-create', + imports: [ReactiveFormsModule, FormsModule, LucideAngularModule, TranslatePipe], + templateUrl: './campaign-create.component.html', + styleUrls: ['./campaign-create.component.scss'] }) export class CampaignCreateComponent implements OnInit { @Output() close = new EventEmitter(); diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index 246291d..458facb 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -1,199 +1,233 @@ -
- - -
-
-

{{ campaign.name }}

-

{{ campaign.description }}

-
- {{ campaign.playerCount || 0 }} joueurs - - - - - {{ linkedLore.name }} - - - - - - Univers introuvable - +@if (campaign) { +
+ + @if (!editing) { +
+
+

{{ campaign.name }}

+

{{ campaign.description }}

+
+ {{ 'campaignDetail.players' | translate:{ n: campaign.playerCount || 0 } }} + + @if (linkedLore) { + + + {{ linkedLore.name }} + + } + + @if (campaign.loreId && !linkedLore) { + + + {{ 'campaignDetail.loreMissing' | translate }} + + } +
+
+
+ + +
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - +
+
+ + +
+
+ + +
+
+ + @if (!creatingGameSystem) { + - -
- -
- - -
-
-
-
- - -
-
- -
-
-

Personnages

-
- - -
-
-

- - Personnages joueurs - {{ characters.length }} -

- -
- -
-
- -
- {{ character.name }} - {{ personaSnippet(character) }} + + @for (gs of availableGameSystems; track gs) { + + } + + + } + @if (creatingGameSystem) { +
+ +
+ + +
+
+ } +
+
+ +
-
- -
-

Aucun personnage joueur pour le moment.

- -
-
- - -
-
-

- - Personnages non-joueurs - {{ npcs.length }} -

- -
- -
-
- -
- {{ npc.name }} - {{ personaSnippet(npc) }} + } + @if (!editing) { +
+
+

{{ 'campaignDetail.charactersTitle' | translate }}

-
-
- -
-

Aucun PNJ pour le moment.

- -
+ + +
+
+

+ + {{ 'campaignDetail.npcTitle' | translate }} + @if (npcs.length > 0) { + {{ npcs.length }} + } +

+ +
+ @if (npcs.length > 0) { +
+ @for (npc of npcs; track npc) { +
+ +
+ {{ npc.name }} + {{ personaSnippet(npc) }} +
+
+ } +
+ } + @if (npcs.length === 0) { +
+

{{ 'campaignDetail.noNpc' | translate }}

+ +
+ } +
+
+ } + @if (!editing) { +
+
+

{{ 'campaignDetail.arcsTitle' | translate }}

+
+ +
+
+ @if (arcs.length > 0) { +
+ @for (arc of arcs; track arc) { +
+ + {{ arc.name }} + {{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }} +
+ } +
+ } + @if (arcs.length === 0) { +
+ +

{{ 'campaignDetail.noArc' | translate }}

+ +
+ } +
+ } + + @if (!editing) { +
+
+

+ + {{ 'campaignDetail.playthroughsTitle' | translate }} +

+ +
+ @if (playthroughs.length > 0) { +
+ @for (p of playthroughs; track p) { +
+ +
+ {{ p.name }} + @if (p.description) { + {{ p.description }} + } +
+
+ } +
+ } + @if (playthroughs.length === 0) { +
+

{{ 'campaignDetail.noPlaythrough' | translate }}

+
+ } +
+ } +
- - -
-
-

Arcs narratifs

- -
- -
-
- - {{ arc.name }} - {{ chapterCountByArc[arc.id!] || 0 }} chapitres -
-
- -
- -

Aucun arc narratif pour le moment.

- -
-
- -
+ } diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss index fb78672..ab3754f 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss @@ -1,3 +1,58 @@ +.section-hint { + margin: -0.25rem 0 0.85rem; + font-size: 0.85rem; + color: var(--color-text-muted, #666); + max-width: 70ch; + line-height: 1.4; +} + +// ==== Parties (Playthroughs) ==== +.playthroughs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.playthrough-card { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.85rem 1rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 10px; + background: var(--color-surface, #fff); + cursor: pointer; + transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease; + + &:hover { + border-color: var(--color-primary, #2c6cd6); + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06); + } +} + +.playthrough-icon { color: var(--color-primary, #2c6cd6); flex: 0 0 auto; } + +.playthrough-info { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.playthrough-name { + font-weight: 600; + font-size: 0.95rem; +} + +.playthrough-meta { + font-size: 0.78rem; + color: var(--color-text-muted, #777); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .campaign-detail { padding: 2.5rem 2rem; display: flex; @@ -213,6 +268,12 @@ h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; } } +.section-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + .btn-add { display: flex; align-items: center; @@ -228,6 +289,14 @@ transition: background 0.2s; &:hover { background: #5b52e0; } + + // Variante secondaire (ex: "Importer un PDF") : discrète à côté de l'action primaire. + &--secondary { + background: #1f2937; + color: #e5e7eb; + border: 1px solid #374151; + &:hover { background: #273244; border-color: #6c63ff; } + } } .arcs-grid { @@ -382,3 +451,57 @@ &:hover { background: #5b52e0; } } + +// ─────────────── Sessions de jeu (Play Context) ─────────────── +.sessions-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.session-card { + display: flex; + align-items: center; + gap: 0.75rem; + background: #111827; + border: 1px solid #1f2937; + border-radius: 10px; + padding: 0.9rem 1rem; + cursor: pointer; + transition: border-color 0.2s, transform 0.2s; + + &:hover { border-color: #6c63ff; transform: translateY(-1px); } + + &--active { + border-color: #10b981; + background: linear-gradient(180deg, #0d1f1a 0%, #111827 100%); + } + + .session-icon { color: #6c63ff; flex-shrink: 0; } + + .session-info { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + } + + .session-name { + color: white; + font-size: 0.9rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .session-meta { + color: #6b7280; + font-size: 0.75rem; + } + + .session-status { + color: #10b981; + font-weight: 600; + } +} diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts index 891f55a..3a7b7ec 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check } from 'lucide-angular'; +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'; @@ -12,7 +13,12 @@ import { GameSystemService } from '../../../services/game-system.service'; import { GameSystem } from '../../../services/game-system.model'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; -import { Character } from '../../../services/character.model'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; +import { SessionService } from '../../../services/session.service'; +import { PlaythroughService } from '../../../services/playthrough.service'; +import { Playthrough } from '../../../services/campaign.model'; +import { Session } from '../../../services/session.model'; import { Npc } from '../../../services/npc.model'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -22,11 +28,10 @@ import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } fr import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; @Component({ - selector: 'app-campaign-detail', - standalone: true, - imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink], - templateUrl: './campaign-detail.component.html', - styleUrls: ['./campaign-detail.component.scss'] + selector: 'app-campaign-detail', + imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe], + templateUrl: './campaign-detail.component.html', + styleUrls: ['./campaign-detail.component.scss'] }) export class CampaignDetailComponent implements OnInit, OnDestroy { readonly Swords = Swords; @@ -34,10 +39,12 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { readonly Globe = Globe; readonly Pencil = Pencil; readonly Trash2 = Trash2; - readonly User = User; readonly Dices = Dices; readonly Drama = Drama; readonly Check = Check; + readonly Play = Play; + readonly Upload = Upload; + readonly Sparkles = Sparkles; campaign: Campaign | null = null; arcs: Arc[] = []; @@ -51,10 +58,21 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { availableGameSystems: GameSystem[] = []; /** GameSystem associé si `campaign.gameSystemId` est renseigné ; sinon null. */ linkedGameSystem: GameSystem | null = null; - /** Fiches de personnages (PJ) de la campagne. */ - characters: Character[] = []; /** Fiches de personnages non-joueurs (PNJ) de la campagne. */ npcs: Npc[] = []; + /** Sessions de jeu (passées et en cours) liées à cette campagne. */ + sessions: Session[] = []; + /** + * Session active globale (toutes campagnes confondues). + * Sert à désactiver le bouton "Lancer" si une session tourne déjà ailleurs. + * Null si aucune session active dans l'app. + */ + activeSessionGlobal: Session | null = null; + /** Indicateur de lancement en cours pour éviter les double-clics. */ + startingSession = false; + + /** Parties (Playthroughs) de cette campagne. */ + playthroughs: Playthrough[] = []; /** Mode édition inline. */ editing = false; @@ -78,9 +96,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { private gameSystemService: GameSystemService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, + private sessionService: SessionService, + private playthroughService: PlaythroughService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -93,17 +116,19 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { switchMap(id => forkJoin({ campaign: this.campaignService.getCampaignById(id), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe( - catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData)) - ) + treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe( + catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData)) + ), + playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) })) - ).subscribe(({ campaign, allCampaigns, treeData }) => { + ).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => { this.campaign = campaign; this.editing = false; + this.playthroughs = playthroughs; this.loadLinkedLore(campaign); this.loadLinkedGameSystem(campaign); - this.loadCharacters(campaign.id!); this.loadNpcs(campaign.id!); + this.loadSessions(campaign.id!); this.arcs = treeData.arcs; this.chapterCountByArc = this.computeChapterCounts(treeData); this.showLayout(allCampaigns, treeData); @@ -128,16 +153,18 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(id), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe( - catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData)) - ) - }).subscribe(({ campaign, allCampaigns, treeData }) => { + treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe( + catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData)) + ), + playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) + }).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => { this.campaign = campaign; this.editing = false; + this.playthroughs = playthroughs; this.loadLinkedLore(campaign); this.loadLinkedGameSystem(campaign); - this.loadCharacters(campaign.id!); this.loadNpcs(campaign.id!); + this.loadSessions(campaign.id!); this.arcs = treeData.arcs; this.chapterCountByArc = this.computeChapterCounts(treeData); this.showLayout(allCampaigns, treeData); @@ -170,23 +197,38 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { ).subscribe(gs => this.linkedGameSystem = gs); } - /** Charge les fiches de personnages (PJ) de la campagne. */ - private loadCharacters(campaignId: string): void { - this.characterService.getByCampaign(campaignId).pipe( - catchError(() => of([] as Character[])) - ).subscribe(list => this.characters = list); - } - - /** Symétrique pour les PNJ. */ + /** Charge les PNJ de la campagne (les PJ vivent désormais dans une Partie). */ private loadNpcs(campaignId: string): void { this.npcService.getByCampaign(campaignId).pipe( catchError(() => of([] as Npc[])) ).subscribe(list => this.npcs = list); } - createCharacter(): void { - if (!this.campaign) return; - this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']); + // Sessions retirées de cette vue (vivent dans Playthrough Detail). + private loadSessions(_campaignId: string): void { this.sessions = []; } + + // ─────────────── Playthroughs (Parties) ─────────────── + + openPlaythrough(p: Playthrough): void { + this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', p.id]); + } + + /** Crée une nouvelle Partie avec un nom par défaut, puis y navigue. */ + newPlaythroughInFlight = false; + createPlaythrough(): void { + if (!this.campaign || this.newPlaythroughInFlight) return; + this.newPlaythroughInFlight = true; + const defaultName = this.translate.instant('campaignDetail.defaultPlaythroughName', { n: this.playthroughs.length + 1 }); + this.playthroughService.create({ + campaignId: this.campaign.id!, + name: defaultName + }).subscribe({ + next: created => { + this.newPlaythroughInFlight = false; + this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', created.id]); + }, + error: () => { this.newPlaythroughInFlight = false; } + }); } createNpc(): void { @@ -199,17 +241,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id, 'edit']); } - editCharacter(character: Character): void { - if (!this.campaign || !character.id) return; - this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']); - } - - /** Ouvre la vue lecture seule (style WorldAnvil) — clic sur la carte. */ - viewCharacter(character: Character): void { - if (!this.campaign || !character.id) return; - this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id]); - } - viewNpc(npc: Npc): void { if (!this.campaign || !npc.id) return; this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id]); @@ -245,13 +276,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { return '(Fiche vide)'; } - /** Alias gardé pour compatibilité avec les anciens templates. */ - characterSnippet(c: Character): string { - return this.personaSnippet(c); - } - 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 ─────────────── @@ -324,20 +350,18 @@ export class CampaignDetailComponent implements OnInit, OnDestroy { const newGameSystemId = this.editGameSystemId ? this.editGameSystemId : null; const currentGameSystemId = this.campaign.gameSystemId ?? null; const gameSystemChanged = newGameSystemId !== currentGameSystemId; - const hasSheets = this.characters.length > 0 || this.npcs.length > 0; + const hasSheets = this.npcs.length > 0; if (gameSystemChanged && hasSheets) { - const count = this.characters.length + this.npcs.length; + 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; @@ -376,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.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`); + 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; diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html new file mode 100644 index 0000000..7d372bc --- /dev/null +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html @@ -0,0 +1,240 @@ +
+ + + + + @if (!reviewing) { +
+ + + + @if (importing) { +
+

{{ importPhase }}

+ @if (importProgress) { +
+
+
+
+ } + @if (importStatus) { +

{{ importStatus }}

+ } + @if (importCounts) { +

+ {{ 'campaignImport.foundSoFar' | translate:{ arcs: importCounts.arcs, chapters: importCounts.chapters, scenes: importCounts.scenes, npcs: importCounts.npcs } }} +

+ } +
+ } + @if (importError) { +

{{ importError }}

+ } +
+ } + + + @if (reviewing) { +
+
+

+ @if (npcs.length > 0) {} +

+
+
+ + @for (arc of tree; track arc; let ai = $index) { +
+
+ + + + @if (arc.existing) { + {{ 'campaignImport.alreadyPresent' | translate }} + } + @if (!arc.existing) { + + } +
+ @if (!arc.collapsed) { +
+ @if (!arc.existing) { +
+ {{ 'campaignImport.typeLabel' | translate }} + + +
+ } + @if (!arc.existing) { + + } + + @for (chapter of arc.chapters; track chapter; let ci = $index) { +
+
+ + + + @if (chapter.existing) { + {{ 'campaignImport.alreadyPresent' | translate }} + } + @if (!chapter.existing) { + + } +
+ @if (!chapter.collapsed) { +
+ @if (!chapter.existing) { + + } + + @for (scene of chapter.scenes; track scene; let si = $index) { +
+
+ +
+ + @if (!scene.existing) { + + } +
+ @if (scene.existing) { + {{ 'campaignImport.alreadyPresent' | translate }} + } + @if (!scene.existing) { + + } + @if (!scene.existing) { + + } +
+ @if (scene.detailsOpen && !scene.existing) { +
+ + + + + + {{ 'campaignImport.roomsLabel' | translate }} +
+ @for (room of scene.rooms; track room; let ri = $index) { +
+ + + + + +
+ } + +
+
+ } +
+ } + +
+ } +
+ } + +
+ } +
+ } + +
+ + + @if (npcs.length > 0) { +
+

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

+

{{ 'campaignImport.npcReviewHint' | translate }}

+
    + @for (npc of npcs; track npc.name) { +
  • + + @if (npc.description) { +

    {{ npc.description }}

    + } +
  • + } +
+
+ } + + @if (applyError) { +

{{ applyError }}

+ } +
+ + +
+
+} + +
diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss new file mode 100644 index 0000000..aef67f9 --- /dev/null +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.scss @@ -0,0 +1,379 @@ +.import-page { + padding: 2rem 2.5rem; + max-width: 900px; +} + +.page-header { + margin-bottom: 1.5rem; + + h1 { margin: 0.6rem 0 0.3rem; font-size: 1.5rem; color: #f3f4f6; } +} + +.btn-back { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: none; + color: #9ca3af; + cursor: pointer; + font-size: 0.85rem; + padding: 0; + + &:hover { color: #c4b5fd; } +} + +.subtitle { margin: 0; color: #9ca3af; font-size: 0.9rem; } + +// --- Upload ----------------------------------------------------------------- +.upload-area { + padding: 2rem; + border: 1px dashed #374151; + border-radius: 12px; + text-align: center; + background: #0b1220; +} + +.btn-primary, +.btn-secondary { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.6rem 1.1rem; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + border: none; +} + +.btn-primary { + background: #6c63ff; + color: white; + &:hover:not(:disabled) { background: #5b52e0; } + &:disabled { opacity: 0.6; cursor: progress; } + + &.big { padding: 0.8rem 1.4rem; font-size: 0.95rem; } +} + +.btn-secondary { + background: #1f2937; + color: #e5e7eb; + border: 1px solid #374151; + &:hover { background: #273244; } +} + +.import-progress { + margin: 1.25rem auto 0; + max-width: 460px; + text-align: left; +} + +.import-phase { margin: 0 0 0.5rem; color: #c4b5fd; font-size: 0.85rem; font-weight: 500; } + +.progress-bar { + height: 6px; + background: #1f2937; + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: #6c63ff; + border-radius: 999px; + transition: width 0.3s ease; +} + +.import-counts { margin: 0.55rem 0 0; color: #9ca3af; font-size: 0.8rem; } + +// Message d'attente live (fournisseur saturé → retry, morceau re-découpé…) : +// ambre pour signaler « ça travaille, mais il se passe quelque chose ». +.import-status { + margin: 0.55rem 0 0; + color: #fbbf24; + font-size: 0.8rem; + font-style: italic; + line-height: 1.4; +} + +.import-error, +.apply-error { + margin: 1rem 0 0; + padding: 0.55rem 0.8rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.35); + border-radius: 8px; + color: #fca5a5; + font-size: 0.85rem; +} + +// --- Revue (arbre) ---------------------------------------------------------- +.review-summary { margin: 0 0 1rem; color: #d1d5db; font-size: 0.9rem; strong { color: #fff; } em { color: #9ca3af; font-style: italic; } } + +// Nœuds déjà présents dans la campagne : grisés, lecture seule. +.existing-node { + opacity: 0.6; + + .node-name { background: transparent; border-color: transparent; cursor: default; } +} + +.badge-existing { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #9ca3af; + background: rgba(255, 255, 255, 0.06); + border: 1px solid #1f2937; + border-radius: 999px; + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + +.tree { display: flex; flex-direction: column; gap: 0.75rem; } + +.arc-card { + border: 1px solid #1f2937; + border-radius: 10px; + background: #0b1220; + overflow: hidden; +} + +.node-head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.7rem; +} + +.arc-head { background: #111827; } +.chapter-head { background: rgba(255, 255, 255, 0.02); } + +.btn-collapse { + background: transparent; + border: none; + color: #9ca3af; + cursor: pointer; + padding: 0; + display: flex; + &:hover { color: #c4b5fd; } +} + +.node-icon { color: #a78bfa; flex-shrink: 0; } +.scene-icon { color: #6b7280; } + +.node-name { + flex: 1; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 6px; + color: #f3f4f6; + padding: 0.35rem 0.5rem; + font-size: 0.88rem; + + &:focus { outline: none; border-color: #6c63ff; } +} + +.btn-remove { + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + padding: 0.2rem; + display: flex; + &:hover { color: #f87171; } +} + +.node-body { padding: 0.5rem 0.7rem 0.7rem 1.7rem; display: flex; flex-direction: column; gap: 0.5rem; } + +.node-desc, +.node-desc-inline { + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 6px; + color: #d1d5db; + padding: 0.35rem 0.5rem; + font-size: 0.82rem; + font-family: inherit; + resize: vertical; + &:focus { outline: none; border-color: #6c63ff; } +} + +.chapter-card { + border: 1px solid #1f2937; + border-radius: 8px; + overflow: hidden; +} + +// Sélecteur Linéaire / Hub +.arc-type-toggle { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.toggle-label { color: #9ca3af; font-size: 0.8rem; } + +.type-btn { + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 6px; + color: #9ca3af; + cursor: pointer; + font-size: 0.78rem; + padding: 0.3rem 0.6rem; + + &:hover { border-color: #6c63ff; } + &.active { background: rgba(108, 99, 255, 0.18); border-color: #6c63ff; color: #c4b5fd; font-weight: 600; } +} + +.scene-card { + border: 1px solid #161e2e; + border-radius: 6px; + padding: 0.3rem 0.4rem; +} + +.scene-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.btn-details { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: transparent; + border: 1px solid #1f2937; + border-radius: 6px; + color: #9ca3af; + cursor: pointer; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + white-space: nowrap; + + &:hover { color: #c4b5fd; border-color: #6c63ff; } +} + +.scene-details { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin: 0.4rem 0 0.2rem 1.5rem; + padding-left: 0.6rem; + border-left: 2px solid #1f2937; +} + +.field-label { + color: #9ca3af; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-top: 0.3rem; +} + +.rooms { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.room-row { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.room-name, +.room-field { + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 5px; + color: #d1d5db; + padding: 0.25rem 0.4rem; + font-size: 0.78rem; + &:focus { outline: none; border-color: #6c63ff; } +} + +.room-name { flex: 0 0 22%; } +.room-field { flex: 1; min-width: 0; } + +.scene-fields { + flex: 1; + display: flex; + gap: 0.4rem; + + .node-name { flex: 0 0 40%; } + .node-desc-inline { flex: 1; } +} + +.btn-add, +.btn-add-inline { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background: transparent; + border: 1px dashed #374151; + border-radius: 6px; + color: #9ca3af; + cursor: pointer; + font-size: 0.8rem; + padding: 0.35rem 0.6rem; + align-self: flex-start; + + &:hover { color: #c4b5fd; border-color: #6c63ff; } +} + +.btn-add { margin-top: 0.25rem; } + +.review-actions { + display: flex; + gap: 0.6rem; + margin-top: 1.5rem; +} + +// --- Revue des PNJ détectés dans le PDF ------------------------------------ +.npc-review { + margin-top: 1.25rem; + padding: 1rem; + border: 1px solid var(--border-color, #2e2e3a); + border-radius: 8px; + + h3 { margin: 0 0 0.25rem; font-size: 1rem; } + .hint { margin: 0 0 0.75rem; font-size: 0.85rem; opacity: 0.75; } +} + +.npc-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.npc-row { + padding: 0.5rem 0.65rem; + border-radius: 6px; + background: rgba(255, 255, 255, 0.03); + + &.npc-existing { opacity: 0.55; } + + .npc-check { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + } + + .npc-tag { + font-size: 0.75rem; + opacity: 0.7; + } + + .npc-desc { + margin: 0.3rem 0 0 1.6rem; + font-size: 0.85rem; + opacity: 0.85; + } +} diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts new file mode 100644 index 0000000..480d8af --- /dev/null +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts @@ -0,0 +1,428 @@ +import { Component, OnInit } from '@angular/core'; + +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +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'; +import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model'; +import { CampaignImportProposal } from '../../../services/campaign-import.model'; +import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +/** + * Nœuds éditables (= proposition + état d'UI). `existing` = déjà présent dans la + * campagne (chargé pour la revue) : lecture seule, sert de parent aux ajouts. + * `existingId` = l'ID de l'entité existante (envoyé à l'apply pour s'y rattacher). + */ +interface RoomNode { name: string; description: string; enemies: string; loot: string; } +interface SceneNode { + name: string; description: string; playerNarration: string; gmNotes: string; + rooms: RoomNode[]; detailsOpen: boolean; existing: boolean; existingId?: string; +} +interface ChapterNode { + name: string; description: string; scenes: SceneNode[]; collapsed: boolean; + existing: boolean; existingId?: string; +} +interface ArcNode { + name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean; + existing: boolean; existingId?: string; +} +/** + * PNJ détecté dans le PDF, à cocher à la revue. `existing` = un PNJ du même nom + * existe déjà dans la campagne (décoché et non créable, anti-doublon). + */ +interface NpcNode { name: string; description: string; selected: boolean; existing: boolean; } + +/** + * Page d'import d'un PDF de campagne → arbre arc/chapitre/scène. + * Route : /campaigns/:campaignId/import + * + * Flux : upload → progression streamée → arbre éditable (revue) → création. + * Rien n'est créé tant que l'utilisateur n'a pas validé « Créer dans la campagne ». + */ +@Component({ + selector: 'app-campaign-import', + imports: [FormsModule, LucideAngularModule, TranslatePipe], + templateUrl: './campaign-import.component.html', + styleUrls: ['./campaign-import.component.scss'] +}) +export class CampaignImportComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Upload = Upload; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly ChevronDown = ChevronDown; + readonly ChevronRight = ChevronRight; + readonly Swords = Swords; + readonly BookOpen = BookOpen; + readonly MapPin = MapPin; + readonly Check = Check; + + campaignId = ''; + + // --- État import (streaming) --- + importing = false; + importPhase = ''; + importProgress: { current: number; total: number } | null = null; + /** + * Dernier message de statut du flux (fournisseur saturé → retry, morceau + * re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE + * en cours, pas l'historique. + */ + importStatus: string | null = null; + importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null; + importError: string | null = null; + /** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */ + reviewing = false; + + // --- Arbre éditable --- + tree: ArcNode[] = []; + + /** PNJ détectés dans le PDF (revue par cases à cocher). */ + npcs: NpcNode[] = []; + + /** Structure actuelle de la campagne (chargée pour la fusion à la revue). */ + private existingData: CampaignTreeData | null = null; + + // --- État application (création) --- + applying = false; + applyError: string | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: CampaignImportService, + private campaignService: CampaignService, + private characterService: CharacterService, + private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, + private campaignSidebar: CampaignSidebarService, + private pageTitle: PageTitleService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId')!; + this.pageTitle.set(this.translate.instant('campaignImport.pageTitle')); + this.campaignSidebar.show(this.campaignId); + + // Pré-chargement de l'arborescence existante (pour fusionner à la revue). + // En cas d'échec on dégrade : tout sera considéré comme nouveau. + loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) + .pipe(catchError(() => of(null))) + .subscribe(data => this.existingData = data); + } + + // --- Upload + streaming -------------------------------------------------- + + onPdfSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (file) this.importPdf(file); + } + + private importPdf(file: File): void { + this.importing = true; + this.reviewing = false; + this.importError = null; + this.applyError = null; + this.importPhase = this.translate.instant('campaignImport.phaseExtracting'); + this.importProgress = null; + this.importStatus = null; + this.importCounts = null; + this.tree = []; + + this.service.importStructureStream(this.campaignId, file).subscribe({ + next: (ev) => { + if (ev.type === 'progress') { + // Un morceau vient d'aboutir : le message d'attente est obsolète. + this.importStatus = null; + if (ev.total === 0) { + this.importPhase = this.translate.instant('campaignImport.phaseExtracting'); + this.importProgress = null; + } else { + 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, + scenes: ev.sceneCount, npcs: ev.npcCount ?? 0 + }; + } + } else if (ev.type === 'status') { + this.importStatus = ev.message; + } else if (ev.type === 'done') { + this.importing = false; + this.importPhase = ''; + this.importProgress = null; + this.importStatus = null; + if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) { + this.importError = this.translate.instant('campaignImport.errorNoStructure'); + this.reviewing = false; + } else { + this.tree = this.buildMergedTree(ev.arcs ?? []); + this.npcs = this.buildNpcNodes(ev.npcs ?? []); + this.reviewing = true; + } + } + }, + error: (err: Error) => { + this.importing = false; + this.importPhase = ''; + this.importProgress = null; + this.importError = err?.message + ? this.translate.instant('campaignImport.errorImportDetail', { message: err.message }) + : this.translate.instant('campaignImport.errorImport'); + } + }); + } + + // --- Construction de l'arbre fusionné (existant + proposition) ----------- + + /** + * Construit l'arbre de revue : d'abord l'arborescence ACTUELLE de la campagne + * (nœuds `existing`, lecture seule), puis on y fusionne la proposition par NOM + * (insensible à la casse). Ce qui matche un nœud existant est rattaché ; ce qui + * ne matche pas devient un nouveau nœud éditable. + */ + private buildMergedTree(proposalArcs: ArcProposal[]): ArcNode[] { + const byOrder = (a: { order?: number }, b: { order?: number }) => (a.order ?? 0) - (b.order ?? 0); + const arcs: ArcNode[] = []; + + // 1. Arbre existant. + const data = this.existingData; + if (data) { + for (const arc of [...data.arcs].sort(byOrder)) { + const chapters: ChapterNode[] = []; + for (const ch of [...(data.chaptersByArc[arc.id!] ?? [])].sort(byOrder)) { + const scenes: SceneNode[] = []; + for (const sc of [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder)) { + scenes.push(this.existingSceneNode(sc.id!, sc.name, sc.description)); + } + chapters.push({ + name: ch.name, description: ch.description ?? '', scenes, + collapsed: true, existing: true, existingId: ch.id + }); + } + arcs.push({ + name: arc.name, description: arc.description ?? '', + type: (arc.type === 'HUB' ? 'HUB' : 'LINEAR'), chapters, + collapsed: true, existing: true, existingId: arc.id + }); + } + } + + // 2. Fusion de la proposition. + for (const pa of proposalArcs ?? []) { + const match = arcs.find(a => a.existing && this.sameName(a.name, pa.name)); + if (match) { + this.mergeChaptersInto(match, pa.chapters ?? []); + match.collapsed = false; + } else { + arcs.push(this.newArcNode(pa)); + } + } + return arcs; + } + + private mergeChaptersInto(arc: ArcNode, propChapters: ChapterProposal[]): void { + for (const pc of propChapters) { + const match = arc.chapters.find(c => c.existing && this.sameName(c.name, pc.name)); + if (match) { + this.mergeScenesInto(match, pc.scenes ?? []); + match.collapsed = false; + } else { + arc.chapters.push(this.newChapterNode(pc)); + } + } + } + + private mergeScenesInto(chapter: ChapterNode, propScenes: SceneProposal[]): void { + for (const ps of propScenes) { + // Scène de même nom déjà présente → on ne duplique pas (dédup). + if (chapter.scenes.some(s => this.sameName(s.name, ps.name))) continue; + chapter.scenes.push(this.newSceneNode(ps)); + } + } + + private sameName(a: string, b: string): boolean { + return (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase(); + } + + /** + * Construit la liste de revue des PNJ : cochés par défaut, sauf ceux dont le + * nom existe déjà dans la campagne (anti-doublon, affichés grisés). + */ + private buildNpcNodes(proposals: NpcProposal[]): NpcNode[] { + const existingNames = new Set( + (this.existingData?.npcs ?? []).map(n => (n.name ?? '').trim().toLowerCase())); + return proposals + .filter(p => (p.name ?? '').trim()) + .map(p => { + const existing = existingNames.has(p.name.trim().toLowerCase()); + return { name: p.name.trim(), description: p.description ?? '', selected: !existing, existing }; + }); + } + + // --- Mappers proposition → nœud NEUF ------------------------------------- + + private newArcNode(a: ArcProposal): ArcNode { + return { + name: a.name ?? '', description: a.description ?? '', + type: (a.type === 'HUB' ? 'HUB' : 'LINEAR'), + collapsed: false, existing: false, + chapters: (a.chapters ?? []).map(c => this.newChapterNode(c)) + }; + } + + private newChapterNode(c: ChapterProposal): ChapterNode { + return { + name: c.name ?? '', description: c.description ?? '', + collapsed: false, existing: false, + scenes: (c.scenes ?? []).map(s => this.newSceneNode(s)) + }; + } + + private newSceneNode(s: SceneProposal): SceneNode { + return { + name: s.name ?? '', description: s.description ?? '', + playerNarration: s.playerNarration ?? '', gmNotes: s.gmNotes ?? '', + detailsOpen: false, existing: false, + rooms: (s.rooms ?? []).map(r => ({ + name: r.name ?? '', description: r.description ?? '', + enemies: r.enemies ?? '', loot: r.loot ?? '' + })) + }; + } + + private existingSceneNode(id: string, name: string, description?: string): SceneNode { + return { + name, description: description ?? '', playerNarration: '', gmNotes: '', + detailsOpen: false, existing: true, existingId: id, rooms: [] + }; + } + + setArcType(arc: ArcNode, type: ArcKind): void { arc.type = type; } + toggleDetails(scene: SceneNode): void { scene.detailsOpen = !scene.detailsOpen; } + addRoom(scene: SceneNode): void { + scene.rooms.push({ name: '', description: '', enemies: '', loot: '' }); + scene.detailsOpen = true; + } + removeRoom(scene: SceneNode, index: number): void { scene.rooms.splice(index, 1); } + + // --- Édition de l'arbre -------------------------------------------------- + + toggleArc(arc: ArcNode): void { arc.collapsed = !arc.collapsed; } + toggleChapter(chapter: ChapterNode): void { chapter.collapsed = !chapter.collapsed; } + + addArc(): void { + this.tree.push({ name: '', description: '', type: 'LINEAR', chapters: [], collapsed: false, existing: false }); + } + removeArc(index: number): void { this.tree.splice(index, 1); } + + addChapter(arc: ArcNode): void { + arc.chapters.push({ name: '', description: '', scenes: [], collapsed: false, existing: false }); + } + removeChapter(arc: ArcNode, index: number): void { arc.chapters.splice(index, 1); } + + addScene(chapter: ChapterNode): void { + chapter.scenes.push({ + name: '', description: '', playerNarration: '', gmNotes: '', rooms: [], detailsOpen: true, existing: false + }); + } + removeScene(chapter: ChapterNode, index: number): void { chapter.scenes.splice(index, 1); } + + /** Compteurs des nœuds NOUVEAUX (= ce qui sera réellement créé). */ + get arcCount(): number { return this.tree.filter(a => !a.existing && a.name.trim()).length; } + get chapterCount(): number { + return this.tree.reduce((n, a) => n + a.chapters.filter(c => !c.existing && c.name.trim()).length, 0); + } + get sceneCount(): number { + return this.tree.reduce((n, a) => + n + a.chapters.reduce((m, c) => m + c.scenes.filter(s => !s.existing && s.name.trim()).length, 0), 0); + } + + /** Nombre de PNJ cochés (= qui seront créés). */ + get npcCount(): number { + return this.npcs.filter(n => n.selected && !n.existing).length; + } + + /** Vrai s'il y a au moins un nœud nouveau à créer (sinon « Créer » désactivé). */ + get hasNewContent(): boolean { + return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0 || this.npcCount > 0; + } + + // --- Application (création) ---------------------------------------------- + + apply(): void { + if (this.applying || !this.hasNewContent) return; + this.applying = true; + this.applyError = null; + + // On envoie l'arbre fusionné COMPLET (existants + nouveaux) : les nœuds + // `existing` portent leur existingId et servent de parents — l'apply ne + // recrée que les nœuds sans existingId. + const proposal: CampaignImportProposal = { + arcs: this.tree + .filter(a => a.name.trim()) + .map(a => ({ + name: a.name.trim(), + description: a.description.trim(), + type: a.type, + existingId: a.existingId ?? null, + chapters: a.chapters + .filter(c => c.name.trim()) + .map(c => ({ + name: c.name.trim(), + description: c.description.trim(), + existingId: c.existingId ?? null, + scenes: c.scenes + .filter(s => s.name.trim()) + .map(s => ({ + name: s.name.trim(), + description: s.description.trim(), + playerNarration: s.playerNarration.trim(), + gmNotes: s.gmNotes.trim(), + existingId: s.existingId ?? null, + rooms: s.rooms + .filter(r => r.name.trim()) + .map(r => ({ + name: r.name.trim(), + description: r.description.trim(), + enemies: r.enemies.trim(), + loot: r.loot.trim() + })) + })) + })) + })), + npcs: this.npcs + .filter(n => n.selected && !n.existing && n.name.trim()) + .map(n => ({ name: n.name.trim(), description: n.description.trim() })) + }; + + this.service.applyStructure(this.campaignId, proposal).subscribe({ + next: () => this.router.navigate(['/campaigns', this.campaignId]), + error: () => { + this.applying = false; + this.applyError = this.translate.instant('campaignImport.errorApply'); + } + }); + } + + cancel(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/campaigns.component.html b/web/src/app/campaigns/campaigns.component.html index 32c6534..1381fa3 100644 --- a/web/src/app/campaigns/campaigns.component.html +++ b/web/src/app/campaigns/campaigns.component.html @@ -2,41 +2,44 @@
-

Vos Campagnes

-

Rejoignez une campagne ou créez-en de nouvelles

+

{{ 'campaigns.heroTitle' | translate }}

+

{{ 'campaigns.heroSubtitle' | translate }}

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

{{ campaign.name }}

+

{{ campaign.description }}

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

{{ campaign.name }}

-

{{ campaign.description }}

-
- ⚔️ {{ campaign.arcCount || 0 }} arcs - 📖 {{ campaign.chapterCount || 0 }} chapitres -
-
+ }
-

Nouvelle Campagne

-

Créez une nouvelle aventure

+

{{ 'campaigns.newCampaign' | translate }}

+

{{ 'campaigns.newCampaignSubtitle' | translate }}

-

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

+

{{ 'campaigns.tip' | translate }}

- - +@if (showCreateModal) { + + +} diff --git a/web/src/app/campaigns/campaigns.component.ts b/web/src/app/campaigns/campaigns.component.ts index 5017df5..992c040 100644 --- a/web/src/app/campaigns/campaigns.component.ts +++ b/web/src/app/campaigns/campaigns.component.ts @@ -1,17 +1,18 @@ import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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'; import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component'; @Component({ - selector: 'app-campaigns', - standalone: true, - imports: [CommonModule, LucideAngularModule, CampaignCreateComponent], - templateUrl: './campaigns.component.html', - styleUrls: ['./campaigns.component.scss'] + selector: 'app-campaigns', + imports: [LucideAngularModule, CampaignCreateComponent, TranslatePipe], + templateUrl: './campaigns.component.html', + styleUrls: ['./campaigns.component.scss'] }) export class CampaignsComponent implements OnInit { readonly Map = Map; @@ -22,10 +23,15 @@ export class CampaignsComponent implements OnInit { constructor( private router: Router, - private campaignService: CampaignService + private campaignService: CampaignService, + private layoutService: LayoutService ) {} ngOnInit(): void { + // Liste racine de la section Campagnes : aucune sidebar secondaire ne + // doit subsister (ex: si on arrive depuis une page Lore qui en affichait + // une, elle persisterait sans ce hide() — cf. bug rapporte 2026-05-19). + this.layoutService.hide(); this.loadCampaigns(); } diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html index 91441e6..aee61b2 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.html @@ -1,43 +1,45 @@
- + + />
- +
- +
- +
diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts index 418e1d6..6ae534b 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts @@ -1,12 +1,15 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; 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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { LayoutService } from '../../../services/layout.service'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; @@ -17,11 +20,10 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; * Route : /campaigns/:campaignId/arcs/:arcId/chapters/create */ @Component({ - selector: 'app-chapter-create', - standalone: true, - imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent], - templateUrl: './chapter-create.component.html', - styleUrls: ['./chapter-create.component.scss'] + selector: 'app-chapter-create', + imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent, TranslatePipe], + templateUrl: './chapter-create.component.html', + styleUrls: ['./chapter-create.component.scss'] }) export class ChapterCreateComponent implements OnInit, OnDestroy { readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; @@ -31,6 +33,8 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { campaignId = ''; arcId = ''; arcName = ''; + /** Arc parent de type hub : un "chapitre" y est présenté comme une "quête". */ + isHub = false; private existingChapterCount = 0; constructor( @@ -40,7 +44,10 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, - private layoutService: LayoutService + private randomTableService: RandomTableService, + private enemyService: EnemyService, + private layoutService: LayoutService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -58,13 +65,14 @@ export class ChapterCreateComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).subscribe(({ campaign, allCampaigns, treeData }) => { const currentArc = treeData.arcs.find(a => a.id === this.arcId); this.arcName = currentArc?.name ?? ''; + this.isHub = currentArc?.type === 'HUB'; this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0; - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html index 2392949..9c8305f 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html @@ -2,132 +2,152 @@
+ +
+

+ {{ (parentArc?.type === 'HUB' ? 'chapterEdit.unlockTitleQuest' : 'chapterEdit.unlockTitleChapter') | translate }} +

+ + {{ (parentArc?.type === 'HUB' ? 'chapterEdit.unlockHintQuest' : 'chapterEdit.unlockHintChapter') | translate }} + + + +
+
- + - Portraits, ambiances, scenes marquantes du chapitre. + {{ 'chapterEdit.illustrationsHint' | translate }}
- + - Cartes regionales, plans de donjon, schemas utiles a la table. + {{ 'chapterEdit.mapsHint' | translate }}
- + + />
- +
- +
- + - Ces notes sont privées et ne seront pas exportées vers FoundryVTT. + {{ 'chapterEdit.gmNotesHint' | translate }}
- +
- +
-
- - - - - Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent. - -
+ @if (loreId) { +
+ + + + + {{ 'chapterEdit.relatedPagesHint' | translate }} + +
+ } -
- - 💡 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. - -
+ @if (!loreId) { +
+ + {{ 'chapterEdit.noLoreHint' | translate }} + +
+ }
@@ -139,7 +159,7 @@ entityType="chapter" [entityId]="chapterId" [isOpen]="chatOpen" - welcomeMessage="Je vois ce chapitre. Demande-moi d'étoffer ses objectifs, ses enjeux ou sa scène d'ouverture." + [welcomeMessage]="'chapterEdit.chatWelcome' | translate" [quickSuggestions]="chatQuickSuggestions" (close)="chatOpen = false"> diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss index 281d355..433318b 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.scss @@ -3,6 +3,57 @@ max-width: 640px; } +// === Section Hub : prérequis + progression === +.hub-section { + border: 1px solid rgba(66, 133, 244, 0.25); + background: rgba(66, 133, 244, 0.03); + border-radius: 12px; + padding: 1rem 1.1rem 1.25rem; + margin-bottom: 1rem; +} + +.hub-section-title { + margin: 0 0 0.85rem; + font-size: 1rem; + font-weight: 600; + color: var(--color-primary, #2c6cd6); +} + +.progression-choice { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; +} + +.progression-option { + padding: 0.5rem 0.7rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 8px; + text-align: center; + font-size: 0.88rem; + cursor: pointer; + background: var(--color-surface, #fff); + transition: border-color 120ms ease, background 120ms ease; + + &:hover { border-color: var(--color-primary, #2c6cd6); } + + &.selected { + border-color: var(--color-primary, #2c6cd6); + background: rgba(66, 133, 244, 0.08); + font-weight: 600; + } + + input[type="radio"] { display: none; } +} + +.hub-flags-panel { + margin-top: 0.75rem; + padding: 0.85rem; + border: 1px dashed var(--color-border, #d8d8d8); + border-radius: 8px; + background: var(--color-surface, #fff); +} + // Header local : titre à gauche, actions (Assistant IA) à droite. .page-header { display: flex; diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts index c50c66c..c872441 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts @@ -1,39 +1,52 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; 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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Chapter } from '../../../services/campaign.model'; +import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model'; import { Page } from '../../../services/page.model'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { PrerequisiteEditorComponent } from '../../../shared/prerequisite-editor/prerequisite-editor.component'; +import { CampaignFlagService } from '../../../services/campaign-flag.service'; import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; /** - * Écran de détail/modification d'un Chapitre. - * Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId + * Écran d'édition d'un Chapitre. Donnée de SCÉNARIO uniquement. * - * Inclut le picker de pages Lore (B2 cross-context) si la campagne parente - * est associée à un Lore. + * Depuis Playthrough : la progression et le toggle des flags se font dans le + * contexte d'une Partie (voir Playthrough Detail). On garde ici uniquement les + * prérequis (conditions de déblocage), qui font partie du scénario. */ @Component({ - selector: 'app-chapter-edit', - standalone: true, - imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent], - templateUrl: './chapter-edit.component.html', - styleUrls: ['./chapter-edit.component.scss'] + selector: 'app-chapter-edit', + imports: [ + ReactiveFormsModule, + LucideAngularModule, + LoreLinkPickerComponent, + AiChatDrawerComponent, + ImageGalleryComponent, + IconPickerComponent, + PrerequisiteEditorComponent, + TranslatePipe +], + templateUrl: './chapter-edit.component.html', + styleUrls: ['./chapter-edit.component.scss'] }) export class ChapterEditComponent implements OnInit, OnDestroy { readonly Trash2 = Trash2; @@ -41,13 +54,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy { readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; selectedIcon: string | null = null; - /** État drawer chat IA (b5.7 — intégration Campagne). */ 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; } @@ -63,6 +71,18 @@ export class ChapterEditComponent implements OnInit, OnDestroy { illustrationImageIds: string[] = []; mapImageIds: string[] = []; + /** Prérequis (donnée de scénario). */ + prerequisites: Prerequisite[] = []; + + /** Quêtes candidates pour QUEST_COMPLETED (chapitres de la campagne, sauf celui-ci). */ + availableQuests: Chapter[] = []; + + /** Faits déclarés au niveau Campagne (autocomplete FLAG_SET). */ + availableFlagNames: string[] = []; + + /** L'arc parent — pour conditionner la section Hub (prérequis pertinents). */ + parentArc: Arc | null = null; + constructor( private fb: FormBuilder, private route: ActivatedRoute, @@ -70,10 +90,14 @@ export class ChapterEditComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private campaignFlagService: CampaignFlagService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -82,12 +106,14 @@ 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 { - // On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular - // réutilise le composant quand on navigue entre chapitres frères via - // l'arbre (même route pattern), et ngOnInit ne se relance pas. this.route.paramMap.subscribe(pm => { const newCampaignId = pm.get('campaignId')!; const newArcId = pm.get('arcId')!; @@ -108,7 +134,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), chapter: this.campaignService.getChapterById(this.chapterId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; @@ -124,6 +150,21 @@ export class ChapterEditComponent implements OnInit, OnDestroy { this.selectedIcon = chapter.icon ?? null; this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])]; this.mapImageIds = [...(chapter.mapImageIds ?? [])]; + + this.prerequisites = [...(chapter.prerequisites ?? [])]; + + const allChapters: Chapter[] = []; + Object.values(treeData.chaptersByArc).forEach(list => allChapters.push(...list)); + this.availableQuests = allChapters.filter(c => c.id !== this.chapterId); + + this.parentArc = treeData.arcs.find(a => a.id === this.arcId) ?? null; + + // Autocomplete des FLAG_SET : les noms de faits déjà référencés ailleurs + // dans la campagne (déduit des autres quêtes). + this.campaignFlagService.listReferenced(this.campaignId).subscribe({ + next: names => { this.availableFlagNames = names; } + }); + this.form.patchValue({ name: chapter.name, description: chapter.description ?? '', @@ -132,10 +173,14 @@ 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)); }); } + onPrerequisitesChange(next: Prerequisite[]): void { + this.prerequisites = next; + } + submit(): void { if (this.form.invalid || !this.chapter) return; this.campaignService.updateChapter(this.chapterId, { @@ -146,6 +191,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy { gmNotes: this.form.value.gmNotes, playerObjectives: this.form.value.playerObjectives, narrativeStakes: this.form.value.narrativeStakes, + prerequisites: this.prerequisites, relatedPageIds: this.relatedPageIds, illustrationImageIds: this.illustrationImageIds, mapImageIds: this.mapImageIds, @@ -158,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; @@ -176,10 +222,5 @@ export class ChapterEditComponent implements OnInit, OnDestroy { this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]); } - ngOnDestroy(): void { - // Volontairement vide : la sidebar reste prise en charge par le composant - // suivant (autre sous-route ou le composant detail parent) qui appellera - // show(). Eviter d'appeler hide() ici previent le clignotement / la - // disparition de la sidebar lors des navigations internes a la section. - } + ngOnDestroy(): void {} } diff --git a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html index dfc0d5f..909cdc7 100644 --- a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.html @@ -2,73 +2,79 @@ -
-

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

-
+ @if (scenes.length === 0) { +
+

{{ 'chapterGraph.empty' | translate }}

+
+ } -
- - - - - - - - - - 0) { +
+ + + + + + + + @for (edge of edges; track edge) { + + - - {{ edge.label }} - + @if (edge.label) { + + {{ edge.label }} + + } + + } - - - - - {{ node.name }} - + @for (node of nodes; track node) { + + {{ node.name }} + - - {{ node.displayName }} - + {{ node.displayName }} + + + } - - - - - 💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas. - -
+ + + {{ 'chapterGraph.hint' | translate }} + +
+ }
diff --git a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts index de29472..ceb0169 100644 --- a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts @@ -1,11 +1,14 @@ import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { LayoutService, GlobalItem } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; import { Campaign, Chapter, Scene } from '../../../services/campaign.model'; @@ -19,11 +22,10 @@ interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: nu * Layout custom (BFS par niveaux) en SVG — évite une dépendance lourde type ngx-graph. */ @Component({ - selector: 'app-chapter-graph', - standalone: true, - imports: [CommonModule, RouterModule, LucideAngularModule], - templateUrl: './chapter-graph.component.html', - styleUrls: ['./chapter-graph.component.scss'] + selector: 'app-chapter-graph', + imports: [RouterModule, LucideAngularModule, TranslatePipe], + templateUrl: './chapter-graph.component.html', + styleUrls: ['./chapter-graph.component.scss'] }) export class ChapterGraphComponent implements OnInit, OnDestroy { readonly ArrowLeft = ArrowLeft; @@ -70,8 +72,11 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, private layoutService: LayoutService, - private pageTitleService: PageTitleService + private pageTitleService: PageTitleService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -89,11 +94,11 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { allCampaigns: this.campaignService.getAllCampaigns(), chapter: this.campaignService.getChapterById(this.chapterId), scenes: this.campaignService.getScenes(this.chapterId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).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) => ({ @@ -101,11 +106,11 @@ export class ChapterGraphComponent implements OnInit, OnDestroy { })); this.layoutService.show({ title: campaign.name, - items: buildCampaignTree(this.campaignId, treeData), - footerLabel: 'Toutes les campagnes', + items: buildCampaignTree(this.campaignId, treeData, this.translate), + footerLabel: this.translate.instant('chapterGraph.allCampaigns'), createActions: [], globalItems, - globalBackLabel: 'Toutes les campagnes', + globalBackLabel: this.translate.instant('chapterGraph.allCampaigns'), globalBackRoute: '/campaigns' }); }); diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html index 5127541..4307993 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html @@ -1,78 +1,123 @@ -
- -
-
-

- - {{ chapter.name }} -

-

Chapitre

-
-
- - - -
-
- - -
- -
- - -
-

🗺️ Cartes & plans

- -
- -
-

📖 Synopsis

-

{{ chapter.description }}

-

Non renseigné

-
- -
+@if (chapter) { +
+
+
+

+ @if (chapter.icon) { + + } + {{ chapter.name }} +

+

+ {{ (parentArc?.type === 'HUB' ? 'chapterView.questHub' : 'chapterView.chapter') | translate }} + @if ((chapter.prerequisites?.length ?? 0) > 0) { + + + {{ 'chapterView.conditional' | translate }} + + } +

+
+
+ + + +
+
+ + @if (parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0) { +
+

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

+ @if ((chapter.prerequisites?.length ?? 0) > 0) { +
    + @for (p of chapter.prerequisites; track p) { +
  • {{ describePrerequisite(p) }}
  • + } +
+ + {{ (parentArc?.type === 'HUB' ? 'chapterView.unlockHintQuest' : 'chapterView.unlockHintChapter') | translate }} + + } @else { +

+ {{ 'chapterView.noConditionQuest' | translate }} +

+

+

+ } +
+ } + + @if ((chapter.illustrationImageIds?.length ?? 0) > 0) { +
+ +
+ } + + @if ((chapter.mapImageIds?.length ?? 0) > 0) { +
+

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

+ +
+ }
-

🎯 Objectifs des joueurs

-

{{ chapter.playerObjectives }}

-

Non renseigné

-
- -
-

Enjeux narratifs

-

{{ chapter.narrativeStakes }}

-

Non renseigné

+

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

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

{{ chapter.description }}

+ } @else { +

{{ 'chapterView.notProvided' | translate }}

+ }
+
+
+

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

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

{{ chapter.playerObjectives }}

+ } @else { +

{{ 'chapterView.notProvided' | translate }}

+ } +
+
+

{{ 'chapterView.narrativeStakes' | translate }}

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

{{ chapter.narrativeStakes }}

+ } @else { +

{{ 'chapterView.notProvided' | translate }}

+ } +
+
+ @if (chapter.gmNotes?.trim()) { +
+

+ 🔒 + {{ 'chapterView.gmNotes' | translate }} +

+

{{ chapter.gmNotes }}

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

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

+
+ @for (relId of chapter.relatedPageIds; track relId) { + + {{ titleOfRelated(relId) }} + + } +
+
+ }
- -
-

- 🔒 - Notes du Maître de Jeu -

-

{{ chapter.gmNotes }}

-
- -
-

🔗 Pages Lore associées

- -
- -
+} diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.scss b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.scss index 9c48f33..a364e96 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.scss +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.scss @@ -1 +1,51 @@ // Styles partagés via styles/_view.scss + +// Badge "Conditionnel" : chapitre porteur de conditions de déblocage. +.cond-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-left: 0.5rem; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + vertical-align: middle; + color: #f0c674; + background: rgba(240, 198, 116, 0.12); + border: 1px solid rgba(240, 198, 116, 0.35); +} + +.quest-status-bar { + border: 1px solid rgba(66, 133, 244, 0.25); + background: rgba(66, 133, 244, 0.04); +} + +.quest-status-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.quest-status-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.quest-prereq-summary { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px dashed rgba(66, 133, 244, 0.2); + font-size: 0.85rem; + color: var(--color-text-muted, #555); + + ul { + margin: 0.3rem 0 0; + padding-left: 1.2rem; + + li { margin-bottom: 0.15rem; } + } +} diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts index 537c1aa..99e0571 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts @@ -1,17 +1,20 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular'; +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'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Chapter } from '../../../services/campaign.model'; +import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model'; import { Page } from '../../../services/page.model'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; @@ -22,25 +25,27 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia * Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId */ @Component({ - selector: 'app-chapter-view', - standalone: true, - imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent], - templateUrl: './chapter-view.component.html', - styleUrls: ['./chapter-view.component.scss'] + selector: 'app-chapter-view', + imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe], + templateUrl: './chapter-view.component.html', + styleUrls: ['./chapter-view.component.scss'] }) export class ChapterViewComponent implements OnInit, OnDestroy { readonly Pencil = Pencil; readonly Network = Network; readonly Trash2 = Trash2; + readonly Lock = Lock; readonly resolveCampaignIcon = resolveCampaignIcon; campaignId = ''; arcId = ''; chapterId = ''; chapter: Chapter | null = null; + parentArc: Arc | null = null; loreId: string | null = null; availablePages: Page[] = []; + private allChaptersById: Record = {}; constructor( private route: ActivatedRoute, @@ -48,10 +53,13 @@ export class ChapterViewComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -75,7 +83,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), chapter: this.campaignService.getChapterById(this.chapterId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; @@ -88,12 +96,33 @@ export class ChapterViewComponent implements OnInit, OnDestroy { this.availablePages = pages; this.pageTitleService.set(chapter.name); - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + // Arc parent (pour conditionner les sections Hub) + index des quêtes par id. + this.parentArc = treeData.arcs.find(a => a.id === this.arcId) ?? null; + this.allChaptersById = {}; + Object.values(treeData.chaptersByArc).forEach(list => + list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; }) + ); + + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } + describePrerequisite(p: Prerequisite): string { + switch (p.kind) { + case 'QUEST_COMPLETED': + return this.translate.instant('chapterView.prereqQuestCompleted', { + name: this.allChaptersById[p.questId]?.name ?? '?' + }); + case 'SESSION_REACHED': + return this.translate.instant('chapterView.prereqSessionReached', { n: p.minSessionNumber }); + case 'FLAG_SET': + 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 { @@ -119,15 +148,16 @@ export class ChapterViewComponent implements OnInit, OnDestroy { next: impact => { const details: string[] = []; if (impact.scenes > 0) { - details.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`); + const key = impact.scenes > 1 ? 'chapterView.deleteScenesPlural' : 'chapterView.deleteScenes'; + details.push(this.translate.instant(key, { n: impact.scenes })); } - details.push('Cette action est irréversible.'); + details.push(this.translate.instant('chapterView.irreversible')); this.confirmDialog.confirm({ - title: 'Supprimer le chapitre', - message: `Supprimer le chapitre "${chapter.name}" ?`, + title: this.translate.instant('chapterView.deleteChapterTitle'), + message: this.translate.instant('chapterView.deleteChapterMessage', { name: chapter.name }), details, - confirmLabel: 'Supprimer', + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.html b/web/src/app/campaigns/character/character-edit/character-edit.component.html index 35cb166..0492ee3 100644 --- a/web/src/app/campaigns/character/character-edit/character-edit.component.html +++ b/web/src/app/campaigns/character/character-edit/character-edit.component.html @@ -3,100 +3,103 @@

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

- + @if (characterId) { + + }
- + -
- -
-
- - - + [placeholder]="'characterEdit.namePlaceholder' | translate" + />
-
- - - + +
+
+ + + +
+
+ + + +
-
-
- - -
+
+ + +
+ +
+ + + + @if (characterId) { + + } +
-
- - - -
-
- - - + @if (characterId && campaignId) { + + + } diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.ts b/web/src/app/campaigns/character/character-edit/character-edit.component.ts index 5cc9a52..489beea 100644 --- a/web/src/app/campaigns/character/character-edit/character-edit.component.ts +++ b/web/src/app/campaigns/character/character-edit/character-edit.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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'; @@ -25,11 +26,10 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia * saisie manuelle d'IDs d'images. */ @Component({ - selector: 'app-character-edit', - standalone: true, - imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent], - templateUrl: './character-edit.component.html', - styleUrls: ['./character-edit.component.scss'] + selector: 'app-character-edit', + imports: [FormsModule, LucideAngularModule, TranslatePipe, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent], + templateUrl: './character-edit.component.html', + styleUrls: ['./character-edit.component.scss'] }) export class CharacterEditComponent implements OnInit { readonly Save = Save; @@ -39,15 +39,19 @@ 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; } campaignId: string | null = null; + /** Partie propriétaire du PJ — lue depuis la route (les PJ sont scoping Playthrough). */ + playthroughId: string | null = null; characterId: string | null = null; name = ''; @@ -66,12 +70,14 @@ 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 { const params = this.route.snapshot.paramMap; this.campaignId = params.get('campaignId'); + this.playthroughId = params.get('playthroughId'); this.characterId = params.get('characterId'); if (this.campaignId) { @@ -113,7 +119,7 @@ export class CharacterEditComponent implements OnInit { submit(): void { - if (!this.name.trim() || !this.campaignId) return; + if (!this.name.trim() || !this.campaignId || !this.playthroughId) return; const payload = { name: this.name.trim(), portraitImageId: this.portraitImageId, @@ -121,7 +127,7 @@ export class CharacterEditComponent implements OnInit { values: this.values, imageValues: this.imageValues, keyValueValues: this.keyValueValues, - campaignId: this.campaignId + playthroughId: this.playthroughId }; const isCreation = !this.characterId; const req = this.characterId @@ -130,7 +136,7 @@ export class CharacterEditComponent implements OnInit { req.subscribe({ next: (saved) => { if (isCreation && saved.id) { - this.router.navigate(['/campaigns', this.campaignId, 'characters', saved.id]); + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'characters', saved.id]); } else { this.back(); } @@ -142,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; @@ -157,7 +163,9 @@ export class CharacterEditComponent implements OnInit { } back(): void { - if (this.campaignId) { + if (this.campaignId && this.playthroughId) { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId]); + } else if (this.campaignId) { this.router.navigate(['/campaigns', this.campaignId]); } else { this.router.navigate(['/campaigns']); diff --git a/web/src/app/campaigns/character/character-view/character-view.component.html b/web/src/app/campaigns/character/character-view/character-view.component.html index 1c72258..773a3a0 100644 --- a/web/src/app/campaigns/character/character-view/character-view.component.html +++ b/web/src/app/campaigns/character/character-view/character-view.component.html @@ -2,27 +2,30 @@
- + @if (characterId) { + + }
- - +@if (characterId && campaignId) { + + +} diff --git a/web/src/app/campaigns/character/character-view/character-view.component.ts b/web/src/app/campaigns/character/character-view/character-view.component.ts index 5c55b43..dd83e38 100644 --- a/web/src/app/campaigns/character/character-view/character-view.component.ts +++ b/web/src/app/campaigns/character/character-view/character-view.component.ts @@ -1,7 +1,8 @@ import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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'; @@ -13,14 +14,13 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr /** * Vue lecture seule "WorldAnvil" d'une fiche PJ. - * Route : /campaigns/:campaignId/characters/:characterId + * Route : /campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId */ @Component({ - selector: 'app-character-view', - standalone: true, - imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent], - templateUrl: './character-view.component.html', - styleUrls: ['./character-view.component.scss'] + selector: 'app-character-view', + imports: [LucideAngularModule, TranslatePipe, PersonaViewComponent, AiChatDrawerComponent], + templateUrl: './character-view.component.html', + styleUrls: ['./character-view.component.scss'] }) export class CharacterViewComponent implements OnInit { readonly ArrowLeft = ArrowLeft; @@ -28,6 +28,7 @@ export class CharacterViewComponent implements OnInit { readonly Sparkles = Sparkles; campaignId: string | null = null; + playthroughId: string | null = null; characterId: string | null = null; character: Character | null = null; @@ -48,6 +49,7 @@ export class CharacterViewComponent implements OnInit { ngOnInit(): void { const params = this.route.snapshot.paramMap; this.campaignId = params.get('campaignId'); + this.playthroughId = params.get('playthroughId'); this.characterId = params.get('characterId'); if (this.characterId) { this.service.getById(this.characterId).subscribe({ @@ -68,13 +70,15 @@ export class CharacterViewComponent implements OnInit { } edit(): void { - if (this.campaignId && this.characterId) { - this.router.navigate(['/campaigns', this.campaignId, 'characters', this.characterId, 'edit']); + if (this.campaignId && this.playthroughId && this.characterId) { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'characters', this.characterId, 'edit']); } } back(): void { - if (this.campaignId) { + if (this.campaignId && this.playthroughId) { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId]); + } else if (this.campaignId) { this.router.navigate(['/campaigns', this.campaignId]); } else { this.router.navigate(['/campaigns']); diff --git a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html new file mode 100644 index 0000000..01a49db --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html @@ -0,0 +1,111 @@ +
+ +
+ +
+

+ + {{ (enemyId ? 'enemyEdit.titleEdit' : 'enemyEdit.titleNew') | translate }} +

+
+
+ +
+ +
+ + +
+ +
+
+ + +
+
+ + + + @for (f of existingFolders; track f) { + + } + +
+
+ +
+
+ + + +
+
+ + + +
+
+ +
+ + +
+ +
+ + + + @if (enemyId) { + + } +
+ +
+ +
diff --git a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss new file mode 100644 index 0000000..0a69ca5 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss @@ -0,0 +1,157 @@ +.ne-page { + padding: 2rem 3rem; + color: #e5e7eb; + max-width: 1000px; + margin: 0 auto; +} + +.ne-header { + margin-bottom: 2rem; + + .header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + } + + h1 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.75rem; + color: white; + margin: 0.75rem 0 0; + } +} + +.btn-ai { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: rgba(167, 139, 250, 0.08); + border: 1px solid rgba(167, 139, 250, 0.4); + color: #a78bfa; + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.15s; + + &:hover { background: rgba(167, 139, 250, 0.15); border-color: #a78bfa; } + &.active { background: #a78bfa; color: #0b1220; } +} + +.btn-back { + background: transparent; + border: none; + color: #9ca3af; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0; + font-size: 0.85rem; + + &:hover { color: #e5e7eb; } +} + +.ne-form { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.field { + display: flex; + flex-direction: column; + + label { + color: #e5e7eb; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.4rem; + } + + .hint { + color: #6b7280; + font-size: 0.8rem; + margin: 0.4rem 0 0.5rem; + } + + input[type="text"], textarea { + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 8px; + color: #e5e7eb; + padding: 0.6rem 0.75rem; + font-size: 0.95rem; + font-family: inherit; + + &:focus { + outline: none; + border-color: #a78bfa; + } + } +} + +.content-field textarea { + font-family: 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.85rem; + line-height: 1.5; + resize: vertical; +} + +.actions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; + align-items: center; + + .spacer { flex: 1; } +} + +.btn-primary { + background: #a78bfa; + color: #0b1220; + border: none; + padding: 0.6rem 1.25rem; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; + + &:disabled { opacity: 0.5; cursor: not-allowed; } + &:hover:not(:disabled) { background: #c4b5fd; } +} + +.btn-secondary { + background: transparent; + border: 1px solid #1f2937; + color: #9ca3af; + padding: 0.6rem 1.25rem; + border-radius: 8px; + cursor: pointer; + + &:hover { border-color: #374151; color: #e5e7eb; } +} + +.btn-danger { + background: transparent; + border: 1px solid rgba(248, 113, 113, 0.3); + color: #f87171; + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + + &:hover { + border-color: #f87171; + background: rgba(248, 113, 113, 0.08); + } +} diff --git a/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts new file mode 100644 index 0000000..b01c02d --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts @@ -0,0 +1,169 @@ +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'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { TemplateField } from '../../../services/template.model'; +import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component'; +import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; + +/** + * Editeur plein écran d'une fiche d'ennemi (bestiaire). Même principe que + * NpcEditComponent : formulaire dynamique piloté par le template ENNEMI du + * GameSystem associé à la campagne, + champs universels niveau/dossier. + */ +@Component({ + selector: 'app-enemy-edit', + imports: [FormsModule, LucideAngularModule, TranslatePipe, DynamicFieldsFormComponent, SingleImagePickerComponent], + templateUrl: './enemy-edit.component.html', + styleUrls: ['./enemy-edit.component.scss'] +}) +export class EnemyEditComponent implements OnInit { + readonly Save = Save; + readonly ArrowLeft = ArrowLeft; + readonly Skull = Skull; + readonly Trash2 = Trash2; + + campaignId: string | null = null; + enemyId: string | null = null; + + name = ''; + level = ''; + folder = ''; + /** Dossiers déjà utilisés dans la campagne (datalist d'auto-complétion). */ + existingFolders: string[] = []; + portraitImageId: string | null = null; + headerImageId: string | null = null; + values: Record = {}; + imageValues: Record = {}; + keyValueValues: Record> = {}; + templateFields: TemplateField[] = []; + private order = 0; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: EnemyService, + private campaignService: CampaignService, + private gameSystemService: GameSystemService, + private campaignSidebar: CampaignSidebarService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.enemyId = params.get('enemyId'); + + if (this.campaignId) { + this.loadTemplateForCampaign(this.campaignId); + this.campaignSidebar.show(this.campaignId); + this.loadExistingFolders(this.campaignId); + } + + if (this.enemyId) { + this.service.getById(this.enemyId).subscribe({ + next: (e) => { + this.name = e.name; + this.level = e.level ?? ''; + this.folder = e.folder ?? ''; + this.portraitImageId = e.portraitImageId ?? null; + this.headerImageId = e.headerImageId ?? null; + this.values = e.values ?? {}; + this.imageValues = e.imageValues ?? {}; + this.keyValueValues = e.keyValueValues ?? {}; + this.order = e.order ?? 0; + }, + error: () => this.back() + }); + } + } + + private loadExistingFolders(campaignId: string): void { + this.service.getByCampaign(campaignId).subscribe({ + next: (list) => { + this.existingFolders = [...new Set( + list.map(e => (e.folder ?? '').trim()).filter(f => f.length > 0) + )].sort((a, b) => a.localeCompare(b, 'fr')); + }, + error: () => { this.existingFolders = []; } + }); + } + + private loadTemplateForCampaign(campaignId: string): void { + this.campaignService.getCampaignById(campaignId).subscribe({ + next: (campaign) => { + if (!campaign.gameSystemId) { + this.templateFields = []; + return; + } + this.gameSystemService.getById(campaign.gameSystemId).subscribe({ + next: (gs) => { this.templateFields = gs.enemyTemplate ?? []; }, + error: () => { this.templateFields = []; } + }); + }, + error: () => { this.templateFields = []; } + }); + } + + submit(): void { + if (!this.name.trim() || !this.campaignId) return; + const payload = { + name: this.name.trim(), + level: this.level.trim() || null, + folder: this.folder.trim() || null, + portraitImageId: this.portraitImageId, + headerImageId: this.headerImageId, + values: this.values, + imageValues: this.imageValues, + keyValueValues: this.keyValueValues, + campaignId: this.campaignId + }; + const isCreation = !this.enemyId; + const req = this.enemyId + ? this.service.update(this.enemyId, { ...payload, id: this.enemyId, order: this.order }) + : this.service.create(payload); + req.subscribe({ + next: (saved) => { + if (isCreation && saved.id) { + this.router.navigate(['/campaigns', this.campaignId, 'enemies', saved.id]); + } else { + this.back(); + } + }, + error: () => console.error('Erreur sauvegarde Enemy') + }); + } + + deleteEnemy(): void { + if (!this.enemyId) return; + this.confirmDialog.confirm({ + 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; + this.service.delete(this.enemyId).subscribe({ + next: () => this.back(), + error: () => console.error('Erreur suppression Enemy') + }); + }); + } + + back(): void { + if (this.campaignId) { + this.router.navigate(['/campaigns', this.campaignId, 'enemies']); + } else { + this.router.navigate(['/campaigns']); + } + } +} diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html new file mode 100644 index 0000000..58bc7a2 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html @@ -0,0 +1,47 @@ +
+
+ + + +
+ +
+

{{ 'enemyList.title' | translate }}

+

+ {{ 'enemyList.hint' | translate }} +

+
+ +
+ @if (total === 0) { +

{{ 'enemyList.empty' | translate }}

+ } + @for (g of groups; track g.folder) { + @if (g.folder) { +

+ + {{ g.folder }} + {{ g.enemies.length }} +

+ } @else if (groups.length > 1) { +

{{ 'enemyList.unclassified' | translate }}

+ } + @for (e of g.enemies; track e.id) { + + } + } +
+
diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss new file mode 100644 index 0000000..501899e --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss @@ -0,0 +1,53 @@ +.sbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.sbl-toolbar { + display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; + .spacer { flex: 1; } + button { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.8rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + } + .btn-create { background: #667eea; border-color: #667eea; color: #fff; } + .btn-create:hover { background: #5568d3; } +} + +.sbl-header { + margin-bottom: 1.25rem; + h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; } + .sbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; } +} + +.sbl-list { display: flex; flex-direction: column; gap: 0.4rem; } +.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes. +.sbl-folder { + display: flex; align-items: center; gap: 0.4rem; + margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.06em; + color: #a5b4fc; + + .sbl-folder-count { + font-weight: 400; color: var(--color-text-muted, #9aa0aa); + } + + &.sbl-folder--none { color: var(--color-text-muted, #9aa0aa); } +} + +.sbl-item { + display: flex; align-items: center; gap: 0.55rem; + padding: 0.7rem 0.85rem; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); + color: inherit; cursor: pointer; text-align: left; + &:hover { background: rgba(255,255,255,0.07); } + .sbl-item-name { flex: 1; font-weight: 500; } + .sbl-item-level { + font-size: 0.74rem; padding: 0.12rem 0.5rem; border-radius: 999px; + background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.35); color: #fca5a5; + } + .sbl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88; + &:hover { background: rgba(224,90,90,0.15); } } +} diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts new file mode 100644 index 0000000..d9d6a05 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts @@ -0,0 +1,109 @@ +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'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; + +/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */ +interface FolderGroup { + folder: string; + enemies: Enemy[]; +} + +/** + * Liste des ennemis d'une campagne, groupés par dossier (comme les PNJ). + * Route : /campaigns/:campaignId/enemies + */ +@Component({ + selector: 'app-enemy-list', + imports: [LucideAngularModule, TranslatePipe], + templateUrl: './enemy-list.component.html', + styleUrls: ['./enemy-list.component.scss'] +}) +export class EnemyListComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly Skull = Skull; + readonly Folder = Folder; + + campaignId = ''; + /** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */ + groups: FolderGroup[] = []; + total = 0; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: EnemyService, + private campaignSidebar: CampaignSidebarService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.load(); + } + } + + load(): void { + this.service.getByCampaign(this.campaignId).subscribe({ + next: (list) => this.groups = this.groupByFolder(list), + error: () => this.groups = [] + }); + } + + /** Même logique de groupement que les PNJ de la sidebar : dossiers triés, non-classés à la fin. */ + private groupByFolder(enemies: Enemy[]): FolderGroup[] { + this.total = enemies.length; + const sorted = [...enemies].sort((a, b) => a.name.localeCompare(b.name, 'fr')); + const byFolder = new Map(); + const ungrouped: Enemy[] = []; + for (const e of sorted) { + const f = (e.folder ?? '').trim(); + if (f) { + if (!byFolder.has(f)) byFolder.set(f, []); + byFolder.get(f)!.push(e); + } else { + ungrouped.push(e); + } + } + const groups: FolderGroup[] = [...byFolder.keys()] + .sort((a, b) => a.localeCompare(b, 'fr')) + .map(folder => ({ folder, enemies: byFolder.get(folder)! })); + if (ungrouped.length) groups.push({ folder: '', enemies: ungrouped }); + return groups; + } + + create(): void { + this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']); + } + + open(e: Enemy): void { + this.router.navigate(['/campaigns', this.campaignId, 'enemies', e.id]); + } + + remove(e: Enemy, ev: Event): void { + ev.stopPropagation(); + this.confirmDialog.confirm({ + 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; + this.service.delete(e.id!).subscribe(() => this.load()); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html new file mode 100644 index 0000000..9ac6474 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.html @@ -0,0 +1,15 @@ +
+
+ + + +
+ + +
diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss new file mode 100644 index 0000000..406bcad --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.scss @@ -0,0 +1,94 @@ +.nv-page { + padding: 16px 0 48px; + min-height: 100vh; +} + +.nv-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 0 32px 16px; + max-width: 1100px; + margin: 0 auto; + + .spacer { flex: 1; } +} + +.btn-back, +.btn-edit, +.btn-ai { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + color: #d1d5db; + font-size: 0.85rem; + cursor: pointer; + transition: all 120ms; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + } +} + +.btn-edit { + border-color: rgba(209, 168, 120, 0.4); + color: #d1a878; + + &:hover { + background: rgba(209, 168, 120, 0.15); + } +} + +.btn-ai.active { + background: rgba(168, 85, 247, 0.2); + border-color: rgba(168, 85, 247, 0.5); + color: #d8b4fe; +} + +// Pages de Lore liées au PNJ — chips cliquables sous la fiche. +.nv-lore-links { + max-width: 1100px; + margin: 24px auto 0; + padding: 0 32px; + + .nv-lore-links-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.72rem; + font-weight: 600; + color: #a5b4fc; + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 0.6rem; + } + + .nv-lore-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + + .nv-lore-chip { + display: inline-flex; + align-items: center; + padding: 0.3rem 0.7rem; + background: #1a1a2e; + border: 1px solid #2a2a3d; + border-radius: 999px; + color: #d1d5db; + font-size: 0.82rem; + text-decoration: none; + transition: border-color 0.15s, color 0.15s; + + &:hover { + border-color: #6c63ff; + color: white; + } + } +} diff --git a/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts new file mode 100644 index 0000000..1c5b5b1 --- /dev/null +++ b/web/src/app/campaigns/enemy/enemy-view/enemy-view.component.ts @@ -0,0 +1,101 @@ +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'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { TemplateField } from '../../../services/template.model'; +import { Enemy } from '../../../services/enemy.model'; +import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component'; + +/** + * Vue lecture seule d'une fiche d'ennemi (même rendu « persona » que les PNJ). + * Route : /campaigns/:campaignId/enemies/:enemyId + */ +@Component({ + selector: 'app-enemy-view', + imports: [LucideAngularModule, TranslatePipe, PersonaViewComponent], + templateUrl: './enemy-view.component.html', + styleUrls: ['./enemy-view.component.scss'] +}) +export class EnemyViewComponent implements OnInit, OnDestroy { + readonly ArrowLeft = ArrowLeft; + readonly Edit3 = Edit3; + + campaignId: string | null = null; + enemyId: string | null = null; + + enemy: Enemy | null = null; + templateFields: TemplateField[] = []; + + private paramsSub?: Subscription; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: EnemyService, + private campaignService: CampaignService, + private gameSystemService: GameSystemService, + private campaignSidebar: CampaignSidebarService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + // paramMap (pas snapshot) : Angular réutilise le composant entre 2 ennemis. + this.paramsSub = this.route.paramMap.subscribe(params => { + const newCampaignId = params.get('campaignId'); + this.enemyId = params.get('enemyId'); + + if (this.enemyId) { + this.service.getById(this.enemyId).subscribe({ + next: e => { this.enemy = e; }, + error: () => this.back() + }); + } + + if (newCampaignId && newCampaignId !== this.campaignId) { + this.campaignId = newCampaignId; + this.campaignSidebar.show(this.campaignId); + this.campaignService.getCampaignById(this.campaignId).subscribe(camp => { + if (camp.gameSystemId) { + this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => { + this.templateFields = gs.enemyTemplate ?? []; + }); + } + }); + } else if (newCampaignId) { + this.campaignId = newCampaignId; + } + }); + } + + ngOnDestroy(): void { + this.paramsSub?.unsubscribe(); + } + + /** Sous-titre de la fiche : niveau + dossier (« Niveau 8 · Démons »). */ + get subtitle(): string { + const parts: string[] = []; + 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(' · '); + } + + edit(): void { + if (this.campaignId && this.enemyId) { + this.router.navigate(['/campaigns', this.campaignId, 'enemies', this.enemyId, 'edit']); + } + } + + back(): void { + if (this.campaignId) { + this.router.navigate(['/campaigns', this.campaignId, 'enemies']); + } else { + this.router.navigate(['/campaigns']); + } + } +} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html new file mode 100644 index 0000000..6fb36fd --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html @@ -0,0 +1,76 @@ +
+
+ + + +
+ +

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

+ + @if (errorMessage) { +
{{ errorMessage }}
+ } + +
+ + +
+ +
+ + +
+ + +
+
{{ 'itemCatalogEdit.aiTitle' | translate }}
+

{{ 'itemCatalogEdit.aiHint' | translate }}

+ +
+ + @if (aiError) { + {{ aiError }} + } +
+
+ +
+

{{ 'itemCatalogEdit.itemsTitle' | translate }}

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

{{ 'itemCatalogEdit.emptyHint' | translate }}

+ } +
diff --git a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.scss b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.scss new file mode 100644 index 0000000..560ca17 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.scss @@ -0,0 +1,87 @@ +.ice-page { max-width: 980px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.ice-toolbar { + display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; + .spacer { flex: 1; } + button { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.8rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + &:disabled { opacity: 0.5; cursor: default; } + } + .btn-save { background: #667eea; border-color: #667eea; color: #fff; } + .btn-save:hover:not(:disabled) { background: #5568d3; } +} + +h1 { font-size: 1.4rem; margin: 0 0 1rem; } +h2 { font-size: 1rem; margin: 0; } + +.error { + padding: 0.6rem 0.9rem; border-radius: 6px; margin-bottom: 1rem; + background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.4); color: #e88; +} + +.form-row { + display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 1rem; + label { font-size: 0.85rem; color: var(--color-text-muted, #9aa0aa); } + input, textarea { + padding: 0.5rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); + color: inherit; font: inherit; + } +} + +.ai-box { + margin: 1rem 0; padding: 0.85rem 1rem; border-radius: 10px; + background: rgba(168,130,255,0.08); border: 1px solid rgba(168,130,255,0.28); + .ai-title { display: flex; align-items: center; gap: 0.4rem; font-weight: 600; color: #c4a8ff; } + .ai-hint { margin: 0.3rem 0 0.5rem; font-size: 0.8rem; color: var(--color-text-muted, #9aa0aa); } + textarea { + width: 100%; padding: 0.5rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; font: inherit; + } + .ai-actions { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.5rem; } + .btn-ai { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.45rem 0.9rem; border: none; border-radius: 7px; + background: #8a6dff; color: #fff; font-weight: 600; cursor: pointer; + &:hover:not(:disabled) { background: #7a5cf0; } + &:disabled { opacity: 0.55; cursor: default; } + } + .ai-error { color: #e88; font-size: 0.82rem; } +} + +.items-head { + display: flex; align-items: center; justify-content: space-between; margin: 1.25rem 0 0.5rem; + .btn-mini { + display: inline-flex; align-items: center; gap: 0.3rem; + padding: 0.3rem 0.6rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.8rem; + &:hover { background: rgba(255,255,255,0.09); } + } +} + +.item-row { + display: grid; grid-template-columns: 1.4fr 80px 1fr 1.8fr 36px; + gap: 0.5rem; align-items: center; margin-bottom: 0.4rem; + &.head { + font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.3rem; + } + input { + padding: 0.4rem 0.55rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); + color: inherit; font: inherit; width: 100%; + } + .btn-del { + display: inline-flex; align-items: center; justify-content: center; padding: 0.35rem; + border-radius: 6px; border: 1px solid rgba(224,90,90,0.3); + background: rgba(224,90,90,0.08); color: #e88; cursor: pointer; + &:hover { background: rgba(224,90,90,0.18); } + } +} + +.empty-hint { color: var(--color-text-muted, #9aa0aa); font-style: italic; } diff --git a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts new file mode 100644 index 0000000..ba84288 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts @@ -0,0 +1,154 @@ +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…). + * Routes : /campaigns/:campaignId/item-catalogs/create + * /campaigns/:campaignId/item-catalogs/:catalogId/edit + */ +@Component({ + selector: 'app-item-catalog-edit', + imports: [FormsModule, LucideAngularModule, TranslatePipe], + templateUrl: './item-catalog-edit.component.html', + styleUrls: ['./item-catalog-edit.component.scss'] +}) +export class ItemCatalogEditComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Save = Save; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly Sparkles = Sparkles; + + campaignId: string | null = null; + catalogId: string | null = null; + + name = ''; + description = ''; + items: CatalogItem[] = []; + + saving = false; + errorMessage = ''; + + aiPrompt = ''; + generating = false; + aiError = ''; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: ItemCatalogService, + private campaignSidebar: CampaignSidebarService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.catalogId = params.get('catalogId'); + if (this.campaignId) this.campaignSidebar.show(this.campaignId); + + if (this.catalogId) { + this.service.getById(this.catalogId).subscribe({ + next: (c: ItemCatalog) => { + this.name = c.name; + this.description = c.description ?? ''; + this.items = c.items.map(i => ({ ...i })); + }, + error: () => this.back() + }); + } else { + this.addItem(); + } + } + + addItem(): void { + this.items.push({ name: '', price: '', category: '', description: '' }); + } + + removeItem(i: number): void { + this.items.splice(i, 1); + } + + generateWithAI(): void { + if (!this.campaignId) 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({ + next: (c) => { + this.generating = false; + if (c.name && !this.name.trim()) this.name = c.name; + if (c.description) this.description = c.description; + this.items = (c.items ?? []).map(i => ({ ...i })); + }, + error: (err) => { + this.generating = false; + this.aiError = err?.error?.message || this.translate.instant('itemCatalogEdit.aiError'); + } + }); + } + + save(): void { + if (!this.campaignId) return; + if (!this.name.trim()) { this.errorMessage = this.translate.instant('itemCatalogEdit.nameRequired'); return; } + this.saving = true; + this.errorMessage = ''; + + const cleanItems = this.items + .filter(i => i.name.trim()) + .map(i => ({ + name: i.name.trim(), + price: i.price?.trim() || undefined, + category: i.category?.trim() || undefined, + description: i.description?.trim() || undefined + })); + + if (this.catalogId) { + const payload: ItemCatalog = { + id: this.catalogId, + name: this.name.trim(), + description: this.description.trim() || undefined, + campaignId: this.campaignId, + items: cleanItems + }; + this.service.update(this.catalogId, payload).subscribe({ + next: () => this.goToView(this.catalogId!), + error: (e) => this.fail(e) + }); + } else { + const payload: ItemCatalogCreate = { + name: this.name.trim(), + description: this.description.trim() || undefined, + campaignId: this.campaignId, + items: cleanItems + }; + this.service.create(payload).subscribe({ + next: (c) => this.goToView(c.id!), + error: (e) => this.fail(e) + }); + } + } + + private goToView(id: string): void { + this.saving = false; + this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', id]); + } + + private fail(err: unknown): void { + this.saving = false; + this.errorMessage = this.translate.instant('itemCatalogEdit.saveError'); + console.error('ItemCatalog save failed', err); + } + + back(): void { + this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']); + } +} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html new file mode 100644 index 0000000..f531e64 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html @@ -0,0 +1,34 @@ +
+
+ + + +
+ +
+

{{ 'itemCatalogList.title' | translate }}

+

+ {{ 'itemCatalogList.hint' | translate }} +

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

{{ 'itemCatalogList.empty' | translate }}

+ } + @for (c of catalogs; track c) { + + } +
+
diff --git a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.scss b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.scss new file mode 100644 index 0000000..8487c46 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.scss @@ -0,0 +1,36 @@ +.icl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.icl-toolbar { + display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; + .spacer { flex: 1; } + button { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.8rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + } + .btn-create { background: #667eea; border-color: #667eea; color: #fff; } + .btn-create:hover { background: #5568d3; } +} + +.icl-header { + margin-bottom: 1.25rem; + h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; } + .icl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; } +} + +.icl-list { display: flex; flex-direction: column; gap: 0.4rem; } +.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.icl-item { + display: flex; align-items: center; gap: 0.55rem; + padding: 0.7rem 0.85rem; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); + color: inherit; cursor: pointer; text-align: left; + &:hover { background: rgba(255,255,255,0.07); } + .icl-item-name { flex: 1; font-weight: 500; } + .icl-item-count { font-size: 0.78rem; color: var(--color-text-muted, #9aa0aa); } + .icl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88; + &:hover { background: rgba(224,90,90,0.15); } } +} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts new file mode 100644 index 0000000..f5545aa --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts @@ -0,0 +1,78 @@ +import { Component, OnInit } from '@angular/core'; + +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Plus, Trash2, Package } from 'lucide-angular'; +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. + * Route : /campaigns/:campaignId/item-catalogs + */ +@Component({ + selector: 'app-item-catalog-list', + imports: [LucideAngularModule, TranslatePipe], + templateUrl: './item-catalog-list.component.html', + styleUrls: ['./item-catalog-list.component.scss'] +}) +export class ItemCatalogListComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly Package = Package; + + campaignId = ''; + catalogs: ItemCatalog[] = []; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: ItemCatalogService, + private campaignSidebar: CampaignSidebarService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.load(); + } + } + + load(): void { + this.service.getByCampaign(this.campaignId).subscribe({ + next: (list) => this.catalogs = list, + error: () => this.catalogs = [] + }); + } + + create(): void { + this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', 'create']); + } + + open(c: ItemCatalog): void { + this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', c.id]); + } + + remove(c: ItemCatalog, ev: Event): void { + ev.stopPropagation(); + this.confirmDialog.confirm({ + 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; + this.service.delete(c.id!).subscribe(() => this.load()); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html new file mode 100644 index 0000000..dbb8b52 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html @@ -0,0 +1,42 @@ +@if (catalog) { +
+
+ + + +
+
+

{{ catalog.name }}

+ @if (catalog.description) { +

{{ catalog.description }}

+ } +
+ @if (catalog.items.length === 0) { +

+ {{ 'itemCatalogView.empty' | translate }} +

+ } + @for (g of groups; track g) { +
+ @if (g.category !== '—') { +

{{ g.category }}

+ } + + + @for (it of g.items; track it) { + + + + + + } + +
{{ it.name }}{{ it.price }}{{ it.description }}
+
+ } +
+} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.scss b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.scss new file mode 100644 index 0000000..da1316a --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.scss @@ -0,0 +1,40 @@ +.ic-page { max-width: 880px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.ic-toolbar { + display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; + .spacer { flex: 1; } + button { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + } +} + +.ic-header { + margin-bottom: 1.25rem; + h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; } + .ic-desc { margin: 0; color: var(--color-text-muted, #9aa0aa); } +} + +.ic-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.ic-group { + margin-bottom: 1.25rem; + h2 { + font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; + color: #c4a8ff; margin: 0 0 0.4rem; padding-bottom: 0.25rem; + border-bottom: 1px solid rgba(168,130,255,0.2); + } + table { width: 100%; border-collapse: collapse; } + td { + padding: 0.45rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.06); vertical-align: top; + } + .col-name { font-weight: 500; white-space: nowrap; } + .col-price { + white-space: nowrap; color: #e0c074; font-variant-numeric: tabular-nums; + text-align: right; width: 90px; + } + .col-desc { color: var(--color-text-muted, #cfd3da); font-size: 0.9rem; } +} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts new file mode 100644 index 0000000..6360a67 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts @@ -0,0 +1,73 @@ +import { Component, OnInit } from '@angular/core'; + +import { ActivatedRoute, Router } from '@angular/router'; +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[]; } + +/** + * Vue d'un catalogue d'objets : liste (groupée par catégorie). + * Route : /campaigns/:campaignId/item-catalogs/:catalogId + */ +@Component({ + selector: 'app-item-catalog-view', + imports: [LucideAngularModule, TranslatePipe], + templateUrl: './item-catalog-view.component.html', + styleUrls: ['./item-catalog-view.component.scss'] +}) +export class ItemCatalogViewComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Edit3 = Edit3; + readonly Package = Package; + + campaignId: string | null = null; + catalogId: string | null = null; + catalog: ItemCatalog | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: ItemCatalogService, + private campaignSidebar: CampaignSidebarService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.catalogId = params.get('catalogId'); + if (this.catalogId) { + this.service.getById(this.catalogId).subscribe({ + next: c => this.catalog = c, + error: () => this.back() + }); + } + if (this.campaignId) this.campaignSidebar.show(this.campaignId); + } + + /** Objets groupés par catégorie (non catégorisés en dernier). */ + get groups(): ItemGroup[] { + const map = new Map(); + for (const it of this.catalog?.items ?? []) { + const cat = (it.category ?? '').trim() || '—'; + if (!map.has(cat)) map.set(cat, []); + map.get(cat)!.push(it); + } + return [...map.entries()] + .sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr'))) + .map(([category, items]) => ({ category, items })); + } + + edit(): void { + if (this.campaignId && this.catalogId) { + this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', this.catalogId, 'edit']); + } + } + + back(): void { + this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']); + } +} diff --git a/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.html b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.html new file mode 100644 index 0000000..f99faf3 --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.html @@ -0,0 +1,59 @@ +
+
+ + {{ typeLabel }} + {{ action.name }} +
+ + @if (action.description) { +

{{ action.description }}

+ } + + + @if (status !== 'created' && needsArc) { +
+ + @if (needsChapter) { + + } +
+ } + + @if (needsArc && arcs.length === 0) { +

+ {{ (needsChapter ? 'notebookActionCard.warnArcChapter' : 'notebookActionCard.warnArc') | translate }} +

+ } + +
+ @if (status !== 'created') { + + } + @if (status === 'created') { + + } + @if (status === 'error') { + {{ errorMsg }} + } +
+
diff --git a/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.scss b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.scss new file mode 100644 index 0000000..8a066aa --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.scss @@ -0,0 +1,52 @@ +.nac { + margin-top: 0.5rem; + padding: 0.6rem 0.75rem; + border-radius: 9px; + border: 1px solid rgba(168, 130, 255, 0.3); + background: rgba(168, 130, 255, 0.08); + + &.created { border-color: rgba(107, 208, 138, 0.4); background: rgba(107, 208, 138, 0.08); } +} + +.nac-head { + display: flex; align-items: center; gap: 0.4rem; + color: #c4a8ff; + .nac-type { + font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; + padding: 0.05rem 0.4rem; border-radius: 4px; background: rgba(168,130,255,0.18); + } + .nac-name { font-weight: 600; color: inherit; } +} + +.nac-desc { + margin: 0.4rem 0 0; font-size: 0.85rem; color: var(--color-text-muted, #cfd3da); + white-space: pre-wrap; +} + +.nac-targets { + display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 0.5rem; + label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); } + select { + padding: 0.3rem 0.45rem; border-radius: 6px; font: inherit; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; + } +} + +.nac-warn { margin: 0.4rem 0 0; font-size: 0.78rem; color: #e0a458; } + +.nac-foot { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.55rem; } + +.nac-create, .nac-open { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.35rem 0.7rem; border-radius: 7px; cursor: pointer; font-size: 0.82rem; font-weight: 600; + border: none; +} +.nac-create { + background: #8a6dff; color: #fff; + &:hover:not(:disabled) { background: #7a5cf0; } + &:disabled { opacity: 0.5; cursor: default; } +} +.nac-open { background: rgba(107,208,138,0.2); color: #6bd08a; } +.nac-open:hover { background: rgba(107,208,138,0.3); } + +.nac-error { color: #e88; font-size: 0.8rem; } diff --git a/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts new file mode 100644 index 0000000..ae391cc --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts @@ -0,0 +1,211 @@ +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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { Arc, Chapter } from '../../../services/campaign.model'; +import { NotebookAction } from '../../../services/notebook-action.model'; + +/** + * Carte « Créer dans la campagne » issue d'une proposition de l'IA. Gère la cible + * (chapitre pour une scène, arc pour un chapitre) et appelle les services existants. + */ +@Component({ + selector: 'app-notebook-action-card', + imports: [FormsModule, LucideAngularModule, TranslatePipe], + templateUrl: './notebook-action-card.component.html', + styleUrls: ['./notebook-action-card.component.scss'] +}) +export class NotebookActionCardComponent implements OnInit { + readonly Plus = Plus; + readonly Check = Check; + readonly ExternalLink = ExternalLink; + + @Input() action!: NotebookAction; + @Input() campaignId!: string; + @Input() arcs: Arc[] = []; + @Input() chaptersByArc: Record = {}; + /** Émis après une création réussie → l'atelier rafraîchit la sidebar. */ + @Output() created = new EventEmitter(); + + selectedArcId = ''; + selectedChapterId = ''; + + status: 'idle' | 'creating' | 'created' | 'error' = 'idle'; + errorMsg = ''; + createdRoute: string[] | null = null; + + constructor( + private campaignService: CampaignService, + private npcService: NpcService, + private tableService: RandomTableService, + private router: Router, + private translate: TranslateService + ) {} + + ngOnInit(): void { + if (this.arcs.length) this.selectedArcId = this.arcs[0].id!; + this.syncChapter(); + } + + get typeLabel(): string { + switch (this.action.type) { + 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; + } + } + + get typeIcon() { + switch (this.action.type) { + case 'npc': return Drama; + case 'scene': return Clapperboard; + case 'chapter': return BookText; + case 'arc': return GitBranch; + case 'table': return Dices; + default: return Plus; + } + } + + get needsArc(): boolean { return this.action.type === 'chapter' || this.action.type === 'scene'; } + get needsChapter(): boolean { return this.action.type === 'scene'; } + + get targetChapters(): Chapter[] { return this.chaptersByArc[this.selectedArcId] ?? []; } + + syncChapter(): void { + const chs = this.targetChapters; + this.selectedChapterId = chs.length ? chs[0].id! : ''; + } + + get canCreate(): boolean { + if (this.status === 'creating' || this.status === 'created') return false; + if (this.needsArc && !this.selectedArcId) return false; + if (this.needsChapter && !this.selectedChapterId) return false; + return true; + } + + create(): void { + if (!this.canCreate) return; + this.status = 'creating'; + this.errorMsg = ''; + switch (this.action.type) { + case 'npc': return this.createNpc(); + case 'arc': return this.createArc(); + case 'chapter': return this.createChapter(); + case 'scene': return this.createScene(); + case 'table': return this.createTable(); + } + } + + private createNpc(): void { + // Valeurs de fiche proposées par l'IA (clés = champs du template PNJ) ; + // la description sert de repli si le modèle n'a pas détaillé par champ. + const values: Record = {}; + for (const [key, val] of Object.entries(this.action.values ?? {})) { + if (typeof val === 'string' && val.trim()) values[key] = val; + } + if (Object.keys(values).length === 0 && this.action.description) { + values['Description'] = this.action.description; + } + this.npcService.create({ + name: this.action.name, + campaignId: this.campaignId, + values + }).subscribe({ + next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]), + error: (e) => this.fail(e) + }); + } + + private createArc(): void { + this.campaignService.createArc({ + name: this.action.name, + description: this.action.description, + campaignId: this.campaignId, + order: this.arcs.length, + type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR', + themes: this.action.themes, + stakes: this.action.stakes, + rewards: this.action.rewards, + resolution: this.action.resolution, + gmNotes: this.action.gmNotes + }).subscribe({ + next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]), + error: (e) => this.fail(e) + }); + } + + private createChapter(): void { + const order = (this.chaptersByArc[this.selectedArcId] ?? []).length; + this.campaignService.createChapter({ + name: this.action.name, + description: this.action.description, + arcId: this.selectedArcId, + order, + playerObjectives: this.action.playerObjectives, + narrativeStakes: this.action.narrativeStakes, + gmNotes: this.action.gmNotes + }).subscribe({ + next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]), + error: (e) => this.fail(e) + }); + } + + private createScene(): void { + this.campaignService.createScene({ + name: this.action.name, + description: this.action.description, + // `content` = ancien protocole (messages archivés d'avant l'enrichissement). + playerNarration: this.action.playerNarration ?? this.action.content, + chapterId: this.selectedChapterId, + order: 0, + location: this.action.location, + timing: this.action.timing, + atmosphere: this.action.atmosphere, + gmSecretNotes: this.action.gmSecretNotes, + choicesConsequences: this.action.choicesConsequences, + combatDifficulty: this.action.combatDifficulty, + enemies: this.action.enemies + }).subscribe({ + next: (s) => this.done( + ['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]), + error: (e) => this.fail(e) + }); + } + + private createTable(): void { + this.tableService.create({ + name: this.action.name, + diceFormula: this.action.diceFormula || '1d20', + campaignId: this.campaignId, + entries: (this.action.entries ?? []).map(e => ({ + minRoll: e.minRoll, maxRoll: e.maxRoll, label: e.label, detail: e.detail + })) + }).subscribe({ + next: (t) => this.done(['/campaigns', this.campaignId, 'random-tables', t.id!]), + error: (e) => this.fail(e) + }); + } + + private done(route: string[]): void { + this.status = 'created'; + this.createdRoute = route; + this.created.emit(); + } + + private fail(err: unknown): void { + this.status = 'error'; + this.errorMsg = (err as { error?: { message?: string } })?.error?.message || this.translate.instant('notebookActionCard.createError'); + } + + openCreated(): void { + if (this.createdRoute) this.router.navigate(this.createdRoute); + } +} diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html new file mode 100644 index 0000000..dd35210 --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html @@ -0,0 +1,222 @@ +@if (detail) { +
+
+ + +
+
+ + + +
+ +
+ @if (viewingArchive) { + + + {{ 'notebookDetail.archiveBanner' | translate:{ label: archiveLabel(viewingArchive) } }} + + + } @else { + @if (referencedArchiveIds.size > 0) { + + + {{ 'notebookDetail.refBadge' | translate:{ n: referencedArchiveIds.size } }} + + } + + + + } +
+ + @if (archivesOpen && !viewingArchive) { +
+ @if (archives.length === 0) { +

{{ 'notebookDetail.archivesEmpty' | translate }}

+ } @else { +

+ } + @for (a of archives; track a.archivedAt) { +
+ + +
+ } +
+ } +
+ @if (viewingArchive) { + + @for (m of viewingArchive.messages; track $index) { +
+
+ @if (m.role === 'assistant') { + + } + {{ (m.role === 'user' ? 'notebookDetail.roleUser' : 'notebookDetail.roleAi') | translate }} +
+ @if (m.role === 'assistant') { +
+ } @else { +
{{ m.content }}
+ } +
+ } + } @else { + @if (messages.length === 0) { +

+ {{ 'notebookDetail.chatEmpty' | translate }} + @if (!hasReadySource()) { + + } +

+ } + @for (m of messages; track m) { +
+
+ @if (m.role === 'assistant') { + + } + {{ (m.role === 'user' ? 'notebookDetail.roleUser' : 'notebookDetail.roleAi') | translate }} +
+ @if (m.role === 'assistant') { + @if (parsedOf(m); as p) { + @if (sending && deepProgress && !p.text) { +
+ + {{ 'notebookDetail.deepProgress' | translate:{ current: deepProgress.current, total: deepProgress.total } }} +
+ } + @if (p.text) { +
+ } @else if (sending && !p.actions.length && !deepProgress) { +
+ } + @for (a of p.actions; track $index) { + + + } + @if (sourcesLabel(m); as label) { +
+ 📖 {{ label }} +
+ } + } + } @else { +
{{ m.content }}
+ } +
+ } + } +
+ @if (!viewingArchive) { +
+ + + +
+ } +
+
+
+} diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss new file mode 100644 index 0000000..e1d240b --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss @@ -0,0 +1,296 @@ +.nbd-page { max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem 2rem; height: calc(100vh - 60px); display: flex; flex-direction: column; } + +.nbd-toolbar { + display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; + .btn-back { + display: inline-flex; align-items: center; gap: 0.35rem; flex-shrink: 0; + padding: 0.4rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + } + .nbd-title { + flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 1.15rem; font-weight: 600; + border: 1px solid transparent; background: transparent; color: inherit; + &:hover, &:focus { border-color: rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); outline: none; } + } +} + +.nbd-grid { + flex: 1; min-height: 0; + display: grid; grid-template-columns: 280px 1fr; gap: 1rem; +} + +/* Sources */ +.nbd-sources { + display: flex; flex-direction: column; gap: 0.5rem; overflow-y: auto; + border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 0.75rem; +} +.nbd-sources-head { + display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; + h3 { display: flex; align-items: center; gap: 0.4rem; margin: 0; font-size: 0.95rem; } +} +.btn-upload { + display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; + padding: 0.3rem 0.55rem; border-radius: 6px; font-size: 0.78rem; + border: 1px solid rgba(102,126,234,0.4); color: #8ea2ff; background: rgba(102,126,234,0.1); + &:hover { background: rgba(102,126,234,0.2); } + &.disabled { opacity: 0.6; cursor: default; } +} +.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; } + +// Compteur de sources actives — ambre quand une partie est décochée, pour +// rappeler que le chat ne voit pas tout. +.nbd-sources-hint { + font-size: 0.72rem; + color: var(--color-text-muted, #9aa0aa); + margin: 0.1rem 0 0.2rem; + + &.partial { color: #fbbf24; } +} + +.nbd-source { + display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; + border-radius: 7px; background: rgba(255,255,255,0.03); + lucide-icon.ok { color: #6bd08a; } + lucide-icon.fail { color: #e88; } + lucide-icon.busy { color: #e0c074; } + + // Case « source utilisée par le chat » (sources prêtes uniquement). + .nbd-source-check { + margin: 2px 0 0; + accent-color: #6bd08a; + cursor: pointer; + flex-shrink: 0; + } + + // Source décochée : grisée = hors du périmètre du chat et de l'analyse. + &.nbd-source--off { + opacity: 0.5; + } + + .nbd-source-info { flex: 1; min-width: 0; } + .nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; } + .nbd-source-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); } + .nbd-source-del { + border: none; background: none; color: #e88; cursor: pointer; padding: 0.15rem; + border-radius: 4px; &:hover { background: rgba(224,90,90,0.15); } + } +} + +/* Chat */ +.nbd-chat { + display: flex; flex-direction: column; min-height: 0; + // min-width: 0 indispensable : enfant de grille (colonne 1fr), sinon sa largeur + // MINIMALE devient celle de la plus longue ligne insécable d'un message + // (tableau markdown, longue URL…) et le chat pousse toute la page hors écran. + min-width: 0; + border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; +} + +// Barre d'actions du chat : archives + vider (ou bandeau « archive en lecture seule »). +.nbd-chat-head { + display: flex; align-items: center; gap: 0.4rem; + padding: 0.45rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.08); + + .nbd-chat-head-spacer { flex: 1; } + + .nbd-archive-banner { + flex: 1; + display: inline-flex; align-items: center; gap: 0.35rem; + font-size: 0.8rem; color: #e0c074; font-style: italic; + } + + // Badge « N archive(s) en référence » : rappelle que le contexte du chat + // inclut d'anciennes conversations, même panneau fermé. + .nbd-ref-badge { + display: inline-flex; align-items: center; gap: 0.3rem; + padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.74rem; + background: rgba(168,130,255,0.12); border: 1px solid rgba(168,130,255,0.4); + color: #c4a8ff; + } + + .nbd-chat-btn { + display: inline-flex; align-items: center; gap: 0.3rem; + padding: 0.28rem 0.55rem; border-radius: 6px; font-size: 0.78rem; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; + &:hover:not(:disabled) { background: rgba(255,255,255,0.09); } + &.active { border-color: #667eea; } + &:disabled { opacity: 0.45; cursor: default; } + + &--danger { + color: #e88; border-color: rgba(224,90,90,0.35); + &:hover:not(:disabled) { background: rgba(224,90,90,0.12); } + } + } +} + +// Panneau des conversations archivées. +.nbd-archives { + display: flex; flex-direction: column; gap: 0.3rem; + padding: 0.5rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.08); + + .nbd-archives-help { + margin: 0 0 0.2rem; + font-size: 0.74rem; + color: var(--color-text-muted, #9aa0aa); + } + + .nbd-archive-row { + display: flex; align-items: center; gap: 0.45rem; + + // Archive cochée en référence : léger liseré violet pour la repérer. + &.referenced .nbd-archive-item { border-color: rgba(168,130,255,0.5); } + } + + .nbd-archive-check { + accent-color: #c4a8ff; + cursor: pointer; + flex-shrink: 0; + } + + .nbd-archive-item { + flex: 1; + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.35rem 0.55rem; border-radius: 6px; font-size: 0.82rem; + border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03); + color: inherit; cursor: pointer; text-align: left; + &:hover { background: rgba(255,255,255,0.08); } + } +} +.nbd-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; } +.nbd-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.nbd-msg { + max-width: 88%; + .nbd-msg-role { + display: flex; align-items: center; gap: 0.3rem; + font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.2rem; + } + // overflow-wrap: anywhere : autorise la coupure DANS une séquence insécable + // (ligne de tableau markdown, URL) plutôt que de déborder du conteneur. + .nbd-msg-content { white-space: pre-wrap; line-height: 1.5; overflow-wrap: anywhere; } + + // Rendu markdown des messages assistant (même esthétique que ai-chat-drawer). + // white-space normal : marked génère des

/

  • /
    , pre-wrap doublerait + // les espacements entre blocs. + .nbd-msg-content.md { + white-space: normal; + + > :first-child { margin-top: 0; } + > :last-child { margin-bottom: 0; } + + p { margin: 0 0 0.5em; } + p:last-child { margin-bottom: 0; } + + h1, h2, h3, h4, h5, h6 { + margin: 0.75em 0 0.35em; + font-weight: 600; + color: #f3f4f6; + line-height: 1.25; + } + h1 { font-size: 1.05rem; } + h2 { font-size: 1rem; } + h3 { font-size: 0.95rem; } + h4, h5, h6 { font-size: 0.9rem; } + + strong { color: #f3f4f6; font-weight: 600; } + em { color: #d1d5db; font-style: italic; } + + ul, ol { margin: 0.35em 0 0.5em; padding-left: 1.4em; } + li { margin: 0.15em 0; } + ul ul, ul ol, ol ul, ol ol { margin: 0.15em 0; } + + code { + background: #0b0b15; + border: 1px solid #2a2a3d; + border-radius: 3px; + padding: 0 0.25em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 0.82em; + } + pre { + background: #0b0b15; + border: 1px solid #2a2a3d; + border-radius: 6px; + padding: 0.6em 0.75em; + margin: 0.5em 0; + overflow-x: auto; + font-size: 0.82em; + + code { background: transparent; border: 0; padding: 0; font-size: inherit; } + } + + blockquote { + margin: 0.4em 0; + padding: 0.2em 0.8em; + border-left: 3px solid #3a3a5a; + color: #9ca3af; + font-style: italic; + } + + a { + color: #a5b4fc; + text-decoration: underline; + &:hover { color: #c7d2fe; } + } + + hr { border: 0; border-top: 1px solid #2a2a3d; margin: 0.6em 0; } + + // Les tableaux (inventaires de boutique…) peuvent être plus larges que la + // bulle : on les laisse défiler horizontalement DANS le message. + table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + margin: 0.4em 0; + font-size: 0.85em; + + th, td { border: 1px solid #2a2a3d; padding: 0.3em 0.5em; white-space: nowrap; } + th { background: #14142a; font-weight: 600; } + } + } + &.user { align-self: flex-end; text-align: right; + .nbd-msg-content { background: rgba(102,126,234,0.16); padding: 0.5rem 0.75rem; border-radius: 10px; display: inline-block; text-align: left; } + } + &.assistant { align-self: flex-start; + .nbd-msg-content { background: rgba(255,255,255,0.04); padding: 0.5rem 0.75rem; border-radius: 10px; } + } +} +.cursor { opacity: 0.6; } + +.nbd-input { + display: flex; gap: 0.5rem; padding: 0.6rem; border-top: 1px solid rgba(255,255,255,0.08); + textarea { + flex: 1; resize: none; padding: 0.55rem 0.7rem; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; font: inherit; + } + .btn-send { + display: inline-flex; align-items: center; justify-content: center; width: 44px; + border: none; border-radius: 8px; background: #667eea; color: #fff; cursor: pointer; + &:hover:not(:disabled) { background: #5568d3; } + &:disabled { opacity: 0.5; cursor: default; } + } + .btn-deep { + display: inline-flex; align-items: center; justify-content: center; width: 44px; + border: 1px solid rgba(168,130,255,0.4); border-radius: 8px; + background: rgba(168,130,255,0.12); color: #c4a8ff; cursor: pointer; + &:hover:not(:disabled) { background: rgba(168,130,255,0.22); } + &:disabled { opacity: 0.5; cursor: default; } + } +} + +.nbd-deep-progress { + display: inline-flex; align-items: center; gap: 0.35rem; + font-size: 0.82rem; color: #c4a8ff; font-style: italic; +} + +// Pages de la source utilisées par le RAG (transparence de la réponse) +.nbd-msg-sources { + margin-top: 0.35rem; + font-size: 0.75rem; + opacity: 0.65; +} diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts new file mode 100644 index 0000000..5e4279e --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts @@ -0,0 +1,338 @@ +import { Component, OnInit } from '@angular/core'; + +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers, Eraser, History, X } from 'lucide-angular'; +import { NotebookService } from '../../../services/notebook.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { EnemyService } from '../../../services/enemy.service'; +import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model'; +import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model'; +import { Arc, Chapter } from '../../../services/campaign.model'; +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). + * Route : /campaigns/:campaignId/notebooks/:notebookId + */ +@Component({ + selector: 'app-notebook-detail', + imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent, MarkdownPipe, TranslatePipe], + templateUrl: './notebook-detail.component.html', + styleUrls: ['./notebook-detail.component.scss'] +}) +export class NotebookDetailComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Upload = Upload; + readonly Trash2 = Trash2; + readonly Send = Send; + readonly FileText = FileText; + readonly Loader = Loader; + readonly CheckCircle2 = CheckCircle2; + readonly AlertCircle = AlertCircle; + readonly Sparkles = Sparkles; + readonly Layers = Layers; + readonly Eraser = Eraser; + readonly History = History; + readonly X = X; + + campaignId = ''; + notebookId = ''; + detail: NotebookDetail | null = null; + sources: NotebookSource[] = []; + messages: NotebookMessage[] = []; + + uploading = false; + uploadError = ''; + sending = false; + draft = ''; + /** Avancement de l'analyse approfondie (lecture du doc par lots). */ + deepProgress: { current: number; total: number } | null = null; + + // Arbre de la campagne — sert aux cartes d'action (cibles arc/chapitre). + arcs: Arc[] = []; + chaptersByArc: Record = {}; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: NotebookService, + private campaignSidebar: CampaignSidebarService, + private campaignService: CampaignService, + private characterService: CharacterService, + private npcService: NpcService, + private enemyService: EnemyService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; + this.notebookId = this.route.snapshot.paramMap.get('notebookId') ?? ''; + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.loadTree(); + } + this.load(); + } + + private loadTree(): void { + loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService) + .subscribe({ + next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; }, + error: () => { /* cibles indisponibles : les cartes le signaleront */ } + }); + } + + /** Après création depuis une carte : rafraîchit la sidebar (l'élément apparaît) + * et l'arbre des cibles (pour les cartes suivantes). */ + onActionCreated(): void { + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.loadTree(); + } + } + + /** Sépare le texte affiché des blocs d'action — MÉMORISÉ par message : renvoie + * une référence STABLE tant que le contenu n'a pas changé. Indispensable : + * appeler le parseur directement dans le template recréait le DOM à chaque + * détection de changement (au mousedown) → les clics sur les cartes étaient + * perdus. */ + parsedOf(m: NotebookMessage): { text: string; actions: NotebookAction[] } { + const cache = m as unknown as { + _parsedFor?: string; + _parsed?: { text: string; actions: NotebookAction[] }; + }; + if (cache._parsedFor !== m.content || !cache._parsed) { + cache._parsed = parseNotebookActions(m.content); + cache._parsedFor = m.content; + } + return cache._parsed; + } + + /** + * Libellé compact des pages utilisées par le RAG pour une réponse + * (« p. 12, 47, 103 » — préfixé du nom du fichier si plusieurs sources). + */ + sourcesLabel(m: NotebookMessage): string { + const src = m.sources ?? []; + if (!src.length) return ''; + const bySource = new Map(); + for (const s of src) { + if (s.page == null) continue; + const pages = bySource.get(s.sourceId) ?? []; + if (!pages.includes(s.page)) pages.push(s.page); + bySource.set(s.sourceId, pages); + } + if (bySource.size === 0) return ''; + const nameOf = (id: string) => this.sources.find(x => x.id === id)?.filename ?? ''; + return [...bySource.entries()] + .map(([id, pages]) => { + const label = `p. ${pages.sort((a, b) => a - b).join(', ')}`; + return bySource.size > 1 && nameOf(id) ? `${nameOf(id)} — ${label}` : label; + }) + .join(' · '); + } + + load(): void { + this.service.get(this.notebookId).subscribe({ + next: (d) => { + this.detail = d; + this.sources = d.sources ?? []; + this.messages = d.messages ?? []; + this.syncSelection(); + }, + error: () => this.back() + }); + } + + reloadSources(): void { + this.service.get(this.notebookId).subscribe({ next: (d) => { + this.sources = d.sources ?? []; + this.syncSelection(); + } }); + } + + // --- Sélection des sources utilisées par le chat ------------------------- + // Par défaut tout est coché ; décocher permet de limiter une question (et + // surtout une analyse approfondie, coûteuse en requêtes) à certains PDF. + + /** IDs des sources cochées (sous-ensemble des sources READY). */ + selectedSourceIds = new Set(); + /** IDs déjà vus — pour ne cocher par défaut que les NOUVELLES sources. */ + private knownSourceIds = new Set(); + + /** Aligne la sélection sur la liste courante : nouvelles sources cochées par + * défaut, sources supprimées retirées, choix de l'utilisateur préservés. */ + private syncSelection(): void { + const readyIds = new Set(this.sources.filter(s => s.status === 'READY').map(s => s.id)); + for (const id of readyIds) { + if (!this.knownSourceIds.has(id)) { + this.selectedSourceIds.add(id); + this.knownSourceIds.add(id); + } + } + for (const id of [...this.selectedSourceIds]) { + if (!readyIds.has(id)) this.selectedSourceIds.delete(id); + } + } + + isSelected(s: NotebookSource): boolean { + return this.selectedSourceIds.has(s.id); + } + + toggleSource(s: NotebookSource): void { + if (this.selectedSourceIds.has(s.id)) this.selectedSourceIds.delete(s.id); + else this.selectedSourceIds.add(s.id); + } + + get readySourceCount(): number { + return this.sources.filter(s => s.status === 'READY').length; + } + + // --- Sources --- + + onFile(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) return; + this.uploading = true; + this.uploadError = ''; + this.service.addSource(this.notebookId, file).subscribe({ + next: () => { this.uploading = false; this.reloadSources(); }, + error: (err) => { + this.uploading = false; + this.uploadError = err?.error?.message || this.translate.instant('notebookDetail.uploadError'); + this.reloadSources(); + } + }); + } + + removeSource(s: NotebookSource): void { + this.service.deleteSource(s.id).subscribe(() => this.reloadSources()); + } + + // --- Vider la conversation / archives ------------------------------------- + // « Vider » ARCHIVE le fil (rien n'est supprimé) ; les archives restent + // consultables en lecture seule via le sélecteur. + + /** Conversations archivées (chargées à l'ouverture du panneau). */ + archives: NotebookArchive[] = []; + /** Archive affichée en lecture seule (null = chat actif). */ + viewingArchive: NotebookArchive | null = null; + /** Panneau « archives » ouvert ? */ + archivesOpen = false; + + clearChat(): void { + if (this.sending || this.messages.length === 0) return; + this.confirmDialog.confirm({ + 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; + this.service.clearChat(this.notebookId).subscribe({ + next: () => { + this.messages = []; + this.archives = []; // re-chargées à la prochaine ouverture du panneau + this.archivesOpen = false; + }, + error: () => { /* le fil reste affiché : rien n'a été modifié côté serveur */ } + }); + }); + } + + toggleArchives(): void { + this.archivesOpen = !this.archivesOpen; + if (this.archivesOpen && this.archives.length === 0) { + this.service.getArchives(this.notebookId).subscribe({ + next: (a) => this.archives = a, + error: () => { this.archives = []; } + }); + } + } + + /** + * Archives cochées « en référence » : leur contenu est injecté dans le + * contexte de chaque tour de chat (normal et approfondi). Clés = archivedAt. + */ + referencedArchiveIds = new Set(); + + isReferenced(a: NotebookArchive): boolean { + return this.referencedArchiveIds.has(a.archivedAt); + } + + toggleReference(a: NotebookArchive): void { + if (this.referencedArchiveIds.has(a.archivedAt)) this.referencedArchiveIds.delete(a.archivedAt); + else this.referencedArchiveIds.add(a.archivedAt); + } + + openArchive(a: NotebookArchive): void { + this.viewingArchive = a; + this.archivesOpen = false; + } + + closeArchive(): void { + this.viewingArchive = null; + } + + /** 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(locale, { dateStyle: 'short', timeStyle: 'short' }); + return this.translate.instant('notebookDetail.archiveLabel', { when, n: a.messages.length }); + } + + // --- Chat --- + + send(deep = false): void { + const text = this.draft.trim(); + if (!text || this.sending) return; + this.draft = ''; + this.deepProgress = null; + this.messages.push({ role: 'user', content: text }); + const assistant: NotebookMessage = { role: 'assistant', content: '' }; + this.messages.push(assistant); + this.sending = true; + + this.service.streamChat( + this.notebookId, text, deep, + [...this.selectedSourceIds], [...this.referencedArchiveIds] + ).subscribe({ + next: (ev) => { + if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; } + else if (ev.type === 'sources') assistant.sources = ev.sources; + else if (ev.type === 'progress') this.deepProgress = { current: ev.current, total: ev.total }; + else if (ev.type === 'error') assistant.content += (assistant.content ? '\n\n' : '') + `⚠️ ${ev.message}`; + }, + complete: () => { this.sending = false; this.deepProgress = null; }, + error: () => { this.sending = false; this.deepProgress = null; } + }); + } + + rename(): void { + if (!this.detail || !this.detail.name.trim()) return; + this.service.rename(this.notebookId, this.detail.name.trim()).subscribe(); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId, 'notebooks']); + } + + hasReadySource(): boolean { + return this.sources.some(s => s.status === 'READY'); + } +} diff --git a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html new file mode 100644 index 0000000..5e8524b --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html @@ -0,0 +1,38 @@ +
    +
    + +
    + +
    +

    {{ 'notebookList.title' | translate }}

    +

    + {{ 'notebookList.hint' | translate }} +

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

    {{ 'notebookList.empty' | translate }}

    + } + @for (nb of notebooks; track nb) { + + } +
    +
    diff --git a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.scss b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.scss new file mode 100644 index 0000000..f1e81bd --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.scss @@ -0,0 +1,49 @@ +.nbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.nbl-toolbar { margin-bottom: 1rem; } +.btn-back { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } +} + +.nbl-header { + margin-bottom: 1.25rem; + h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; } + .nbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; } +} + +.nbl-create { + display: flex; gap: 0.5rem; margin-bottom: 1.5rem; + input { + flex: 1; padding: 0.55rem 0.75rem; border-radius: 7px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); + color: inherit; font: inherit; + } + .btn-create { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.55rem 1rem; border: none; border-radius: 7px; + background: #667eea; color: #fff; font-weight: 600; cursor: pointer; + &:hover:not(:disabled) { background: #5568d3; } + &:disabled { opacity: 0.55; cursor: default; } + } +} + +.nbl-list { display: flex; flex-direction: column; gap: 0.4rem; } +.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.nbl-item { + display: flex; align-items: center; gap: 0.55rem; + padding: 0.7rem 0.85rem; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); + color: inherit; cursor: pointer; text-align: left; + &:hover { background: rgba(255,255,255,0.07); } + + .nbl-item-name { flex: 1; font-weight: 500; } + .nbl-del { + display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88; + &:hover { background: rgba(224,90,90,0.15); } + } +} diff --git a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts new file mode 100644 index 0000000..74ad8d4 --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts @@ -0,0 +1,86 @@ +import { Component, OnInit } from '@angular/core'; + +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Plus, Trash2, BookOpen } from 'lucide-angular'; +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. + * Route : /campaigns/:campaignId/notebooks + */ +@Component({ + selector: 'app-notebook-list', + imports: [FormsModule, LucideAngularModule, TranslatePipe], + templateUrl: './notebook-list.component.html', + styleUrls: ['./notebook-list.component.scss'] +}) +export class NotebookListComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly BookOpen = BookOpen; + + campaignId = ''; + notebooks: Notebook[] = []; + newName = ''; + creating = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: NotebookService, + private campaignSidebar: CampaignSidebarService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.load(); + } + } + + load(): void { + this.service.listByCampaign(this.campaignId).subscribe({ + next: (list) => this.notebooks = list, + error: () => this.notebooks = [] + }); + } + + create(): void { + if (this.creating) return; + this.creating = true; + 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 + }); + } + + open(nb: Notebook): void { + this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]); + } + + remove(nb: Notebook, ev: Event): void { + ev.stopPropagation(); + this.confirmDialog.confirm({ + 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; + this.service.delete(nb.id).subscribe(() => this.load()); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html index 2e3df12..d93cda5 100644 --- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html @@ -3,100 +3,134 @@

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

    - + @if (npcId) { + + }
    - + -
    - -
    -
    - - - + [placeholder]="'npcEdit.namePlaceholder' | translate" + />
    -
    - - - + +
    + + + + @for (f of existingFolders; track f) { + + } + +
    + +
    +
    + + + +
    +
    + + + +
    +
    + +
    + + +
    + + + @if (loreId) { +
    + + + +

    {{ 'npcEdit.relatedPagesHint' | translate }}

    +
    + } + +
    + + + + @if (npcId) { + + } +
    +
    +
    -
    - - -
    - -
    - - - - -
    - -
    - -
  • - - - + @if (npcId && campaignId) { + + + } diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts index 4e16dc9..3232890 100644 --- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts @@ -1,16 +1,20 @@ import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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'; +import { PageService } from '../../../services/page.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { TemplateField } from '../../../services/template.model'; +import { Page } from '../../../services/page.model'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component'; import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component'; +import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; /** @@ -20,11 +24,10 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia * GameSystem associe a la campagne. */ @Component({ - selector: 'app-npc-edit', - standalone: true, - imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent], - templateUrl: './npc-edit.component.html', - styleUrls: ['./npc-edit.component.scss'] + selector: 'app-npc-edit', + imports: [FormsModule, LucideAngularModule, TranslatePipe, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent, LoreLinkPickerComponent], + templateUrl: './npc-edit.component.html', + styleUrls: ['./npc-edit.component.scss'] }) export class NpcEditComponent implements OnInit { readonly Save = Save; @@ -34,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; } @@ -46,6 +51,9 @@ export class NpcEditComponent implements OnInit { npcId: string | null = null; name = ''; + folder = ''; + /** Dossiers déjà utilisés dans la campagne (datalist d'auto-complétion). */ + existingFolders: string[] = []; portraitImageId: string | null = null; headerImageId: string | null = null; values: Record = {}; @@ -54,14 +62,23 @@ export class NpcEditComponent implements OnInit { templateFields: TemplateField[] = []; private order = 0; + /** Lore lié à la campagne (null = pas de lore → section liens masquée). */ + loreId: string | null = null; + /** Pages du lore lié — référentiel du picker. */ + lorePages: Page[] = []; + /** IDs des pages de lore référencées par ce PNJ. */ + relatedPageIds: string[] = []; + constructor( private route: ActivatedRoute, private router: Router, private service: NpcService, private campaignService: CampaignService, private gameSystemService: GameSystemService, + private pageService: PageService, private campaignSidebar: CampaignSidebarService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -72,17 +89,20 @@ export class NpcEditComponent implements OnInit { if (this.campaignId) { this.loadTemplateForCampaign(this.campaignId); this.campaignSidebar.show(this.campaignId); + this.loadExistingFolders(this.campaignId); } if (this.npcId) { this.service.getById(this.npcId).subscribe({ next: (n) => { this.name = n.name; + this.folder = n.folder ?? ''; this.portraitImageId = n.portraitImageId ?? null; this.headerImageId = n.headerImageId ?? null; this.values = n.values ?? {}; this.imageValues = n.imageValues ?? {}; this.keyValueValues = n.keyValueValues ?? {}; + this.relatedPageIds = [...(n.relatedPageIds ?? [])]; this.order = n.order ?? 0; }, error: () => this.back() @@ -90,9 +110,28 @@ export class NpcEditComponent implements OnInit { } } + private loadExistingFolders(campaignId: string): void { + this.service.getByCampaign(campaignId).subscribe({ + next: (list) => { + this.existingFolders = [...new Set( + list.map(n => (n.folder ?? '').trim()).filter(f => f.length > 0) + )].sort((a, b) => a.localeCompare(b, 'fr')); + }, + error: () => { this.existingFolders = []; } + }); + } + private loadTemplateForCampaign(campaignId: string): void { this.campaignService.getCampaignById(campaignId).subscribe({ next: (campaign) => { + // Lore lié → charge ses pages pour le picker de références. + if (campaign.loreId) { + this.loreId = campaign.loreId; + this.pageService.getByLoreId(campaign.loreId).subscribe({ + next: (pages) => { this.lorePages = pages; }, + error: () => { this.lorePages = []; } + }); + } if (!campaign.gameSystemId) { this.templateFields = []; return; @@ -111,12 +150,14 @@ export class NpcEditComponent implements OnInit { if (!this.name.trim() || !this.campaignId) return; const payload = { name: this.name.trim(), + folder: this.folder.trim() || null, portraitImageId: this.portraitImageId, headerImageId: this.headerImageId, values: this.values, imageValues: this.imageValues, keyValueValues: this.keyValueValues, - campaignId: this.campaignId + campaignId: this.campaignId, + relatedPageIds: this.relatedPageIds }; const isCreation = !this.npcId; const req = this.npcId @@ -137,10 +178,10 @@ export class NpcEditComponent implements OnInit { deleteNpc(): void { if (!this.npcId) return; this.confirmDialog.confirm({ - title: 'Supprimer la fiche ?', - message: `Supprimer la fiche de "${this.name}" ?`, - details: ['Cette action est irreversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('npcEdit.deleteTitle'), + message: this.translate.instant('npcEdit.deleteMessage', { name: this.name }), + details: [this.translate.instant('npcEdit.irreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok || !this.npcId) return; diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.html b/web/src/app/campaigns/npc/npc-list/npc-list.component.html new file mode 100644 index 0000000..dc10bd4 --- /dev/null +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.html @@ -0,0 +1,44 @@ +
    +
    + + + +
    + +
    +

    {{ 'npcList.title' | translate }}

    +

    + {{ 'npcList.hint' | translate }} +

    +
    + +
    + @if (total === 0) { +

    {{ 'npcList.empty' | translate }}

    + } + @for (g of groups; track g.folder) { + @if (g.folder) { +

    + + {{ g.folder }} + {{ g.npcs.length }} +

    + } @else if (groups.length > 1) { +

    {{ 'npcList.unclassified' | translate }}

    + } + @for (n of g.npcs; track n.id) { + + } + } +
    +
    diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.scss b/web/src/app/campaigns/npc/npc-list/npc-list.component.scss new file mode 100644 index 0000000..501899e --- /dev/null +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.scss @@ -0,0 +1,53 @@ +.sbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.sbl-toolbar { + display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; + .spacer { flex: 1; } + button { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.8rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + } + .btn-create { background: #667eea; border-color: #667eea; color: #fff; } + .btn-create:hover { background: #5568d3; } +} + +.sbl-header { + margin-bottom: 1.25rem; + h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; } + .sbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; } +} + +.sbl-list { display: flex; flex-direction: column; gap: 0.4rem; } +.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes. +.sbl-folder { + display: flex; align-items: center; gap: 0.4rem; + margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.06em; + color: #a5b4fc; + + .sbl-folder-count { + font-weight: 400; color: var(--color-text-muted, #9aa0aa); + } + + &.sbl-folder--none { color: var(--color-text-muted, #9aa0aa); } +} + +.sbl-item { + display: flex; align-items: center; gap: 0.55rem; + padding: 0.7rem 0.85rem; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); + color: inherit; cursor: pointer; text-align: left; + &:hover { background: rgba(255,255,255,0.07); } + .sbl-item-name { flex: 1; font-weight: 500; } + .sbl-item-level { + font-size: 0.74rem; padding: 0.12rem 0.5rem; border-radius: 999px; + background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.35); color: #fca5a5; + } + .sbl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88; + &:hover { background: rgba(224,90,90,0.15); } } +} diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.ts b/web/src/app/campaigns/npc/npc-list/npc-list.component.ts new file mode 100644 index 0000000..7c14f4d --- /dev/null +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.ts @@ -0,0 +1,111 @@ +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'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; + +/** Groupe d'affichage : un dossier (« Bard's Gate »…) et ses PNJ. */ +interface FolderGroup { + folder: string; + npcs: Npc[]; +} + +/** + * Vue d'ensemble des PNJ d'une campagne, groupés par dossier — pendant « page » + * de l'arbre dépliable de la sidebar (les deux modes coexistent). + * Route : /campaigns/:campaignId/npcs + */ +@Component({ + selector: 'app-npc-list', + imports: [LucideAngularModule, TranslatePipe], + templateUrl: './npc-list.component.html', + styleUrls: ['./npc-list.component.scss'] +}) +export class NpcListComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly Drama = Drama; + readonly Folder = Folder; + + campaignId = ''; + /** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */ + groups: FolderGroup[] = []; + total = 0; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: NpcService, + private campaignSidebar: CampaignSidebarService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.load(); + } + } + + load(): void { + this.service.getByCampaign(this.campaignId).subscribe({ + next: (list) => this.groups = this.groupByFolder(list), + error: () => this.groups = [] + }); + } + + /** Même logique de groupement que la sidebar : dossiers triés, non-classés à la fin. */ + private groupByFolder(npcs: Npc[]): FolderGroup[] { + this.total = npcs.length; + const sorted = [...npcs].sort((a, b) => a.name.localeCompare(b.name, 'fr')); + const byFolder = new Map(); + const ungrouped: Npc[] = []; + for (const n of sorted) { + const f = (n.folder ?? '').trim(); + if (f) { + if (!byFolder.has(f)) byFolder.set(f, []); + byFolder.get(f)!.push(n); + } else { + ungrouped.push(n); + } + } + const groups: FolderGroup[] = [...byFolder.keys()] + .sort((a, b) => a.localeCompare(b, 'fr')) + .map(folder => ({ folder, npcs: byFolder.get(folder)! })); + if (ungrouped.length) groups.push({ folder: '', npcs: ungrouped }); + return groups; + } + + create(): void { + this.router.navigate(['/campaigns', this.campaignId, 'npcs', 'create']); + } + + open(n: Npc): void { + this.router.navigate(['/campaigns', this.campaignId, 'npcs', n.id]); + } + + remove(n: Npc, ev: Event): void { + ev.stopPropagation(); + this.confirmDialog.confirm({ + 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; + this.service.delete(n.id!).subscribe(() => this.load()); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.html b/web/src/app/campaigns/npc/npc-view/npc-view.component.html index b94f5a0..bdf9546 100644 --- a/web/src/app/campaigns/npc/npc-view/npc-view.component.html +++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.html @@ -2,27 +2,47 @@
    - + @if (npcId) { + + }
    + + + @if (loreId && (npc?.relatedPageIds?.length ?? 0) > 0) { + + }
    - - +@if (npcId && campaignId) { + + +} diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.scss b/web/src/app/campaigns/npc/npc-view/npc-view.component.scss index cf9f3b5..406bcad 100644 --- a/web/src/app/campaigns/npc/npc-view/npc-view.component.scss +++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.scss @@ -49,3 +49,46 @@ border-color: rgba(168, 85, 247, 0.5); color: #d8b4fe; } + +// Pages de Lore liées au PNJ — chips cliquables sous la fiche. +.nv-lore-links { + max-width: 1100px; + margin: 24px auto 0; + padding: 0 32px; + + .nv-lore-links-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.72rem; + font-weight: 600; + color: #a5b4fc; + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 0.6rem; + } + + .nv-lore-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + + .nv-lore-chip { + display: inline-flex; + align-items: center; + padding: 0.3rem 0.7rem; + background: #1a1a2e; + border: 1px solid #2a2a3d; + border-radius: 999px; + color: #d1d5db; + font-size: 0.82rem; + text-decoration: none; + transition: border-color 0.15s, color 0.15s; + + &:hover { + border-color: #6c63ff; + color: white; + } + } +} diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.ts b/web/src/app/campaigns/npc/npc-view/npc-view.component.ts index 650b37b..74ef99c 100644 --- a/web/src/app/campaigns/npc/npc-view/npc-view.component.ts +++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.ts @@ -1,13 +1,17 @@ -import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; -import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'; +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'; +import { PageService } from '../../../services/page.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { TemplateField } from '../../../services/template.model'; import { Npc } from '../../../services/npc.model'; +import { Page } from '../../../services/page.model'; import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; @@ -16,55 +20,92 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr * Route : /campaigns/:campaignId/npcs/:npcId */ @Component({ - selector: 'app-npc-view', - standalone: true, - imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent], - templateUrl: './npc-view.component.html', - styleUrls: ['./npc-view.component.scss'] + selector: 'app-npc-view', + imports: [LucideAngularModule, TranslatePipe, RouterLink, PersonaViewComponent, AiChatDrawerComponent], + templateUrl: './npc-view.component.html', + styleUrls: ['./npc-view.component.scss'] }) -export class NpcViewComponent implements OnInit { +export class NpcViewComponent implements OnInit, OnDestroy { readonly ArrowLeft = ArrowLeft; readonly Edit3 = Edit3; readonly Sparkles = Sparkles; + readonly Link2 = Link2; campaignId: string | null = null; npcId: string | null = null; npc: Npc | null = null; templateFields: TemplateField[] = []; + /** Lore lié à la campagne (résolution des chips de pages liées). */ + loreId: string | null = null; + /** Pages du lore lié, indexées pour résoudre les titres des chips. */ + private lorePagesById = new Map(); chatOpen = false; toggleChat(): void { this.chatOpen = !this.chatOpen; } + private paramsSub?: Subscription; + constructor( private route: ActivatedRoute, private router: Router, private service: NpcService, private campaignService: CampaignService, private gameSystemService: GameSystemService, - private campaignSidebar: CampaignSidebarService + private pageService: PageService, + private campaignSidebar: CampaignSidebarService, + private translate: TranslateService ) {} ngOnInit(): void { - const params = this.route.snapshot.paramMap; - this.campaignId = params.get('campaignId'); - this.npcId = params.get('npcId'); - if (this.npcId) { - this.service.getById(this.npcId).subscribe({ - next: n => { this.npc = n; }, - error: () => this.back() - }); - } - if (this.campaignId) { - this.campaignSidebar.show(this.campaignId); - this.campaignService.getCampaignById(this.campaignId).subscribe(camp => { - if (camp.gameSystemId) { - this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => { - this.templateFields = gs.npcTemplate ?? []; - }); - } - }); - } + // S'abonner aux paramMap (pas le snapshot) : quand on passe d'un PNJ à un autre, + // Angular RÉUTILISE le composant (même route) → ngOnInit ne re-tourne pas. Sans ce + // subscribe, la fiche du centre resterait figée sur l'ancien PNJ. + this.paramsSub = this.route.paramMap.subscribe(params => { + const newCampaignId = params.get('campaignId'); + this.npcId = params.get('npcId'); + + // Recharge la fiche à CHAQUE changement de PNJ. + this.chatOpen = false; + if (this.npcId) { + this.service.getById(this.npcId).subscribe({ + next: n => { this.npc = n; }, + error: () => this.back() + }); + } + + // Sidebar + template du système : seulement quand la campagne change (inutile + // de les recharger à chaque switch de PNJ d'une même campagne). + if (newCampaignId && newCampaignId !== this.campaignId) { + this.campaignId = newCampaignId; + this.campaignSidebar.show(this.campaignId); + this.campaignService.getCampaignById(this.campaignId).subscribe(camp => { + if (camp.gameSystemId) { + this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => { + this.templateFields = gs.npcTemplate ?? []; + }); + } + // Lore lié → référentiel de pages pour résoudre les chips de liens. + if (camp.loreId) { + this.loreId = camp.loreId; + this.pageService.getByLoreId(camp.loreId).subscribe(pages => { + this.lorePagesById = new Map(pages.map(p => [p.id!, p])); + }); + } + }); + } else if (newCampaignId) { + this.campaignId = newCampaignId; + } + }); + } + + ngOnDestroy(): void { + this.paramsSub?.unsubscribe(); + } + + /** Titre d'une page de lore liée (pour les chips). */ + titleOfPage(pageId: string): string { + return this.lorePagesById.get(pageId)?.title ?? this.translate.instant('npcView.deletedPage'); } edit(): void { diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html new file mode 100644 index 0000000..9348279 --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.html @@ -0,0 +1,83 @@ +@if (playthrough) { +
    + + +
    + @if (!activeOnThis) { + + } + @if (activeOnThis) { + + } + +
    + +
    +
    +

    {{ 'playthroughDetail.charactersTitle' | translate }}

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

    {{ 'playthroughDetail.noCharacters' | translate }}

    + } + @if (characters.length > 0) { +
      + @for (c of characters; track c) { +
    • + {{ c.name }} +
    • + } +
    + } +
    + +
    +

    {{ 'playthroughDetail.sessionsTitle' | translate }}

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

    {{ 'playthroughDetail.noSessions' | translate }}

    + } + @if (sessions.length > 0) { +
      + @for (s of sessions; track s) { +
    • + {{ s.name }} + {{ (s.active ? 'playthroughDetail.statusActive' : 'playthroughDetail.statusEnded') | translate }} +
    • + } +
    + } +
    +
    +} diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss new file mode 100644 index 0000000..dd4be89 --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.scss @@ -0,0 +1,135 @@ +.playthrough-page { + padding: 2.5rem 2rem; + max-width: 880px; +} + +.page-header { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; + + h1 { margin: 0 0 0.2rem; font-size: 1.6rem; } +} + +.header-info { flex: 1; } + +.subtitle { + margin: 0; + font-size: 0.9rem; + color: var(--color-text-muted, #666); +} + +.play-action { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.btn-primary.big, +.btn-secondary.big { + padding: 0.75rem 1.25rem; + font-size: 0.95rem; +} + +.block { + margin-bottom: 1.75rem; + + h2 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.05rem; + margin: 0 0 0.7rem; + } +} + +.block-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.7rem; + + h2 { margin: 0; } +} + +.btn-add { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + background: #6c63ff; + color: white; + border: none; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + + &:hover { background: #5b52e0; } +} + +.empty { + font-size: 0.88rem; + color: var(--color-text-muted, #777); + font-style: italic; +} + +.character-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + a { + display: inline-block; + padding: 0.35rem 0.7rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 999px; + text-decoration: none; + color: inherit; + + &:hover { border-color: var(--color-primary, #2c6cd6); } + } +} + +.session-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; + + li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0.85rem; + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 8px; + cursor: pointer; + transition: border-color 120ms ease; + + &:hover { border-color: var(--color-primary, #2c6cd6); } + } +} + +.session-status { + font-size: 0.78rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + background: rgba(128, 128, 128, 0.1); + color: var(--color-text-muted, #666); + + &.active { + background: rgba(52, 168, 83, 0.15); + color: #2f7a47; + font-weight: 600; + } +} diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts new file mode 100644 index 0000000..d3a8a8e --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts @@ -0,0 +1,156 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; + +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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; +import { PlaythroughService } from '../../../services/playthrough.service'; +import { SessionService } from '../../../services/session.service'; +import { LayoutService } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Playthrough } from '../../../services/campaign.model'; +import { Session } from '../../../services/session.model'; +import { Character } from '../../../services/character.model'; +import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; + +/** + * Vue détail d'une Partie (Playthrough). + * Minimal MVP — affiche les infos, le lien vers les faits, la liste des sessions + * et les PJ de cette Partie. + */ +@Component({ + selector: 'app-playthrough-detail', + imports: [RouterModule, LucideAngularModule, TranslatePipe], + templateUrl: './playthrough-detail.component.html', + styleUrls: ['./playthrough-detail.component.scss'] +}) +export class PlaythroughDetailComponent implements OnInit, OnDestroy { + readonly ArrowLeft = ArrowLeft; + readonly Play = Play; + readonly Flag = Flag; + readonly Users = Users; + readonly Trash2 = Trash2; + readonly Pencil = Pencil; + readonly Plus = Plus; + + campaignId = ''; + playthroughId = ''; + + playthrough: Playthrough | null = null; + sessions: Session[] = []; + characters: Character[] = []; + /** Session active de CETTE Partie (null si aucune). Plus de check global. */ + activeOnThis: Session | null = null; + + startingSession = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private campaignService: CampaignService, + private characterService: CharacterService, + private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, + private playthroughService: PlaythroughService, + private sessionService: SessionService, + private layoutService: LayoutService, + private pageTitleService: PageTitleService, + private confirmDialog: ConfirmDialogService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + this.route.paramMap.subscribe(pm => { + const cid = pm.get('campaignId')!; + const pid = pm.get('playthroughId')!; + if (cid !== this.campaignId || pid !== this.playthroughId) { + this.campaignId = cid; + this.playthroughId = pid; + this.load(); + } + }); + } + + private load(): void { + forkJoin({ + campaign: this.campaignService.getCampaignById(this.campaignId), + allCampaigns: this.campaignService.getAllCampaigns(), + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService), + playthrough: this.playthroughService.getById(this.playthroughId), + sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))), + characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))), + activeOnThis: this.sessionService.getActiveByPlaythrough(this.playthroughId).pipe(catchError(() => of(null))) + }).subscribe(({ campaign, allCampaigns, treeData, playthrough, sessions, characters, activeOnThis }) => { + this.playthrough = playthrough; + this.sessions = sessions; + this.characters = characters; + this.activeOnThis = activeOnThis; + this.pageTitleService.set(`${playthrough.name} — ${campaign.name}`); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); + }); + } + + startSession(): void { + if (this.startingSession || this.activeOnThis) return; + this.startingSession = true; + this.sessionService.startSession(this.playthroughId).subscribe({ + next: s => { this.startingSession = false; this.router.navigate(['/sessions', s.id]); }, + error: () => { this.startingSession = false; } + }); + } + + openSession(s: Session): void { + this.router.navigate(['/sessions', s.id]); + } + + /** Crée un PJ rattaché à CETTE Partie (route scoping Playthrough). */ + createCharacter(): void { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'characters', 'create']); + } + + openFlags(): void { + this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'flags']); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } + + delete(): void { + if (!this.playthrough) return; + this.playthroughService.deletionImpact(this.playthroughId).subscribe({ + next: impact => { + const parts: string[] = []; + 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(this.translate.instant('playthroughDetail.deleteCascade', { parts: parts.join(', ') })); + details.push(this.translate.instant('playthroughDetail.irreversible')); + this.confirmDialog.confirm({ + title: this.translate.instant('playthroughDetail.deleteTitle'), + message: this.translate.instant('playthroughDetail.deleteMessage', { name: this.playthrough?.name }), + details, + confirmLabel: this.translate.instant('common.delete'), + variant: 'danger' + }).then(ok => { + if (!ok) return; + this.playthroughService.delete(this.playthroughId).subscribe({ + next: () => this.router.navigate(['/campaigns', this.campaignId]) + }); + }); + } + }); + } + + ngOnDestroy(): void {} +} diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html new file mode 100644 index 0000000..c2fee58 --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.html @@ -0,0 +1,20 @@ +@if (playthrough) { +
    + + + +
    +} diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.scss b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.scss new file mode 100644 index 0000000..0f4c955 --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.scss @@ -0,0 +1,24 @@ +.flags-page { + padding: 2.5rem 2rem; + max-width: 720px; +} + +.page-header { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; + + h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; + } +} + +.subtitle { + margin: 0; + font-size: 0.88rem; + color: var(--color-text-muted, #666); + max-width: 60ch; + line-height: 1.4; +} diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts new file mode 100644 index 0000000..287b029 --- /dev/null +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts @@ -0,0 +1,80 @@ +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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; +import { PlaythroughService } from '../../../services/playthrough.service'; +import { LayoutService } from '../../../services/layout.service'; +import { PageTitleService } from '../../../services/page-title.service'; +import { Playthrough } from '../../../services/campaign.model'; +import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; +import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-flags-manager/playthrough-flags-manager.component'; + +/** + * Page dédiée aux "Faits" d'une Partie. + * Route : /campaigns/:campaignId/playthroughs/:playthroughId/flags + */ +@Component({ + selector: 'app-playthrough-flags-page', + imports: [RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent, TranslatePipe], + templateUrl: './playthrough-flags-page.component.html', + styleUrls: ['./playthrough-flags-page.component.scss'] +}) +export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy { + readonly ArrowLeft = ArrowLeft; + + campaignId = ''; + playthroughId = ''; + playthrough: Playthrough | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private campaignService: CampaignService, + private characterService: CharacterService, + private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, + private playthroughService: PlaythroughService, + private layoutService: LayoutService, + private pageTitleService: PageTitleService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + this.route.paramMap.subscribe(pm => { + const cid = pm.get('campaignId')!; + const pid = pm.get('playthroughId')!; + if (cid !== this.campaignId || pid !== this.playthroughId) { + this.campaignId = cid; + this.playthroughId = pid; + this.load(); + } + }); + } + + private load(): void { + forkJoin({ + campaign: this.campaignService.getCampaignById(this.campaignId), + allCampaigns: this.campaignService.getAllCampaigns(), + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService), + playthrough: this.playthroughService.getById(this.playthroughId) + }).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => { + this.playthrough = playthrough; + this.pageTitleService.set(this.translate.instant('playthroughFlagsPage.pageTitle', { name: playthrough.name })); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } + + ngOnDestroy(): void {} +} diff --git a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html new file mode 100644 index 0000000..f448ac9 --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html @@ -0,0 +1,93 @@ +
    +
    + + + +
    + +

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

    + + @if (errorMessage) { +
    {{ errorMessage }}
    + } + +
    + + +
    + +
    + + +
    + +
    + + + + {{ (formulaValid ? 'randomTableEdit.formulaValid' : 'randomTableEdit.formulaExpected') | translate }} + +
    + + +
    +
    + {{ 'randomTableEdit.aiTitle' | translate }} +
    +

    {{ 'randomTableEdit.aiHint' | translate }}

    + +
    + + @if (aiError) { + {{ aiError }} + } +
    +
    + +
    +

    {{ 'randomTableEdit.entriesTitle' | translate }}

    +
    + + +
    +
    + +
    + {{ 'randomTableEdit.colMin' | translate }} + {{ 'randomTableEdit.colMax' | translate }} + {{ 'randomTableEdit.colResult' | translate }} + {{ 'randomTableEdit.colDetail' | translate }} + +
    + + @for (e of entries; track e; let i = $index) { +
    + + + + + +
    + } + + @if (entries.length === 0) { +

    {{ 'randomTableEdit.emptyHint' | translate }}

    + } +
    diff --git a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.scss b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.scss new file mode 100644 index 0000000..922f5c9 --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.scss @@ -0,0 +1,172 @@ +.rte-page { + max-width: 900px; + margin: 0 auto; + padding: 1rem 1.5rem 3rem; +} + +.rte-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + + .spacer { flex: 1; } + + button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.8rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: inherit; + cursor: pointer; + font-size: 0.85rem; + &:hover { background: rgba(255, 255, 255, 0.09); } + &:disabled { opacity: 0.5; cursor: default; } + } + .btn-save { background: #667eea; border-color: #667eea; color: #fff; } + .btn-save:hover:not(:disabled) { background: #5568d3; } +} + +h1 { font-size: 1.4rem; margin: 0 0 1rem; } +h2 { font-size: 1rem; margin: 0; } + +.error { + padding: 0.6rem 0.9rem; + border-radius: 6px; + background: rgba(224, 90, 90, 0.12); + border: 1px solid rgba(224, 90, 90, 0.4); + color: #e88; + margin-bottom: 1rem; +} + +.form-row { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-bottom: 1rem; + + label { font-size: 0.85rem; color: var(--color-text-muted, #9aa0aa); } + + input, textarea { + padding: 0.5rem 0.7rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: inherit; + font: inherit; + &.invalid { border-color: rgba(224, 90, 90, 0.6); } + } + + .hint { font-size: 0.75rem; color: var(--color-text-muted, #9aa0aa); } + .hint.bad { color: #e88; } +} + +.entries-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 1.5rem 0 0.5rem; + + .entries-actions { display: flex; gap: 0.4rem; } + .btn-mini { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.6rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: inherit; + cursor: pointer; + font-size: 0.8rem; + &:hover { background: rgba(255, 255, 255, 0.09); } + } +} + +.entry-row { + display: grid; + grid-template-columns: 70px 70px 1.2fr 1.6fr 36px; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.4rem; + + &.head { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted, #9aa0aa); + margin-bottom: 0.3rem; + } + + input { + padding: 0.4rem 0.55rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: inherit; + font: inherit; + width: 100%; + } + + .btn-del { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem; + border-radius: 6px; + border: 1px solid rgba(224, 90, 90, 0.3); + background: rgba(224, 90, 90, 0.08); + color: #e88; + cursor: pointer; + &:hover { background: rgba(224, 90, 90, 0.18); } + } +} + +.empty-hint { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.ai-box { + margin: 1.25rem 0 0.5rem; + padding: 0.85rem 1rem; + border-radius: 10px; + background: rgba(168, 130, 255, 0.08); + border: 1px solid rgba(168, 130, 255, 0.28); + + .ai-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: #c4a8ff; + } + .ai-hint { margin: 0.3rem 0 0.5rem; font-size: 0.8rem; color: var(--color-text-muted, #9aa0aa); } + + textarea { + width: 100%; + padding: 0.5rem 0.7rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: inherit; + font: inherit; + } + + .ai-actions { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.5rem; } + .btn-ai { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.9rem; + border: none; + border-radius: 7px; + background: #8a6dff; + color: #fff; + font-weight: 600; + cursor: pointer; + &:hover:not(:disabled) { background: #7a5cf0; } + &:disabled { opacity: 0.55; cursor: default; } + } + .ai-error { color: #e88; font-size: 0.82rem; } +} diff --git a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts new file mode 100644 index 0000000..55957cd --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts @@ -0,0 +1,186 @@ +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'; +import { DiceUtils } from '../../../shared/dice.utils'; + +/** + * Création/édition d'une table aléatoire (formule de dé + entrées par plage). + * Routes : /campaigns/:campaignId/random-tables/create + * /campaigns/:campaignId/random-tables/:tableId/edit + */ +@Component({ + selector: 'app-random-table-edit', + imports: [FormsModule, LucideAngularModule, TranslatePipe], + templateUrl: './random-table-edit.component.html', + styleUrls: ['./random-table-edit.component.scss'] +}) +export class RandomTableEditComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Save = Save; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly Wand2 = Wand2; + readonly Sparkles = Sparkles; + + campaignId: string | null = null; + tableId: string | null = null; + + name = ''; + description = ''; + diceFormula = '1d20'; + entries: RandomTableEntry[] = []; + + saving = false; + errorMessage = ''; + + // --- Génération IA --- + aiPrompt = ''; + generating = false; + aiError = ''; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: RandomTableService, + private campaignSidebar: CampaignSidebarService, + private translate: TranslateService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.tableId = params.get('tableId'); + if (this.campaignId) this.campaignSidebar.show(this.campaignId); + + if (this.tableId) { + this.service.getById(this.tableId).subscribe({ + next: (t: RandomTable) => { + this.name = t.name; + this.description = t.description ?? ''; + this.diceFormula = t.diceFormula; + this.entries = t.entries.map(e => ({ ...e })); + }, + error: () => this.back() + }); + } else { + // Démarre avec une entrée vide pour guider. + this.addEntry(); + } + } + + get formulaValid(): boolean { + return DiceUtils.parse(this.diceFormula) !== null; + } + + addEntry(): void { + const range = DiceUtils.totalRange(this.diceFormula); + const next = this.entries.length ? this.entries[this.entries.length - 1].maxRoll + 1 : (range?.min ?? 1); + this.entries.push({ minRoll: next, maxRoll: next, label: '', detail: '' }); + } + + removeEntry(i: number): void { + this.entries.splice(i, 1); + } + + /** Répartit équitablement la plage du dé sur toutes les entrées. */ + autoRanges(): void { + const range = DiceUtils.totalRange(this.diceFormula); + if (!range || this.entries.length === 0) return; + const span = range.max - range.min + 1; + const n = this.entries.length; + const base = Math.floor(span / n); + let cursor = range.min; + this.entries.forEach((e, idx) => { + const size = idx === n - 1 ? (range.max - cursor + 1) : Math.max(1, base); + e.minRoll = cursor; + e.maxRoll = Math.min(range.max, cursor + size - 1); + cursor = e.maxRoll + 1; + }); + } + + /** 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 = 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({ + next: (t) => { + this.generating = false; + if (t.name && !this.name.trim()) this.name = t.name; + if (t.description) this.description = t.description; + this.entries = (t.entries ?? []).map(e => ({ ...e })); + }, + error: (err) => { + this.generating = false; + this.aiError = err?.error?.message || this.translate.instant('randomTableEdit.aiErrorGenerate'); + } + }); + } + + save(): void { + if (!this.campaignId) 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 = ''; + + const cleanEntries = this.entries + .filter(e => e.label.trim()) + .map(e => ({ + minRoll: e.minRoll, + maxRoll: Math.max(e.minRoll, e.maxRoll), + label: e.label.trim(), + detail: e.detail?.trim() || undefined + })); + + if (this.tableId) { + const payload: RandomTable = { + id: this.tableId, + name: this.name.trim(), + description: this.description.trim() || undefined, + diceFormula: this.diceFormula.trim(), + campaignId: this.campaignId, + entries: cleanEntries + }; + this.service.update(this.tableId, payload).subscribe({ + next: () => this.goToView(this.tableId!), + error: (e) => this.fail(e) + }); + } else { + const payload: RandomTableCreate = { + name: this.name.trim(), + description: this.description.trim() || undefined, + diceFormula: this.diceFormula.trim(), + campaignId: this.campaignId, + entries: cleanEntries + }; + this.service.create(payload).subscribe({ + next: (t) => this.goToView(t.id!), + error: (e) => this.fail(e) + }); + } + } + + private goToView(id: string): void { + this.saving = false; + this.router.navigate(['/campaigns', this.campaignId, 'random-tables', id]); + } + + private fail(err: unknown): void { + this.saving = false; + this.errorMessage = this.translate.instant('randomTableEdit.errorSaveFailed'); + console.error('RandomTable save failed', err); + } + + back(): void { + this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']); + } +} diff --git a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html new file mode 100644 index 0000000..42d1161 --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html @@ -0,0 +1,75 @@ +@if (table) { +
    +
    + + + +
    +
    +

    {{ table.name }}

    + @if (table.description) { +

    {{ table.description }}

    + } + {{ 'randomTableView.die' | translate }} {{ table.diceFormula }} +
    + +
    + + @if (lastRoll) { +
    + {{ lastRoll.total }} + @if (lastRoll.rolls.length > 1) { + ({{ lastRoll.rolls.join(' + ') }}) + } + + @if (matched) { + {{ matched.label }} + } + @if (!matched) { + {{ 'randomTableView.noMatch' | translate }} + } +
    + } +
    + @if (matched?.detail) { +
    {{ matched?.detail }}
    + } + +
    + @if (table.entries.length === 0) { +
    + {{ 'randomTableView.empty' | translate }} +
    + } + @if (table.entries.length > 0) { + + + + + + @for (e of table.entries; track e) { + + + + + } + +
    {{ 'randomTableView.colRoll' | translate }}{{ 'randomTableView.colResult' | translate }}
    {{ rangeLabel(e) }} + + @if (e.detail) { +
    {{ e.detail }}
    + } +
    + } +
    +
    +} diff --git a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.scss b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.scss new file mode 100644 index 0000000..d91a554 --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.scss @@ -0,0 +1,133 @@ +.rt-page { + max-width: 860px; + margin: 0 auto; + padding: 1rem 1.5rem 3rem; +} + +.rt-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + + .spacer { flex: 1; } + + button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.7rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: inherit; + cursor: pointer; + font-size: 0.85rem; + + &:hover { background: rgba(255, 255, 255, 0.09); } + } +} + +.rt-header { + margin-bottom: 1.25rem; + + h1 { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.35rem; + font-size: 1.5rem; + } + + .rt-desc { margin: 0 0 0.4rem; color: var(--color-text-muted, #9aa0aa); } + .rt-formula code { + background: rgba(255, 255, 255, 0.08); + padding: 0.1rem 0.4rem; + border-radius: 4px; + } +} + +.rt-roll { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + padding: 1rem; + border-radius: 10px; + background: rgba(102, 126, 234, 0.08); + border: 1px solid rgba(102, 126, 234, 0.25); + margin-bottom: 1rem; + + .btn-roll { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1.1rem; + border: none; + border-radius: 8px; + background: #667eea; + color: #fff; + font-weight: 600; + cursor: pointer; + + &:hover { background: #5568d3; } + } + + .rt-result { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + font-size: 1.05rem; + } + .rt-total { font-weight: 700; font-size: 1.3rem; color: #8ea2ff; } + .rt-rolls { color: var(--color-text-muted, #9aa0aa); font-size: 0.85rem; } + .rt-matched { font-weight: 600; } + .rt-nomatch { color: #e0a458; font-style: italic; } +} + +.rt-detail { + white-space: pre-wrap; + padding: 0.85rem 1rem; + margin-bottom: 1.5rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border-left: 3px solid #667eea; +} + +.rt-entries { + .rt-empty { + color: var(--color-text-muted, #9aa0aa); + font-style: italic; + padding: 1rem 0; + } + + table { width: 100%; border-collapse: collapse; } + + th, td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + vertical-align: top; + } + th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted, #9aa0aa); + } + .col-range { width: 84px; font-variant-numeric: tabular-nums; white-space: nowrap; } + + .entry-label { font-weight: 500; } + .entry-detail { + margin-top: 0.2rem; + font-size: 0.85rem; + color: var(--color-text-muted, #9aa0aa); + white-space: pre-wrap; + } + + tr.matched { + background: rgba(102, 126, 234, 0.16); + td { border-bottom-color: rgba(102, 126, 234, 0.3); } + } +} diff --git a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts new file mode 100644 index 0000000..cd5ff8d --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts @@ -0,0 +1,85 @@ +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'; +import { DiceUtils, DiceRoll } from '../../../shared/dice.utils'; + +/** + * Vue d'une table aléatoire : affiche les entrées et permet de LANCER le dé + * (jet côté client) → surligne l'entrée tombée + montre son détail. + * Route : /campaigns/:campaignId/random-tables/:tableId + */ +@Component({ + selector: 'app-random-table-view', + imports: [LucideAngularModule, TranslatePipe], + templateUrl: './random-table-view.component.html', + styleUrls: ['./random-table-view.component.scss'] +}) +export class RandomTableViewComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Edit3 = Edit3; + readonly Dices = Dices; + + campaignId: string | null = null; + tableId: string | null = null; + table: RandomTable | null = null; + + lastRoll: DiceRoll | null = null; + matched: RandomTableEntry | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: RandomTableService, + private campaignSidebar: CampaignSidebarService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.tableId = params.get('tableId'); + if (this.tableId) { + this.service.getById(this.tableId).subscribe({ + next: t => this.table = t, + error: () => this.back() + }); + } + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + } + } + + roll(): void { + if (!this.table) return; + const result = DiceUtils.roll(this.table.diceFormula); + if (!result) { this.lastRoll = null; this.matched = null; return; } + this.lastRoll = result; + this.matched = this.table.entries.find( + e => result.total >= e.minRoll && result.total <= e.maxRoll + ) ?? null; + } + + isMatched(entry: RandomTableEntry): boolean { + return this.matched === entry; + } + + rangeLabel(entry: RandomTableEntry): string { + return entry.minRoll === entry.maxRoll + ? String(entry.minRoll) + : `${entry.minRoll}–${entry.maxRoll}`; + } + + edit(): void { + if (this.campaignId && this.tableId) { + this.router.navigate(['/campaigns', this.campaignId, 'random-tables', this.tableId, 'edit']); + } + } + + back(): void { + this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']); + } +} diff --git a/web/src/app/campaigns/scene/scene-create/scene-create.component.html b/web/src/app/campaigns/scene/scene-create/scene-create.component.html index 6d7c360..0c6bd67 100644 --- a/web/src/app/campaigns/scene/scene-create/scene-create.component.html +++ b/web/src/app/campaigns/scene/scene-create/scene-create.component.html @@ -1,43 +1,45 @@
    - + + />
    - +
    - +
    - +
    diff --git a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts index 78aa368..51c6aab 100644 --- a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts +++ b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts @@ -1,12 +1,15 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; 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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { LayoutService } from '../../../services/layout.service'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; @@ -17,11 +20,10 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; * Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create */ @Component({ - selector: 'app-scene-create', - standalone: true, - imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent], - templateUrl: './scene-create.component.html', - styleUrls: ['./scene-create.component.scss'] + selector: 'app-scene-create', + imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent, TranslatePipe], + templateUrl: './scene-create.component.html', + styleUrls: ['./scene-create.component.scss'] }) export class SceneCreateComponent implements OnInit, OnDestroy { readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; @@ -41,7 +43,10 @@ export class SceneCreateComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, - private layoutService: LayoutService + private randomTableService: RandomTableService, + private enemyService: EnemyService, + private layoutService: LayoutService, + private translate: TranslateService ) { this.form = this.fb.group({ name: ['', Validators.required], @@ -60,13 +65,13 @@ export class SceneCreateComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).subscribe(({ campaign, allCampaigns, treeData }) => { const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId); this.chapterName = currentChapter?.name ?? ''; this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0; - this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId)); + this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate)); }); } diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html index 328f33b..cd558f4 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html @@ -2,24 +2,24 @@ @@ -28,208 +28,237 @@
    - + - Portraits des PNJ, ambiance visuelle, scenes evocatrices... + {{ 'sceneEdit.illustrationsHint' | translate }}
    - + - Plans du lieu, cartes tactiques, schemas utilisables a la table. + {{ 'sceneEdit.mapsHint' | translate }}
    - + + />
    - +
    - +
    - +
    - - + +
    - - + +
    - +
    - +
    - Ce texte peut être lu directement à vos joueurs. + {{ 'sceneEdit.narrationHint' | translate }}
    - +
    - Ces notes sont privées et visibles uniquement par le MJ. + {{ 'sceneEdit.gmNotesHint' | translate }}
    - +
    - -
    - - 💡 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. - -
    - -
    -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - - + + @if (siblingScenes.length === 0) { +
    + + {{ 'sceneEdit.branchesNoSibling' | translate }} +
    + } - - - - Chaque branche représente une "sortie" possible depuis cette scène selon l'action des joueurs. - Les cibles sont limitées aux scènes du même chapitre. - -
    + @if (siblingScenes.length > 0) { +
    + @for (branch of branches; track $index; let i = $index) { +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + } + + + {{ 'sceneEdit.branchesHint' | translate }} + +
    + } - +
    - - + +
    - + + + + + {{ 'sceneEdit.bestiaryEnemiesHint' | translate }} + +
    +
    +
    - + @if (loreId) { + +
    + + + + {{ 'sceneEdit.loreHint' | translate }} + +
    +
    + } + + @if (!loreId) {
    - - - Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page. + {{ 'sceneEdit.noLoreHint' | translate }}
    -
    + } -
    + + - 💡 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.dungeonHint' | translate }} -
    + + +
    @@ -241,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"> diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts index 679a71e..7cbdbda 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts @@ -1,24 +1,30 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; 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'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Scene, SceneBranch } from '../../../services/campaign.model'; +import { Scene, SceneBranch, Room } from '../../../services/campaign.model'; import { Page } from '../../../services/page.model'; +import { Enemy } from '../../../services/enemy.model'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component'; import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component'; +import { EnemyLinkPickerComponent } from '../../../shared/enemy-link-picker/enemy-link-picker.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; +import { RoomsEditorComponent } from '../../../shared/rooms-editor/rooms-editor.component'; import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; @@ -27,11 +33,10 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia * Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId */ @Component({ - selector: 'app-scene-edit', - standalone: true, - imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent], - templateUrl: './scene-edit.component.html', - styleUrls: ['./scene-edit.component.scss'] + selector: 'app-scene-edit', + imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent, TranslatePipe], + templateUrl: './scene-edit.component.html', + styleUrls: ['./scene-edit.component.scss'] }) export class SceneEditComponent implements OnInit, OnDestroy { readonly Trash2 = Trash2; @@ -41,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; } @@ -59,6 +66,9 @@ export class SceneEditComponent implements OnInit, OnDestroy { availablePages: Page[] = []; loreId: string | null = null; relatedPageIds: string[] = []; + /** Bestiaire de la campagne + fiches liées à la rencontre. */ + availableEnemies: Enemy[] = []; + enemyIds: string[] = []; illustrationImageIds: string[] = []; mapImageIds: string[] = []; @@ -67,6 +77,11 @@ export class SceneEditComponent implements OnInit, OnDestroy { /** Branches narratives (état local mutable, persisté au submit). */ branches: SceneBranch[] = []; + /** Pièces du lieu explorable (état local, persisté au submit). */ + rooms: Room[] = []; + + onRoomsChange(next: Room[]): void { this.rooms = next; } + constructor( private fb: FormBuilder, private route: ActivatedRoute, @@ -74,10 +89,13 @@ export class SceneEditComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, 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], @@ -126,7 +144,7 @@ export class SceneEditComponent implements OnInit, OnDestroy { allCampaigns: this.campaignService.getAllCampaigns(), scene: this.campaignService.getSceneById(this.sceneId), chapterScenes: this.campaignService.getScenes(this.chapterId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; @@ -139,11 +157,14 @@ export class SceneEditComponent implements OnInit, OnDestroy { this.loreId = loreId; this.availablePages = pages; this.relatedPageIds = [...(scene.relatedPageIds ?? [])]; + this.availableEnemies = treeData.enemies ?? []; + this.enemyIds = [...(scene.enemyIds ?? [])]; this.selectedIcon = scene.icon ?? null; this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])]; this.mapImageIds = [...(scene.mapImageIds ?? [])]; this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId); this.branches = (scene.branches ?? []).map(b => ({ ...b })); + this.rooms = (scene.rooms ?? []).map(r => ({ ...r, branches: [...(r.branches ?? [])] })); this.form.patchValue({ name: scene.name, description: scene.description ?? '', @@ -157,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)); }); } @@ -176,10 +197,12 @@ export class SceneEditComponent implements OnInit, OnDestroy { choicesConsequences: this.form.value.choicesConsequences, combatDifficulty: this.form.value.combatDifficulty, enemies: this.form.value.enemies, + enemyIds: this.enemyIds, relatedPageIds: this.relatedPageIds, illustrationImageIds: this.illustrationImageIds, mapImageIds: this.mapImageIds, branches: this.branches, + rooms: this.rooms, icon: this.selectedIcon }).subscribe({ next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]), @@ -189,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; @@ -209,7 +232,6 @@ export class SceneEditComponent implements OnInit, OnDestroy { // ─────────────── Gestion des branches narratives ─────────────── - trackByIndex = (i: number) => i; addBranch(): void { this.branches.push({ label: '', targetSceneId: '', condition: '' }); diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.html b/web/src/app/campaigns/scene/scene-view/scene-view.component.html index 2642dbc..8d20b79 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.html +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.html @@ -1,103 +1,212 @@ -
    - -
    -
    -

    - - {{ scene.name }} -

    -

    Scène

    -
    -
    - - -
    -
    - - -
    - -
    - - -
    -

    🗺️ Cartes & plans

    - -
    - - -
    -

    📝 Description

    -

    {{ scene.description }}

    -

    Non renseigné

    -
    - - -
    -
    -

    📍 Lieu

    -

    {{ scene.location }}

    -
    -
    -

    Moment

    -

    {{ scene.timing }}

    +@if (scene) { +
    +
    +
    +

    + @if (scene.icon) { + + } + {{ scene.name }} +

    +

    {{ 'sceneView.subtitle' | translate }}

    +
    +
    + + +
    +
    + + @if ((scene.illustrationImageIds?.length ?? 0) > 0) { +
    + +
    + } + + @if ((scene.mapImageIds?.length ?? 0) > 0) { +
    +

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

    + +
    + } + +
    +

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

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

    {{ scene.description }}

    + } @else { +

    {{ 'sceneView.empty' | translate }}

    + }
    + + @if (scene.location?.trim() || scene.timing?.trim()) { +
    + @if (scene.location?.trim()) { +
    +

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

    +

    {{ scene.location }}

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

    {{ 'sceneView.timingSectionTitle' | translate }}

    +

    {{ scene.timing }}

    +
    + } +
    + } + @if (scene.atmosphere?.trim()) { +
    +

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

    +

    {{ scene.atmosphere }}

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

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

    +

    {{ scene.playerNarration }}

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

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

    +

    {{ scene.choicesConsequences }}

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

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

    +

    {{ scene.combatDifficulty }}

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

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

    + @if (linkedEnemies.length > 0) { +
    + @for (e of linkedEnemies; track e.id) { + + {{ enemyLabel(e) }} + + } +
    + } + @if (scene.enemies?.trim()) { +

    {{ scene.enemies }}

    + } +
    + } + } + + @if (scene.gmSecretNotes?.trim()) { +
    +

    + 🔒 + {{ 'sceneView.gmNotesSectionTitle' | translate }} +

    +

    {{ scene.gmSecretNotes }}

    +
    + } + + @if (loreId && (scene.relatedPageIds?.length ?? 0) > 0) { +
    +

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

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

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

    +
    + @for (r of scene.rooms; track r; let i = $index) { +
    +
    + #{{ i + 1 }} + {{ r.name }} + @if (r.floor !== null && r.floor !== undefined) { + + {{ 'sceneView.roomFloor' | translate:{ floor: r.floor } }} + + } +
    + @if (r.description?.trim()) { +

    {{ r.description }}

    + } +
    + @if (r.enemies?.trim() || roomLinkedEnemies(r).length > 0) { +
    + {{ 'sceneView.roomEnemies' | translate }} + @if (roomLinkedEnemies(r).length > 0) { +
    + @for (e of roomLinkedEnemies(r); track e.id) { + + {{ enemyLabel(e) }} + + } +
    + } + @if (r.enemies?.trim()) { +

    {{ r.enemies }}

    + } +
    + } + @if (r.loot?.trim()) { +
    + {{ 'sceneView.roomLoot' | translate }} +

    {{ r.loot }}

    +
    + } + @if (r.traps?.trim()) { +
    + {{ 'sceneView.roomTraps' | translate }} +

    {{ r.traps }}

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

    {{ r.gmNotes }}

    +
    + } +
    + @if ((r.branches?.length ?? 0) > 0) { +
    + {{ 'sceneView.roomExits' | translate }} +
      + @for (b of r.branches; track b) { +
    • + {{ b.label }} → {{ roomNameById(scene, b.targetRoomId) }} + @if (b.condition?.trim()) { + {{ 'sceneView.roomExitCondition' | translate:{ condition: b.condition } }} + } +
    • + } +
    +
    + } +
    + } +
    +
    + }
    - -
    -

    🌫️ Ambiance et atmosphère

    -

    {{ scene.atmosphere }}

    -
    - - -
    -

    📖 Narration pour les joueurs

    -

    {{ scene.playerNarration }}

    -
    - - -
    -

    🔀 Choix et conséquences

    -

    {{ scene.choicesConsequences }}

    -
    - - - -
    -

    ⚔️ Difficulté estimée

    -

    {{ scene.combatDifficulty }}

    -
    -
    -

    🐲 Ennemis et créatures

    -

    {{ scene.enemies }}

    -
    -
    - - -
    -

    - 🔒 - Notes et secrets du MJ -

    -

    {{ scene.gmSecretNotes }}

    -
    - - -
    -

    🔗 Pages Lore associées

    - -
    - -
    +} diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.scss b/web/src/app/campaigns/scene/scene-view/scene-view.component.scss index 9c48f33..dcdcba8 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.scss +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.scss @@ -1 +1,84 @@ // Styles partagés via styles/_view.scss + +.rooms-readonly { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.room-readonly { + border: 1px solid var(--color-border, #e2e2e2); + border-radius: 10px; + padding: 0.85rem 1rem; + background: var(--color-surface, #fff); +} + +.room-readonly-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; +} + +.room-readonly-index { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.78rem; + color: var(--color-text-muted, #777); +} + +.room-readonly-name { + font-weight: 600; + font-size: 1rem; + flex: 1; +} + +.room-readonly-floor { + font-size: 0.78rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + background: rgba(66, 133, 244, 0.1); + color: #2c6cd6; +} + +.room-readonly-desc { + margin: 0 0 0.6rem; + font-size: 0.9rem; + line-height: 1.5; + color: var(--color-text, #333); + white-space: pre-wrap; +} + +.room-readonly-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.7rem; + margin-bottom: 0.5rem; + + > div { + strong { display: block; font-size: 0.82rem; margin-bottom: 0.2rem; color: var(--color-text-muted, #555); } + p { margin: 0; font-size: 0.88rem; white-space: pre-wrap; } + } +} + +.room-readonly-private { + background: rgba(192, 57, 43, 0.05); + padding: 0.4rem 0.55rem; + border-radius: 6px; + border-left: 3px solid rgba(192, 57, 43, 0.4); +} + +.room-readonly-branches { + margin-top: 0.45rem; + padding-top: 0.45rem; + border-top: 1px dashed var(--color-border, #ececec); + font-size: 0.85rem; + + strong { display: block; font-size: 0.82rem; margin-bottom: 0.15rem; color: var(--color-text-muted, #555); } + ul { margin: 0; padding-left: 1.1rem; } + li { margin-bottom: 0.1rem; } +} + +.branch-cond { + color: var(--color-text-muted, #777); + font-style: italic; +} diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts index 24d9bc5..a93f27a 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts @@ -1,18 +1,22 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; +import { EnemyService } from '../../../services/enemy.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; -import { Scene } from '../../../services/campaign.model'; +import { Scene, Room } from '../../../services/campaign.model'; import { Page } from '../../../services/page.model'; +import { Enemy } from '../../../services/enemy.model'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; @@ -22,11 +26,10 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia * Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId */ @Component({ - selector: 'app-scene-view', - standalone: true, - imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent], - templateUrl: './scene-view.component.html', - styleUrls: ['./scene-view.component.scss'] + selector: 'app-scene-view', + imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe], + templateUrl: './scene-view.component.html', + styleUrls: ['./scene-view.component.scss'] }) export class SceneViewComponent implements OnInit, OnDestroy { readonly Pencil = Pencil; @@ -41,6 +44,8 @@ export class SceneViewComponent implements OnInit, OnDestroy { loreId: string | null = null; availablePages: Page[] = []; + /** Bestiaire de la campagne — résout les enemyIds de la scène en fiches. */ + availableEnemies: Enemy[] = []; constructor( private route: ActivatedRoute, @@ -48,10 +53,13 @@ export class SceneViewComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, + private enemyService: EnemyService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -78,7 +86,7 @@ export class SceneViewComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), scene: this.campaignService.getSceneById(this.sceneId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; @@ -89,14 +97,40 @@ export class SceneViewComponent implements OnInit, OnDestroy { this.scene = scene; this.loreId = loreId; this.availablePages = pages; + 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). */ + get linkedEnemies(): Enemy[] { + return (this.scene?.enemyIds ?? []) + .map(id => this.availableEnemies.find(e => e.id === id)) + .filter((e): e is Enemy => !!e); + } + + /** Libellé d'un chip ennemi : nom + niveau s'il est renseigné. */ + enemyLabel(enemy: Enemy): string { + const level = enemy.level?.trim(); + return level ? `${enemy.name} (${level})` : enemy.name; + } + + /** Fiches du bestiaire liées à une pièce (IDs orphelins ignorés). */ + roomLinkedEnemies(room: Room): Enemy[] { + return (room.enemyIds ?? []) + .map(id => this.availableEnemies.find(e => e.id === id)) + .filter((e): e is Enemy => !!e); + } + + /** 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 ?? this.translate.instant('sceneView.deletedRoom'); } editMode(): void { @@ -111,10 +145,10 @@ export class SceneViewComponent implements OnInit, OnDestroy { if (!this.scene) return; const scene = this.scene; this.confirmDialog.confirm({ - title: 'Supprimer la scène', - message: `Supprimer la scène "${scene.name}" ?`, - details: ['Cette action est irréversible.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('sceneView.deleteTitle'), + message: this.translate.instant('sceneView.deleteMessage', { name: scene.name }), + details: [this.translate.instant('sceneView.deleteIrreversible')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html index b3907a2..3f6cef7 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.html +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.html @@ -3,127 +3,173 @@

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

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

    Règles du système

    -

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

    +

    {{ 'gameSystemEdit.rulesTitle' | translate }}

    +

    {{ 'gameSystemEdit.rulesHint' | translate }}

    + + +
    + + + @if (!importing) { + {{ 'gameSystemEdit.importHint' | translate }} + } +
    + + + @if (importing) { +
    +

    {{ importPhase }}

    + @if (importProgress) { +
    +
    +
    +
    + } + @if (importStatus) { +

    {{ importStatus }}

    + } + @if (importFound.length) { +

    + {{ 'gameSystemEdit.sectionsFound' | translate:{ titles: importFound.join(' · ') } }} +

    + } +
    + } + + @if (importNote) { +

    {{ importNote }}

    + } + @if (importError) { +

    {{ importError }}

    + }
    -
    - -
    - - - -
    - - + @for (section of sections; track section; let i = $index) { +
    +
    + + + +
    + @if (!section.collapsed) { + + } +
    + } + @if (sections.length === 0) { +
    + {{ 'gameSystemEdit.noSections' | translate }} +
    + }
    -
    - Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous. +
    + {{ 'gameSystemEdit.addSection' | translate }} + @for (name of suggestedSections; track name) { + + } +
    -
    - Ajouter une section : - - + +
    +

    {{ 'gameSystemEdit.charactersTitle' | translate }}

    +

    {{ 'gameSystemEdit.charactersHint' | translate }}

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

    Fiches de personnages

    -

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

    +
    + + +
    - - - - - -
    - -
    - -
    - -
    diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss b/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss index 1cee97a..5835413 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.scss @@ -95,6 +95,104 @@ margin: 0 0 1rem; } +// --- Import PDF de règles --------------------------------------------------- +.import-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin: 0 0 1rem; +} + +.btn-import { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 0.9rem; + background: #1f2937; + color: #e5e7eb; + border: 1px solid #374151; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; + + &:hover:not(:disabled) { background: #273244; border-color: #6c63ff; } + &:disabled { opacity: 0.6; cursor: progress; } +} + +.import-hint { + color: #6b7280; + font-size: 0.8rem; +} + +.import-progress { + margin: -0.4rem 0 1rem; + padding: 0.7rem 0.85rem; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 8px; +} + +.import-phase { + margin: 0 0 0.5rem; + color: #c4b5fd; + font-size: 0.85rem; + font-weight: 500; +} + +.progress-bar { + height: 6px; + background: #1f2937; + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: #6c63ff; + border-radius: 999px; + transition: width 0.3s ease; +} + +.import-found { + margin: 0.55rem 0 0; + color: #9ca3af; + font-size: 0.78rem; + line-height: 1.4; +} + +// Message d'attente live (fournisseur saturé → retry, morceau re-découpé…) : +// ambre pour signaler « ça travaille, mais il se passe quelque chose ». +.import-status { + margin: 0.55rem 0 0; + color: #fbbf24; + font-size: 0.8rem; + font-style: italic; + line-height: 1.4; +} + +.import-note { + margin: -0.4rem 0 1rem; + padding: 0.55rem 0.8rem; + background: rgba(108, 99, 255, 0.12); + border: 1px solid rgba(108, 99, 255, 0.35); + border-radius: 8px; + color: #c4b5fd; + font-size: 0.85rem; +} + +.import-error { + margin: -0.4rem 0 1rem; + padding: 0.55rem 0.8rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.35); + border-radius: 8px; + color: #fca5a5; + font-size: 0.85rem; +} + .section-list { display: flex; flex-direction: column; diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts index 763e05d..dc48374 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; + import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular'; +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'; @@ -39,12 +40,14 @@ const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'N /** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */ const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ']; +/** Suggestions de champs pour la fiche ENNEMI — stats de combat. */ +const ENEMY_FIELD_SUGGESTIONS = ['Stats', 'Attaques', 'Capacités', 'Faiblesses', 'Butin', 'Tactique']; + @Component({ - selector: 'app-game-system-edit', - standalone: true, - imports: [CommonModule, FormsModule, LucideAngularModule, TemplateFieldsEditorComponent], - templateUrl: './game-system-edit.component.html', - styleUrls: ['./game-system-edit.component.scss'] + selector: 'app-game-system-edit', + imports: [FormsModule, LucideAngularModule, TranslatePipe, TemplateFieldsEditorComponent], + templateUrl: './game-system-edit.component.html', + styleUrls: ['./game-system-edit.component.scss'] }) export class GameSystemEditComponent implements OnInit { readonly Save = Save; @@ -54,24 +57,47 @@ export class GameSystemEditComponent implements OnInit { readonly Trash2 = Trash2; readonly ChevronDown = ChevronDown; readonly ChevronRight = ChevronRight; + readonly Upload = Upload; id: string | null = null; + /** Import PDF en cours (appel LLM long) → désactive le bouton + spinner. */ + importing = false; + /** Message de succès post-import (sections ajoutées, pages, OCR). */ + importNote: string | null = null; + /** Message d'erreur d'import (Brain injoignable, PDF illisible…). */ + importError: string | null = null; + /** Libellé de l'étape courante (« Extraction… », « Analyse… (3/12) »). */ + importPhase = ''; + /** Avancement de la structuration ; null pendant l'extraction (total inconnu). */ + importProgress: { current: number; total: number } | null = null; + /** Titres de sections trouvés au fil de l'eau (affichage live). */ + importFound: string[] = []; + /** + * Dernier message de statut du flux (fournisseur saturé → retry, morceau + * re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE + * en cours, pas l'historique. + */ + importStatus: string | null = null; + name = ''; description = ''; author = ''; sections: RuleSection[] = []; characterTemplate: TemplateField[] = []; npcTemplate: TemplateField[] = []; + enemyTemplate: TemplateField[] = []; readonly suggestedSections = SUGGESTED_SECTIONS; readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS; readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS; + readonly enemyFieldSuggestions = ENEMY_FIELD_SUGGESTIONS; constructor( private route: ActivatedRoute, private router: Router, - private service: GameSystemService + private service: GameSystemService, + private translate: TranslateService ) {} ngOnInit(): void { @@ -85,6 +111,7 @@ export class GameSystemEditComponent implements OnInit { this.sections = this.parseMarkdown(gs.rulesMarkdown ?? ''); this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : []; this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : []; + this.enemyTemplate = gs.enemyTemplate ? [...gs.enemyTemplate] : []; }, error: () => this.back() }); @@ -108,6 +135,105 @@ export class GameSystemEditComponent implements OnInit { this.sections.push({ title: '', content: '', collapsed: false }); } + // --- Import d'un PDF de règles ------------------------------------------- + + /** Déclenché par le caché : lance l'import du PDF choisi. */ + onPdfSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + // Reset de la valeur : re-sélectionner le même fichier doit re-déclencher. + input.value = ''; + if (file) this.importPdf(file); + } + + private importPdf(file: File): void { + this.importing = true; + this.importNote = null; + this.importError = null; + this.importPhase = this.translate.instant('gameSystemEdit.importExtracting'); + this.importProgress = null; + this.importFound = []; + this.importStatus = null; + + this.service.importRulesStream(file).subscribe({ + next: (ev) => { + if (ev.type === 'progress') { + // Un morceau vient d'aboutir : le message d'attente est obsolète. + this.importStatus = null; + if (ev.total === 0) { + // Phase d'extraction (total encore inconnu). + this.importPhase = this.translate.instant('gameSystemEdit.importExtracting'); + this.importProgress = null; + } else { + 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); + } + } + } else if (ev.type === 'status') { + this.importStatus = ev.message; + } else if (ev.type === 'done') { + this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount); + } + }, + error: (err: Error) => { + this.resetImportProgress(); + this.importError = err?.message + ? this.translate.instant('gameSystemEdit.importFailedReason', { reason: err.message }) + : this.translate.instant('gameSystemEdit.importFailed'); + } + }); + } + + private finishImport(sections: Record, pageCount: number, ocrPageCount: number): void { + this.resetImportProgress(); + const added = this.mergeImportedSections(sections); + if (added === 0) { + this.importError = this.translate.instant('gameSystemEdit.importNoRules'); + return; + } + 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 { + this.importing = false; + this.importPhase = ''; + this.importProgress = null; + } + + /** + * Fusionne les sections proposées dans l'éditeur. Section de même titre + * (insensible à la casse) → contenu ajouté à la suite ; sinon nouvelle carte. + * Retourne le nombre de sections effectivement intégrées. + */ + private mergeImportedSections(sections: Record): number { + let count = 0; + for (const [rawTitle, rawContent] of Object.entries(sections ?? {})) { + const title = (rawTitle ?? '').trim(); + const content = (rawContent ?? '').trim(); + if (!title || !content) continue; + const existing = this.sections.find( + s => s.title.trim().toLowerCase() === title.toLowerCase() + ); + if (existing) { + existing.content = `${existing.content.trim()}\n\n${content}`.trim(); + existing.collapsed = false; + } else { + this.sections.push({ title, content, collapsed: false }); + } + count++; + } + return count; + } + removeSection(index: number): void { this.sections.splice(index, 1); } @@ -129,6 +255,7 @@ export class GameSystemEditComponent implements OnInit { rulesMarkdown: this.serializeMarkdown(), characterTemplate: this.characterTemplate, npcTemplate: this.npcTemplate, + enemyTemplate: this.enemyTemplate, isPublic: false }; const req = this.id @@ -146,7 +273,9 @@ export class GameSystemEditComponent implements OnInit { /** Validation cote front : nom vide ou doublons (case-insensitive). */ private hasInvalidTemplateFields(): boolean { - return this.hasInvalidList(this.characterTemplate) || this.hasInvalidList(this.npcTemplate); + return this.hasInvalidList(this.characterTemplate) + || this.hasInvalidList(this.npcTemplate) + || this.hasInvalidList(this.enemyTemplate); } private hasInvalidList(fields: TemplateField[]): boolean { diff --git a/web/src/app/game-systems/game-systems.component.html b/web/src/app/game-systems/game-systems.component.html index 4bc73a0..a9f88f8 100644 --- a/web/src/app/game-systems/game-systems.component.html +++ b/web/src/app/game-systems/game-systems.component.html @@ -2,38 +2,44 @@
    -

    Systèmes de JDR

    -

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

    +

    {{ 'gameSystems.heroTitle' | translate }}

    +

    {{ 'gameSystems.heroSubtitle' | translate }}

    -
    -
    -

    {{ gs.name }}

    -
    - + @for (gs of gameSystems; track gs) { +
    +
    +

    {{ gs.name }}

    +
    + +
    +
    +

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

    +
    -

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

    - -
    + }
    -

    Nouveau système

    -

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

    +

    {{ 'gameSystems.newSystem' | translate }}

    +

    {{ 'gameSystems.newSystemSubtitle' | translate }}

    -

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

    +

    diff --git a/web/src/app/game-systems/game-systems.component.ts b/web/src/app/game-systems/game-systems.component.ts index e2d1d01..cb4a883 100644 --- a/web/src/app/game-systems/game-systems.component.ts +++ b/web/src/app/game-systems/game-systems.component.ts @@ -1,17 +1,18 @@ import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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'; import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service'; @Component({ - selector: 'app-game-systems', - standalone: true, - imports: [CommonModule, LucideAngularModule], - templateUrl: './game-systems.component.html', - styleUrls: ['./game-systems.component.scss'] + selector: 'app-game-systems', + imports: [LucideAngularModule, TranslatePipe], + templateUrl: './game-systems.component.html', + styleUrls: ['./game-systems.component.scss'] }) export class GameSystemsComponent implements OnInit { readonly Dices = Dices; @@ -24,10 +25,15 @@ export class GameSystemsComponent implements OnInit { constructor( private router: Router, private gameSystemService: GameSystemService, - private confirmDialog: ConfirmDialogService + private confirmDialog: ConfirmDialogService, + private layoutService: LayoutService, + private translate: TranslateService ) {} ngOnInit(): void { + // Page racine : on s'assure de ne pas heriter de la sidebar d'une + // section precedente (cf. fix CampaignsComponent / LoreComponent). + this.layoutService.hide(); this.load(); } @@ -50,10 +56,10 @@ export class GameSystemsComponent implements OnInit { event.stopPropagation(); if (!system.id) return; this.confirmDialog.confirm({ - title: 'Supprimer le système', - message: `Supprimer le système "${system.name}" ?`, - details: ['Les campagnes qui l\'utilisent ne seront plus associées à aucun système.'], - confirmLabel: 'Supprimer', + title: this.translate.instant('gameSystems.deleteTitle'), + message: this.translate.instant('gameSystems.deleteMessage', { name: system.name }), + details: [this.translate.instant('gameSystems.deleteDetail')], + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok || !system.id) return; diff --git a/web/src/app/lore/folder-view/folder-view.component.html b/web/src/app/lore/folder-view/folder-view.component.html index 44b2545..bf79ec4 100644 --- a/web/src/app/lore/folder-view/folder-view.component.html +++ b/web/src/app/lore/folder-view/folder-view.component.html @@ -1,85 +1,92 @@ -
    - - - + +
    +
    +

    + + {{ node.name }} +

    +

    + {{ 'folderView.summary' | translate:{ folders: subfolders.length, pages: pages.length } }} +

    +
    +
    + + +
    + +
    +
    +

    {{ 'folderView.subfolders' | translate }}

    + +
    + @if (subfolders.length > 0) { +
    + @for (sub of subfolders; track sub) { +
    + + {{ sub.name }} +
    + } +
    + } + @if (subfolders.length === 0) { +
    +

    {{ 'folderView.noSubfolders' | translate }}

    +
    + } +
    + +
    +
    +

    {{ 'folderView.pages' | translate }}

    + +
    + @if (pages.length > 0) { +
    + @for (page of pages; track page) { +
    + + {{ page.title }} +
    + } +
    + } + @if (pages.length === 0) { +
    +

    {{ 'folderView.noPages' | translate }}

    +
    + } +
    - - -
    -
    -

    Sous-dossiers

    - -
    - -
    -
    - - {{ sub.name }} -
    -
    - -
    -

    Aucun sous-dossier.

    -
    -
    - - -
    -
    -

    Pages

    - -
    - -
    -
    - - {{ page.title }} -
    -
    - -
    -

    Aucune page dans ce dossier.

    -
    -
    - -
    +} diff --git a/web/src/app/lore/folder-view/folder-view.component.ts b/web/src/app/lore/folder-view/folder-view.component.ts index 929ca23..8ba10a1 100644 --- a/web/src/app/lore/folder-view/folder-view.component.ts +++ b/web/src/app/lore/folder-view/folder-view.component.ts @@ -1,8 +1,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; + 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'; @@ -23,11 +24,10 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog * dialogue d'impact que les autres écrans. */ @Component({ - selector: 'app-folder-view', - standalone: true, - imports: [CommonModule, LucideAngularModule], - templateUrl: './folder-view.component.html', - styleUrls: ['./folder-view.component.scss'] + selector: 'app-folder-view', + imports: [LucideAngularModule, TranslatePipe], + templateUrl: './folder-view.component.html', + styleUrls: ['./folder-view.component.scss'] }) export class FolderViewComponent implements OnInit, OnDestroy { readonly Folder = Folder; @@ -54,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 { @@ -147,20 +148,24 @@ export class FolderViewComponent implements OnInit, OnDestroy { this.loreService.getLoreNodeDeletionImpact(this.folderId).subscribe({ next: impact => { const parts: string[] = []; - if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`); - if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`); + if (impact.folders > 0) parts.push(this.translate.instant( + impact.folders > 1 ? 'folderView.impact.subfoldersPlural' : 'folderView.impact.subfolders', + { n: impact.folders })); + if (impact.pages > 0) parts.push(this.translate.instant( + impact.pages > 1 ? 'folderView.impact.pagesPlural' : 'folderView.impact.pages', + { n: impact.pages })); const details: string[] = []; if (parts.length) { - details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`); + details.push(this.translate.instant('folderView.impact.alsoDeletes', { items: parts.join(', ') })); } - details.push('Cette action est irréversible.'); + details.push(this.translate.instant('folderView.impact.irreversible')); this.confirmDialog.confirm({ - title: 'Supprimer le dossier', - message: `Supprimer le dossier "${node.name}" ?`, + title: this.translate.instant('folderView.deleteConfirmTitle'), + message: this.translate.instant('folderView.deleteConfirmMessage', { name: node.name }), details, - confirmLabel: 'Supprimer', + confirmLabel: this.translate.instant('common.delete'), variant: 'danger' }).then(ok => { if (!ok) return; diff --git a/web/src/app/lore/lore-create/lore-create.component.html b/web/src/app/lore/lore-create/lore-create.component.html index fd8716e..2b7a32c 100644 --- a/web/src/app/lore/lore-create/lore-create.component.html +++ b/web/src/app/lore/lore-create/lore-create.component.html @@ -2,7 +2,7 @@