Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
All checks were successful
All checks were successful
mode séance, battlemaps multiples et export Foundry ciblé
- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
Fusion quête/conteneur dans la sidebar, progression par partie (statuts
disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
automatiquement (arc technique « Quêtes libres », V21 : levée de la
contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
brouillons de scènes en propose→applique (brain : narrative-fields,
scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
épinglée (V19), récap « précédemment » (brain : session-recap), onglet
« Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
(respect du périmètre + une Scene Foundry par variante de carte,
rétro-compatible anciens bundles).
This commit is contained in:
@@ -27,6 +27,10 @@ export const routes: Routes = [
|
||||
{ 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/quests', loadComponent: () => import('./campaigns/quest/quest-list/quest-list.component').then(m => m.QuestListComponent) },
|
||||
{ path: 'campaigns/:campaignId/quests/create', loadComponent: () => import('./campaigns/quest/quest-edit/quest-edit.component').then(m => m.QuestEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/quests/:questId/edit', loadComponent: () => import('./campaigns/quest/quest-edit/quest-edit.component').then(m => m.QuestEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/quests/:questId', loadComponent: () => import('./campaigns/quest/quest-view/quest-view.component').then(m => m.QuestViewComponent) },
|
||||
{ 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) },
|
||||
|
||||
@@ -26,18 +26,15 @@
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>{{ 'arcEdit.illustrationsLabel' | translate }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">{{ 'arcEdit.illustrationsHint' | translate }}</small>
|
||||
</div>
|
||||
<!-- Tout est optionnel sauf le titre : identité visible, le reste replié. -->
|
||||
<p class="optional-hint">{{ 'arcEdit.optionalHint' | translate }}</p>
|
||||
|
||||
<!-- Co-création (Pilier A) : le co-MJ propose, validation champ par champ → remplit le formulaire. -->
|
||||
<app-entity-assist-panel
|
||||
[entityType]="'arc'" [entityId]="arcId" [campaignId]="campaignId"
|
||||
(applied)="onAssistApplied($event)"></app-entity-assist-panel>
|
||||
|
||||
<!-- Identité -->
|
||||
<div class="field">
|
||||
<label for="arc-edit-name">{{ 'arcEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
@@ -80,62 +77,80 @@
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<app-expandable-section [title]="'arcEdit.illustrationsLabel' | translate" icon="🖼️" [filled]="illustrationsFilled">
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">{{ 'arcEdit.illustrationsHint' | translate }}</small>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Thèmes et enjeux -->
|
||||
<app-expandable-section [title]="'arcEdit.themesStakesSectionTitle' | translate" icon="🎭" [filled]="themesStakesFilled">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="arc-edit-themes">{{ 'arcEdit.themesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-themes"
|
||||
formControlName="themes"
|
||||
[placeholder]="'arcEdit.themesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="arc-edit-stakes">{{ 'arcEdit.stakesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-stakes"
|
||||
formControlName="stakes"
|
||||
[placeholder]="'arcEdit.stakesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Notes du MJ (privé) -->
|
||||
<app-expandable-section [title]="'arcEdit.gmNotesSectionTitle' | translate" icon="🔒" variant="private" [filled]="gmNotesFilled">
|
||||
<div class="field">
|
||||
<label for="arc-edit-themes">{{ 'arcEdit.themesLabel' | translate }}</label>
|
||||
<label for="arc-edit-gm-notes">{{ 'arcEdit.gmNotesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-themes"
|
||||
formControlName="themes"
|
||||
[placeholder]="'arcEdit.themesPlaceholder' | translate"
|
||||
id="arc-edit-gm-notes"
|
||||
formControlName="gmNotes"
|
||||
[placeholder]="'arcEdit.gmNotesPlaceholder' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
<small class="field-hint">{{ 'arcEdit.gmNotesHint' | translate }}</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Récompenses et dénouement -->
|
||||
<app-expandable-section [title]="'arcEdit.rewardsResolutionSectionTitle' | translate" icon="🎁" [filled]="rewardsResolutionFilled">
|
||||
<div class="field">
|
||||
<label for="arc-edit-rewards">{{ 'arcEdit.rewardsLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-rewards"
|
||||
formControlName="rewards"
|
||||
[placeholder]="'arcEdit.rewardsPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="arc-edit-stakes">{{ 'arcEdit.stakesLabel' | translate }}</label>
|
||||
<label for="arc-edit-resolution">{{ 'arcEdit.resolutionLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-stakes"
|
||||
formControlName="stakes"
|
||||
[placeholder]="'arcEdit.stakesPlaceholder' | translate"
|
||||
id="arc-edit-resolution"
|
||||
formControlName="resolution"
|
||||
[placeholder]="'arcEdit.resolutionPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-edit-gm-notes">{{ 'arcEdit.gmNotesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-gm-notes"
|
||||
formControlName="gmNotes"
|
||||
[placeholder]="'arcEdit.gmNotesPlaceholder' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
<small class="field-hint">{{ 'arcEdit.gmNotesHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-edit-rewards">{{ 'arcEdit.rewardsLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-rewards"
|
||||
formControlName="rewards"
|
||||
[placeholder]="'arcEdit.rewardsPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-edit-resolution">{{ 'arcEdit.resolutionLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="arc-edit-resolution"
|
||||
formControlName="resolution"
|
||||
[placeholder]="'arcEdit.resolutionPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- ===== Pages Lore associées (phase B2 cross-context) ===== -->
|
||||
@if (loreId) {
|
||||
<div class="field">
|
||||
<label>{{ 'arcEdit.relatedPagesLabel' | translate }}</label>
|
||||
<app-expandable-section [title]="'arcEdit.relatedPagesLabel' | translate" icon="🔗" [filled]="loreFilled">
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="availablePages"
|
||||
@@ -145,7 +160,7 @@
|
||||
<small class="field-hint">
|
||||
{{ 'arcEdit.relatedPagesHint' | translate }}
|
||||
</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
}
|
||||
|
||||
@if (!loreId) {
|
||||
|
||||
@@ -61,6 +61,17 @@
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Bandeau « tout est optionnel sauf le titre » en tête du formulaire.
|
||||
.optional-hint {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: rgba(108, 99, 255, 0.08);
|
||||
border: 1px solid rgba(108, 99, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
color: #a5b4fc;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
// Override local : dans ce header, le bouton Supprimer est poussé à droite
|
||||
// via margin-left auto (les boutons Annuler/Sauvegarder restent groupés à gauche).
|
||||
// Styles de base fournis globalement par @app/styles/_buttons.scss (.btn-danger).
|
||||
|
||||
@@ -20,8 +20,11 @@ import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-l
|
||||
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 { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
import { EntityAssistPanelComponent } from '../../../shared/entity-assist-panel/entity-assist-panel.component';
|
||||
import { FieldProposal } from '../../../services/entity-assist.model';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Arc.
|
||||
@@ -34,7 +37,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-arc-edit',
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, TranslatePipe],
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, ExpandableSectionComponent, EntityAssistPanelComponent, TranslatePipe],
|
||||
templateUrl: './arc-edit.component.html',
|
||||
styleUrls: ['./arc-edit.component.scss']
|
||||
})
|
||||
@@ -56,6 +59,15 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||
|
||||
/** Applique au FORMULAIRE les champs étoffés retenus (Pilier A). Non destructif. */
|
||||
onAssistApplied(fields: FieldProposal[]): void {
|
||||
const patch: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
if (this.form.get(f.key)) patch[f.key] = f.proposedValue;
|
||||
}
|
||||
this.form.patchValue(patch);
|
||||
}
|
||||
|
||||
form: FormGroup;
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
@@ -71,6 +83,20 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
|
||||
illustrationImageIds: string[] = [];
|
||||
|
||||
// ─────────────── État « rempli » par section (pastille de l'en-tête) ───────────────
|
||||
// Seul le titre est requis ; ces getters signalent ce qui contient déjà du contenu.
|
||||
get illustrationsFilled(): boolean { return this.illustrationImageIds.length > 0; }
|
||||
get themesStakesFilled(): boolean {
|
||||
const v = this.form.value;
|
||||
return !!(v.themes || v.stakes);
|
||||
}
|
||||
get gmNotesFilled(): boolean { return !!this.form.value.gmNotes; }
|
||||
get rewardsResolutionFilled(): boolean {
|
||||
const v = this.form.value;
|
||||
return !!(v.rewards || v.resolution);
|
||||
}
|
||||
get loreFilled(): boolean { return this.relatedPageIds.length > 0; }
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
|
||||
@@ -29,52 +29,6 @@
|
||||
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
|
||||
</section>
|
||||
}
|
||||
<!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut.
|
||||
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
|
||||
@if (arc.type === 'HUB') {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> {{ 'arcView.hubQuestsTitle' | translate }}</h2>
|
||||
@if (hubQuests.length === 0) {
|
||||
<p class="view-section-empty">
|
||||
{{ 'arcView.hubQuestsEmpty' | translate }}
|
||||
</p>
|
||||
}
|
||||
@if (hubQuests.length > 0) {
|
||||
<div class="hub-quest-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
(cdkDropListDropped)="dropQuest($event)">
|
||||
@for (q of hubQuests; track q.id) {
|
||||
<button type="button"
|
||||
class="hub-quest-card"
|
||||
cdkDrag [cdkDragData]="q"
|
||||
(click)="openQuest(q)">
|
||||
<div class="hub-quest-card-head">
|
||||
@if (q.icon) {
|
||||
<span class="hub-quest-card-icon">
|
||||
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon>
|
||||
</span>
|
||||
}
|
||||
<span class="hub-quest-card-name">{{ q.name }}</span>
|
||||
</div>
|
||||
@if (q.description?.trim()) {
|
||||
<p class="hub-quest-card-desc">{{ q.description }}</p>
|
||||
}
|
||||
@if ((q.prerequisites?.length ?? 0) > 0) {
|
||||
<div class="hub-quest-card-locked-hint">
|
||||
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
|
||||
<span>{{ 'arcView.unlockConditions' | translate:{ n: q.prerequisites!.length } }}</span>
|
||||
<ul class="hub-quest-card-prereq-list">
|
||||
@for (p of q.prerequisites; track p) {
|
||||
<li>{{ describePrerequisite(p) }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📜</span> {{ 'arcView.synopsisTitle' | translate }}</h2>
|
||||
@if (arc.description?.trim()) {
|
||||
@@ -83,6 +37,35 @@
|
||||
<p class="view-section-empty">{{ 'arcView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<!-- Arc HUB : ses quêtes rattachées (entité Quête réelle, avec déblocage/progression). -->
|
||||
@if (arc.type === 'HUB') {
|
||||
<section class="view-section">
|
||||
<div class="hub-section-header">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🏳️</span> {{ 'arcView.questsTitle' | translate }}</h2>
|
||||
<button type="button" class="btn-primary" (click)="createQuest()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ 'arcView.createQuest' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (hubQuests.length > 0) {
|
||||
<div class="hub-quest-grid">
|
||||
@for (q of hubQuests; track q.id) {
|
||||
<a class="hub-quest-card" [routerLink]="['/campaigns', campaignId, 'quests', q.id]">
|
||||
<div class="hub-quest-card-head">
|
||||
@if (q.icon) {
|
||||
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="16" class="hub-quest-card-icon"></lucide-icon>
|
||||
}
|
||||
<span class="hub-quest-card-name">{{ q.name }}</span>
|
||||
</div>
|
||||
@if (q.description) { <p class="hub-quest-card-desc">{{ q.description }}</p> }
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="view-section-empty">{{ 'arcView.questsEmpty' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
<div class="view-row">
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">✨</span> {{ 'arcView.themesTitle' | translate }}</h2>
|
||||
|
||||
@@ -4,9 +4,8 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { DataSyncService } from '../../../services/data-sync.service';
|
||||
import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular';
|
||||
import { LucideAngularModule, Pencil, Trash2, Plus } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
@@ -16,7 +15,7 @@ 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, Chapter, Prerequisite } from '../../../services/campaign.model';
|
||||
import { Arc, Quest } 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';
|
||||
@@ -25,32 +24,28 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
/**
|
||||
* Écran de consultation d'un Arc narratif (lecture seule).
|
||||
* Route : /campaigns/:campaignId/arcs/:arcId
|
||||
* Bouton "Modifier" → /campaigns/:campaignId/arcs/:arcId/edit
|
||||
*
|
||||
* Niveau 1 — cutover : la grille « quêtes du hub » (chapitres-as-quêtes) a été
|
||||
* retirée. Les quêtes sont désormais une entité orthogonale, gérées dans la branche
|
||||
* « Quêtes » de la campagne. Les chapitres restent navigables via l'arbre de la sidebar.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-arc-view',
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe, CdkDropList, CdkDrag],
|
||||
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 Plus = Plus;
|
||||
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<string, Chapter> = {};
|
||||
/** Quêtes rattachées à cet arc (affichées si l'arc est un HUB). */
|
||||
hubQuests: Quest[] = [];
|
||||
|
||||
/** ID du Lore associé à la campagne (null si pas d'univers lié). */
|
||||
loreId: string | null = null;
|
||||
@@ -104,51 +99,18 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
this.arc = arc;
|
||||
this.loreId = loreId;
|
||||
this.availablePages = pages;
|
||||
// Les quêtes viennent déjà avec treeData (chargées par loadCampaignTreeData) — pas de fetch en plus.
|
||||
this.hubQuests = (treeData.quests ?? [])
|
||||
.filter(q => q.arcId === arc.id)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
this.pageTitleService.set(arc.name);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Réordonne les quêtes (chapitres) de l'arc par glisser-déposer. */
|
||||
dropQuest(event: CdkDragDrop<Chapter[]>): void {
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
moveItemInArray(this.hubQuests, event.previousIndex, event.currentIndex);
|
||||
this.dataSync.persist(
|
||||
this.campaignService.reorderChapters(this.arcId, this.hubQuests.map(q => q.id!)),
|
||||
() => this.load());
|
||||
}
|
||||
|
||||
openQuest(q: Chapter): void {
|
||||
if (!q.id) return;
|
||||
this.router.navigate([
|
||||
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', q.id
|
||||
]);
|
||||
/** Crée une quête rattachée à CET arc HUB (arcId pré-rempli). */
|
||||
createQuest(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'quests', 'create'], { queryParams: { arcId: this.arcId } });
|
||||
}
|
||||
|
||||
titleOfRelated(pageId: string): string {
|
||||
@@ -202,9 +164,6 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
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.
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ function data(partial: Partial<CampaignTreeData> = {}): CampaignTreeData {
|
||||
}
|
||||
|
||||
describe('buildCampaignTree', () => {
|
||||
it('produit les nœuds fixes (PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => {
|
||||
it('produit les nœuds fixes (quêtes, PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => {
|
||||
const tree = buildCampaignTree('c1', data(), t);
|
||||
expect(tree.map((n) => n.id)).toEqual([
|
||||
'npcs-root', 'enemies-root', 'random-tables-root',
|
||||
'quests-root', 'npcs-root', 'enemies-root', 'random-tables-root',
|
||||
'notebooks-root', 'item-catalogs-root', 'import-pdf-root',
|
||||
]);
|
||||
});
|
||||
@@ -34,16 +34,16 @@ describe('buildCampaignTree', () => {
|
||||
expect(arcLabels).toEqual(['Intro', 'Voyage', 'Final']);
|
||||
});
|
||||
|
||||
it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => {
|
||||
it('imbrique chapitres et scènes', () => {
|
||||
const tree = buildCampaignTree('camp', data({
|
||||
arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never,
|
||||
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap', prerequisites: [{}] }] } as never,
|
||||
// 2 chapitres pour éviter le mode plat (Niveau 0), qui masque arc + chapitre
|
||||
// quand il n'y a qu'un seul arc d'un seul chapitre.
|
||||
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }] } as never,
|
||||
scenesByChapter: { c1: [{ id: 's1', name: 'Scene' }] } as never,
|
||||
}), t);
|
||||
const arc = tree.find((n) => n.id === 'arc-a1')!;
|
||||
const chapter = arc.children![0];
|
||||
expect(chapter.id).toBe('chapter-c1');
|
||||
expect(chapter.meta).toBe('🔒'); // cadenas si prérequis
|
||||
const chapter = arc.children!.find((c) => c.id === 'chapter-c1')!;
|
||||
expect(chapter.children![0].id).toBe('scene-s1');
|
||||
});
|
||||
|
||||
@@ -73,13 +73,96 @@ describe('buildCampaignTree', () => {
|
||||
expect(folder.children![0].meta).toBe('Niv. 3');
|
||||
});
|
||||
|
||||
it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => {
|
||||
const hub = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never }), t)
|
||||
.find((n) => n.id === 'arc-a')!;
|
||||
expect(hub.createActions![0].label).toBe('campaignTree.newQuest');
|
||||
it('arc LINÉAIRE = chapitres ; arc HUB = quêtes (bouton « nouvelle quête » uniquement)', () => {
|
||||
const hub = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never,
|
||||
quests: [{ id: 'q1', name: 'Enquête', arcId: 'a' }, { id: 'q2', name: 'Transverse', arcId: null }] as never,
|
||||
}), t);
|
||||
const hubArc = hub.find((n) => n.id === 'arc-a')!;
|
||||
expect(hubArc.children!.some((c) => c.id === 'quest-q1')).toBe(true); // rattachée → enfant
|
||||
expect(hubArc.children!.some((c) => c.id === 'quest-q2')).toBe(false); // transverse → pas ici
|
||||
// HUB : on ne crée QUE des quêtes (pas de « nouveau chapitre »).
|
||||
expect(hubArc.createActions!.map((a) => a.label)).toEqual(['campaignTree.newQuest']);
|
||||
expect(hubArc.createActions![0].queryParams).toEqual({ arcId: 'a' });
|
||||
|
||||
const linear = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never }), t)
|
||||
.find((n) => n.id === 'arc-a')!;
|
||||
expect(linear.createActions![0].label).toBe('campaignTree.newChapter');
|
||||
// LINEAR : uniquement des chapitres ; une quête même arcId=arc n'apparaît PAS sous l'arc.
|
||||
const linear = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never,
|
||||
quests: [{ id: 'q3', name: 'X', arcId: 'a' }] as never,
|
||||
}), t);
|
||||
const linArc = linear.find((n) => n.id === 'arc-a')!;
|
||||
expect(linArc.children!.some((c) => c.id === 'quest-q3')).toBe(false);
|
||||
expect(linArc.createActions!.map((a) => a.label)).toEqual(['campaignTree.newChapter']);
|
||||
|
||||
// Le nœud « Quêtes » dédié conserve son action de création.
|
||||
expect(hub.find((n) => n.id === 'quests-root')!.createActions![0].label).toBe('campaignTree.newQuest');
|
||||
});
|
||||
|
||||
it('pastilles : un manque de QUÊTE n\'allume PAS une scène/PNJ portant le même id (séquences par table)', () => {
|
||||
// Chaque table a sa propre séquence IDENTITY : quête id 7 et scène id 7 coexistent.
|
||||
const gaps = [{
|
||||
entityType: 'QUEST', entityId: '7', entityName: 'Q', ruleId: 'QUEST-001-NO-NODES',
|
||||
message: 'm', severity: 'BLOCKING', arcId: null, chapterId: null,
|
||||
}];
|
||||
const tree = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never,
|
||||
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }] } as never,
|
||||
scenesByChapter: { c1: [{ id: '7', name: 'Scène homonyme' }] } as never,
|
||||
npcs: [{ id: '7', name: 'PNJ homonyme' }] as never,
|
||||
quests: [{ id: '7', name: 'Q', arcId: null }] as never,
|
||||
}), t, gaps as never);
|
||||
const arc = tree.find((n) => n.id === 'arc-a1')!;
|
||||
const scene = arc.children!.find((c) => c.id === 'chapter-c1')!.children!.find((s) => s.id === 'scene-7')!;
|
||||
expect(scene.statusDot).toBeUndefined(); // pas de faux positif
|
||||
expect(tree.find((n) => n.id === 'npcs-root')!.statusDot).toBeUndefined(); // idem PNJ
|
||||
expect(tree.find((n) => n.id === 'quests-root')!.statusDot).toBe('blocking'); // le vrai coupable
|
||||
});
|
||||
|
||||
it('arc HUB : une quête FUSIONNE avec son chapitre jumeau (un seul nœud, scènes dessous)', () => {
|
||||
// Héritage V10 : la quête référence son chapitre jumeau via un nœud CHAPTER.
|
||||
const tree = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never,
|
||||
chaptersByArc: { a: [{ id: 'c1', name: 'Jumeau' }, { id: 'c2', name: 'Chapitre libre' }] } as never,
|
||||
scenesByChapter: { c1: [{ id: 's1', name: 'Scène du jumeau' }] } as never,
|
||||
quests: [{ id: 'q1', name: 'La quête', arcId: 'a', nodes: [{ nodeType: 'CHAPTER', nodeId: 'c1', order: 0 }] }] as never,
|
||||
}), t);
|
||||
const hubArc = tree.find((n) => n.id === 'arc-a')!;
|
||||
// La quête absorbe le jumeau : pas de nœud chapter-c1, ses scènes vivent sous quest-q1.
|
||||
expect(hubArc.children!.some((c) => c.id === 'chapter-c1')).toBe(false);
|
||||
const questNode = hubArc.children!.find((c) => c.id === 'quest-q1')!;
|
||||
expect(questNode.children!.some((c) => c.id === 'scene-s1')).toBe(true);
|
||||
expect(questNode.dropParentId).toBe('c1'); // le jumeau reste le conteneur des scènes
|
||||
// Le chapitre NON consommé reste affiché tel quel (contenu mixte/historique).
|
||||
expect(hubArc.children!.some((c) => c.id === 'chapter-c2')).toBe(true);
|
||||
});
|
||||
|
||||
it("l'arc SYSTEM (« Quêtes libres ») est masqué de la narration", () => {
|
||||
const tree = buildCampaignTree('c', data({
|
||||
arcs: [
|
||||
{ id: 'a1', name: 'Arc visible', type: 'LINEAR' },
|
||||
{ id: 'sys', name: 'Quêtes libres', type: 'SYSTEM' },
|
||||
] as never,
|
||||
chaptersByArc: {
|
||||
a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }],
|
||||
sys: [{ id: 'cf', name: 'Conteneur' }],
|
||||
} as never,
|
||||
}), t);
|
||||
expect(tree.some((n) => n.id === 'arc-sys')).toBe(false); // pas dans l'arbre
|
||||
expect(tree.some((n) => n.id === 'arc-a1')).toBe(true);
|
||||
});
|
||||
|
||||
it('quête LIBRE : fusionnée sous « Quêtes » avec les scènes de son conteneur SYSTEM et « + scène »', () => {
|
||||
const tree = buildCampaignTree('c', data({
|
||||
arcs: [{ id: 'sys', name: 'Quêtes libres', type: 'SYSTEM' }] as never,
|
||||
chaptersByArc: { sys: [{ id: 'cf', name: 'Libre', arcId: 'sys' }] } as never,
|
||||
scenesByChapter: { cf: [{ id: 's1', name: 'Scène à la volée' }] } as never,
|
||||
quests: [{ id: 'q1', name: 'Libre', arcId: null, nodes: [{ nodeType: 'CHAPTER', nodeId: 'cf', order: 0 }] }] as never,
|
||||
}), t);
|
||||
const questsRoot = tree.find((n) => n.id === 'quests-root')!;
|
||||
const questNode = questsRoot.children!.find((c) => c.id === 'quest-q1')!;
|
||||
expect(questNode.children!.some((c) => c.id === 'scene-s1')).toBe(true);
|
||||
expect(questNode.dropParentId).toBe('cf'); // le conteneur reçoit les drops de scènes
|
||||
// « + scène » pointe vers la création de scène DANS le conteneur (arc SYSTEM).
|
||||
expect(questNode.createActions![0].route).toBe('/campaigns/c/arcs/sys/chapters/cf/scenes/create');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { switchMap, map } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { NpcService } from '../services/npc.service';
|
||||
import { RandomTableService } from '../services/random-table.service';
|
||||
import { EnemyService } from '../services/enemy.service';
|
||||
import { TreeItem, SecondarySidebarConfig, GlobalItem, ReorderKind } from '../services/layout.service';
|
||||
import { TreeItem, TreeCreateAction, SecondarySidebarConfig, GlobalItem, ReorderKind } from '../services/layout.service';
|
||||
import { groupByFolder, byOrder } from '../shared/folder-grouping.util';
|
||||
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
|
||||
import { Arc, Chapter, Scene, Campaign, Quest } from '../services/campaign.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';
|
||||
import { CampaignReadinessAssessment, ReadinessGap, ReadinessSeverity, ReadinessEntityType } from '../services/readiness.model';
|
||||
|
||||
/**
|
||||
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
|
||||
@@ -28,71 +28,93 @@ export interface CampaignTreeData {
|
||||
npcs: Npc[];
|
||||
randomTables: RandomTable[];
|
||||
enemies: Enemy[];
|
||||
/** Manques de readiness (Pilier B) — alimentent les pastilles de l'arbre. Optionnel. */
|
||||
gaps?: ReadinessGap[];
|
||||
/** Quêtes de la campagne — pour afficher celles rattachées à un arc HUB. Optionnel. */
|
||||
quests?: Quest[];
|
||||
/** Bilan de readiness complet — évite un fetch séparé pour le panneau du hub. */
|
||||
readiness?: CampaignReadinessAssessment | null;
|
||||
}
|
||||
|
||||
export function loadCampaignTreeData(
|
||||
service: CampaignService,
|
||||
campaignId: string,
|
||||
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
|
||||
// Paramètres CONSERVÉS pour ne pas toucher les ~15 appelants, mais devenus inutiles :
|
||||
// l'arbre complet (structure + PNJ + tables + ennemis + quêtes + readiness) arrive
|
||||
// désormais en UNE requête via GET /api/campaigns/{id}/tree — la rafale de 15-20
|
||||
// appels HTTP par navigation était ce qui rendait la sidebar lente.
|
||||
_npcService?: NpcService,
|
||||
_randomTableService?: RandomTableService,
|
||||
_enemyService?: EnemyService
|
||||
): Observable<CampaignTreeData> {
|
||||
// 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),
|
||||
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, npcs, randomTables, enemies }) => {
|
||||
if (arcs.length === 0) {
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, npcs, randomTables, enemies });
|
||||
}
|
||||
const chapterCalls = arcs.map(a =>
|
||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||
);
|
||||
return forkJoin(chapterCalls).pipe(
|
||||
switchMap(chapterResults => {
|
||||
const chaptersByArc: Record<string, Chapter[]> = {};
|
||||
const allChapters: Chapter[] = [];
|
||||
chapterResults.forEach(r => {
|
||||
chaptersByArc[r.arcId] = r.chapters;
|
||||
allChapters.push(...r.chapters);
|
||||
});
|
||||
|
||||
if (allChapters.length === 0) {
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, npcs, randomTables, enemies });
|
||||
}
|
||||
const sceneCalls = allChapters.map(c =>
|
||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||
);
|
||||
return forkJoin(sceneCalls).pipe(
|
||||
map(sceneResults => {
|
||||
const scenesByChapter: Record<string, Scene[]> = {};
|
||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
||||
return { arcs, chaptersByArc, scenesByChapter, npcs, randomTables, enemies };
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
return service.getTree(campaignId).pipe(
|
||||
map(tree => ({
|
||||
arcs: tree.arcs,
|
||||
chaptersByArc: tree.chaptersByArc ?? {},
|
||||
scenesByChapter: tree.scenesByChapter ?? {},
|
||||
npcs: tree.npcs ?? [],
|
||||
randomTables: tree.randomTables ?? [],
|
||||
enemies: tree.enemies ?? [],
|
||||
quests: tree.quests ?? [],
|
||||
gaps: tree.readiness?.gaps ?? [],
|
||||
readiness: tree.readiness ?? null
|
||||
} as CampaignTreeData)),
|
||||
// Dégradation gracieuse : sidebar vide plutôt qu'écran cassé si le back est indisponible.
|
||||
catchError(() => of({
|
||||
arcs: [], chaptersByArc: {}, scenesByChapter: {},
|
||||
npcs: [], randomTables: [], enemies: [], quests: [], gaps: [], readiness: null
|
||||
} as CampaignTreeData))
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCampaignTree(campaignId: string, data: CampaignTreeData, translate: TranslateService): TreeItem[] {
|
||||
export function buildCampaignTree(
|
||||
campaignId: string,
|
||||
data: CampaignTreeData,
|
||||
translate: TranslateService,
|
||||
readinessGaps: ReadinessGap[] = []
|
||||
): TreeItem[] {
|
||||
// Tri par ORDRE manuel (réagencé par glisser-déposer, persisté en base ; `byOrder`
|
||||
// et `groupByFolder` sont mutualisés avec les vues cartes). Repli sur 0 sinon.
|
||||
|
||||
// --- Readiness (Pilier B — guidage) : pastille par nœud = pire sévérité de
|
||||
// l'entité ET de ses descendants, avec au survol LE(S) message(s) réel(s) du manque
|
||||
// (fini le tooltip générique). Purement dérivé des gaps (aucune persistance).
|
||||
// Clé TYPE|ID obligatoire : chaque table a sa propre séquence IDENTITY (une quête
|
||||
// id 6 et une scène id 6 coexistent) — sans le type, un manque de quête allumerait
|
||||
// à tort la pastille d'une scène/PNJ portant le même numéro.
|
||||
const sevRank = (s: ReadinessSeverity): number => (s === 'BLOCKING' ? 2 : s === 'RECOMMENDED' ? 1 : 0);
|
||||
const gapsByKey = new Map<string, ReadinessGap[]>();
|
||||
for (const g of readinessGaps) {
|
||||
const key = `${g.entityType}|${g.entityId}`;
|
||||
const list = gapsByKey.get(key) ?? [];
|
||||
list.push(g);
|
||||
gapsByKey.set(key, list);
|
||||
}
|
||||
const gapsOf = (type: ReadinessEntityType, id?: string | null): ReadinessGap[] =>
|
||||
(id ? (gapsByKey.get(`${type}|${id}`) ?? []) : []);
|
||||
const quests: Quest[] = data.quests ?? [];
|
||||
const questsByArc = (arcId: string): Quest[] => quests.filter(q => q.arcId === arcId);
|
||||
const chapterGaps = (ch: Chapter): ReadinessGap[] => [
|
||||
...gapsOf('CHAPTER', ch.id),
|
||||
...(data.scenesByChapter[ch.id!] ?? []).flatMap(s => gapsOf('SCENE', s.id))
|
||||
];
|
||||
const arcGaps = (arc: Arc): ReadinessGap[] => [
|
||||
...gapsOf('ARC', arc.id),
|
||||
...(data.chaptersByArc[arc.id!] ?? []).flatMap(chapterGaps),
|
||||
...(arc.type === 'HUB' ? questsByArc(arc.id!).flatMap(q => gapsOf('QUEST', q.id)) : [])
|
||||
];
|
||||
/** Pastille + tooltip : les pires manques en clair (2 max, puis « + N autres »). */
|
||||
const dotProps = (gaps: ReadinessGap[]): Pick<TreeItem, 'statusDot' | 'statusDotTitle'> => {
|
||||
const worst = Math.max(0, ...gaps.map(g => sevRank(g.severity)));
|
||||
if (worst < 1) return {};
|
||||
const sorted = [...gaps].sort((a, b) => sevRank(b.severity) - sevRank(a.severity));
|
||||
const shown = sorted.slice(0, 2).map(g => g.message);
|
||||
if (sorted.length > shown.length) {
|
||||
shown.push(translate.instant('readiness.dot.more', { n: sorted.length - shown.length }));
|
||||
}
|
||||
return { statusDot: worst >= 2 ? 'blocking' : 'recommended', statusDotTitle: shown.join('\n') };
|
||||
};
|
||||
|
||||
/**
|
||||
* Nœuds-dossiers d'une collection ordonnable : un nœud dépliable par dossier
|
||||
* (+ un pseudo-dossier « Sans dossier »), via le regroupement partagé. Tous les
|
||||
@@ -125,12 +147,14 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
});
|
||||
|
||||
const npcChildren = folderChildren(sortedNpcs, 'npc', npcItem, 'npc');
|
||||
const npcGaps = sortedNpcs.flatMap(n => gapsOf('NPC', n.id));
|
||||
|
||||
const npcsNode: TreeItem = {
|
||||
id: 'npcs-root',
|
||||
label: translate.instant('campaignTree.npcs'),
|
||||
iconKey: 'c-drama',
|
||||
children: npcChildren,
|
||||
...dotProps(npcGaps),
|
||||
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.
|
||||
@@ -149,6 +173,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
// --- 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(byOrder);
|
||||
const enemyGaps = sortedEnemies.flatMap(e => gapsOf('ENEMY', e.id));
|
||||
const enemyItem = (e: Enemy): TreeItem => ({
|
||||
id: `enemy-${e.id}`,
|
||||
label: e.name,
|
||||
@@ -158,54 +183,105 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
});
|
||||
const enemyChildren = folderChildren(sortedEnemies, 'enemy', enemyItem, 'enemy');
|
||||
|
||||
const sortedArcs = [...data.arcs].sort(byOrder);
|
||||
// L'arc SYSTEM (« Quêtes libres ») est de la plomberie : ses chapitres sont les
|
||||
// conteneurs des quêtes libres, affichés sous le nœud « Quêtes » — jamais en narration.
|
||||
const sortedArcs = [...data.arcs].sort(byOrder).filter(a => a.type !== 'SYSTEM');
|
||||
|
||||
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
||||
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byOrder);
|
||||
const isHub = arc.type === 'HUB';
|
||||
|
||||
const chapterItems: TreeItem[] = sortedChapters.map(ch => {
|
||||
const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder);
|
||||
|
||||
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
|
||||
const sceneItemsOf = (ch: Chapter): TreeItem[] =>
|
||||
[...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder).map(sc => ({
|
||||
id: `scene-${sc.id}`,
|
||||
label: sc.name,
|
||||
iconKey: sc.icon ?? undefined,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`,
|
||||
dragKind: 'scene', dragId: sc.id
|
||||
dragKind: 'scene' as ReorderKind, dragId: sc.id,
|
||||
...dotProps(gapsOf('SCENE', sc.id))
|
||||
}));
|
||||
return {
|
||||
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,
|
||||
dragKind: 'chapter', dragId: ch.id, dropKinds: ['scene'], dropParentId: ch.id,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
|
||||
createActions: [{
|
||||
id: `new-scene-${ch.id}`,
|
||||
label: translate.instant('campaignTree.newScene'),
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`,
|
||||
|
||||
const newSceneAction = (ch: Chapter) => ({
|
||||
id: `new-scene-${ch.id}`,
|
||||
label: translate.instant('campaignTree.newScene'),
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`,
|
||||
actionIcon: 'plus' as const
|
||||
});
|
||||
|
||||
const chapterNode = (ch: Chapter): TreeItem => ({
|
||||
id: `chapter-${ch.id}`,
|
||||
label: ch.name,
|
||||
iconKey: ch.icon ?? undefined,
|
||||
children: sceneItemsOf(ch),
|
||||
dragKind: 'chapter', dragId: ch.id, dropKinds: ['scene'], dropParentId: ch.id,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
|
||||
...dotProps(chapterGaps(ch)),
|
||||
createActions: [newSceneAction(ch)]
|
||||
});
|
||||
|
||||
let children: TreeItem[];
|
||||
if (isHub) {
|
||||
// Arc HUB = quêtes. Une quête FUSIONNE avec son chapitre jumeau (héritage V10 :
|
||||
// même contenu, la quête référence le chapitre en nœud) : UN SEUL nœud d'arbre —
|
||||
// la quête — portant les scènes du jumeau. Évite les doublons quête/chapitre qui
|
||||
// rendaient l'arbre illisible sur les campagnes migrées.
|
||||
const attached = [...questsByArc(arc.id!)].sort(byOrder);
|
||||
const consumed = new Set<string>();
|
||||
const questNodes: TreeItem[] = attached.map(q => {
|
||||
const twinIds = new Set((q.nodes ?? [])
|
||||
.filter(n => n.nodeType === 'CHAPTER').map(n => n.nodeId));
|
||||
const twins = sortedChapters.filter(ch => twinIds.has(ch.id!));
|
||||
twins.forEach(ch => consumed.add(ch.id!));
|
||||
const questNodeGaps = [...gapsOf('QUEST', q.id), ...twins.flatMap(chapterGaps)];
|
||||
const container = twins[0];
|
||||
return {
|
||||
id: `quest-${q.id}`,
|
||||
label: q.name,
|
||||
iconKey: q.icon ?? 'flag',
|
||||
route: `/campaigns/${campaignId}/quests/${q.id}`,
|
||||
...dotProps(questNodeGaps),
|
||||
children: twins.flatMap(sceneItemsOf),
|
||||
// Le premier jumeau reste le conteneur des scènes (ajout + réordonnancement).
|
||||
...(container ? {
|
||||
dropKinds: ['scene'] as ReorderKind[],
|
||||
dropParentId: container.id,
|
||||
createActions: [newSceneAction(container)]
|
||||
} : {})
|
||||
};
|
||||
});
|
||||
// Chapitres non « consommés » par une quête (contenu mixte / historique) : affichés tels quels.
|
||||
const plainChapters = sortedChapters.filter(ch => !consumed.has(ch.id!)).map(chapterNode);
|
||||
children = [...questNodes, ...plainChapters];
|
||||
} else {
|
||||
children = sortedChapters.map(chapterNode);
|
||||
}
|
||||
|
||||
// HUB → on ne crée QUE des quêtes ; LINÉAIRE → que des chapitres.
|
||||
const arcCreateActions: TreeCreateAction[] = isHub
|
||||
? [{
|
||||
id: `new-quest-${arc.id}`,
|
||||
label: translate.instant('campaignTree.newQuest'),
|
||||
route: `/campaigns/${campaignId}/quests/create`,
|
||||
queryParams: { arcId: arc.id! },
|
||||
actionIcon: 'plus'
|
||||
}]
|
||||
};
|
||||
});
|
||||
: [{
|
||||
id: `new-chapter-${arc.id}`,
|
||||
label: translate.instant('campaignTree.newChapter'),
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`,
|
||||
actionIcon: 'plus'
|
||||
}];
|
||||
|
||||
return {
|
||||
id: `arc-${arc.id}`,
|
||||
label: arc.name,
|
||||
iconKey: arc.icon ?? undefined,
|
||||
children: chapterItems,
|
||||
children,
|
||||
dragKind: 'arc', dragId: arc.id, dropKinds: ['chapter'], dropParentId: arc.id,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
|
||||
...dotProps(arcGaps(arc)),
|
||||
sectionHeaderBefore: idx === 0 ? translate.instant('campaignTree.sectionNarration') : undefined,
|
||||
|
||||
createActions: [{
|
||||
id: `new-chapter-${arc.id}`,
|
||||
// Dans un arc hub, un "chapitre" est présenté comme une "quête".
|
||||
label: arc.type === 'HUB' ? translate.instant('campaignTree.newQuest') : translate.instant('campaignTree.newChapter'),
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`,
|
||||
actionIcon: 'plus'
|
||||
}]
|
||||
createActions: arcCreateActions
|
||||
};
|
||||
});
|
||||
|
||||
@@ -258,6 +334,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
label: translate.instant('campaignTree.enemies'),
|
||||
iconKey: 'skull',
|
||||
children: enemyChildren,
|
||||
...dotProps(enemyGaps),
|
||||
meta: sortedEnemies.length ? String(sortedEnemies.length) : undefined,
|
||||
route: `/campaigns/${campaignId}/enemies`,
|
||||
createActions: [{
|
||||
@@ -276,7 +353,110 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
route: `/campaigns/${campaignId}/import`
|
||||
};
|
||||
|
||||
return [...arcNodes, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode];
|
||||
// Mode "plat" (Niveau 0) : une campagne avec un seul arc d'un seul chapitre n'a
|
||||
// pas besoin de la hiérarchie arc > chapitre. On masque ces deux niveaux et on
|
||||
// présente directement les scènes sous un nœud neutre "Scènes". Le drag & drop
|
||||
// des scènes et l'ajout restent identiques (c'est le nœud chapitre, relabellisé
|
||||
// et remonté à la racine). Dès qu'un 2e arc OU un 2e chapitre existe, la
|
||||
// structure complète réapparaît automatiquement.
|
||||
const onlyArc = sortedArcs.length === 1 ? sortedArcs[0] : null;
|
||||
const onlyArcChapters = onlyArc ? (data.chaptersByArc[onlyArc.id!] ?? []) : [];
|
||||
let narrationNodes: TreeItem[] = arcNodes;
|
||||
// Le mode plat ne s'applique PAS à un arc HUB (qui présente des quêtes, pas juste des scènes).
|
||||
if (onlyArc && onlyArc.type !== 'HUB' && onlyArcChapters.length === 1) {
|
||||
const ch = onlyArcChapters[0];
|
||||
const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder);
|
||||
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
|
||||
id: `scene-${sc.id}`,
|
||||
label: sc.name,
|
||||
iconKey: sc.icon ?? undefined,
|
||||
route: `/campaigns/${campaignId}/arcs/${onlyArc.id}/chapters/${ch.id}/scenes/${sc.id}`,
|
||||
dragKind: 'scene', dragId: sc.id,
|
||||
...dotProps(gapsOf('SCENE', sc.id))
|
||||
}));
|
||||
narrationNodes = [{
|
||||
id: `chapter-${ch.id}`,
|
||||
label: translate.instant('campaignTree.scenesGroup'),
|
||||
iconKey: 'book-open',
|
||||
children: sceneItems,
|
||||
...dotProps(chapterGaps(ch)),
|
||||
// Réordonnancement des scènes inchangé : ce nœud EST le chapitre (caché).
|
||||
dropKinds: ['scene'], dropParentId: ch.id,
|
||||
// En mode plat le chapitre est masqué : le libellé « Scènes » ouvre la CARTE des
|
||||
// scènes (graphe) — cohérent avec le label, et garde le graphe accessible — plutôt
|
||||
// que la vue du chapitre caché (titrée « Chapitre 1 », sans liste de scènes : déroutant).
|
||||
// Le chevron, lui, déplie la liste des scènes ci-dessous.
|
||||
route: `/campaigns/${campaignId}/arcs/${onlyArc.id}/chapters/${ch.id}/graph`,
|
||||
sectionHeaderBefore: translate.instant('campaignTree.sectionNarration'),
|
||||
createActions: [{
|
||||
id: `new-scene-${ch.id}`,
|
||||
label: translate.instant('campaignTree.newScene'),
|
||||
route: `/campaigns/${campaignId}/arcs/${onlyArc.id}/chapters/${ch.id}/scenes/create`,
|
||||
actionIcon: 'plus'
|
||||
}]
|
||||
}];
|
||||
}
|
||||
|
||||
// Quêtes LIBRES (hors arc) : même fusion quête/conteneur que dans les hubs — le
|
||||
// conteneur vit dans l'arc SYSTEM (chargé avec l'arbre), la quête s'affiche avec
|
||||
// ses scènes dessous et son « + scène ». Recherche des conteneurs TOUS arcs confondus.
|
||||
const chapterById = new Map<string, Chapter>(
|
||||
Object.values(data.chaptersByArc).flat().map(ch => [ch.id!, ch]));
|
||||
const questsFolderGaps: ReadinessGap[] = [];
|
||||
const freeQuestNodes: TreeItem[] = quests.filter(q => !q.arcId).sort(byOrder).map(q => {
|
||||
const twins = (q.nodes ?? [])
|
||||
.filter(n => n.nodeType === 'CHAPTER')
|
||||
.map(n => chapterById.get(n.nodeId))
|
||||
.filter((c): c is Chapter => !!c);
|
||||
const questGaps = [...gapsOf('QUEST', q.id), ...twins.flatMap(chapterGaps)];
|
||||
questsFolderGaps.push(...questGaps);
|
||||
const container = twins[0];
|
||||
const sceneItems: TreeItem[] = twins.flatMap(ch =>
|
||||
[...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder).map(sc => ({
|
||||
id: `scene-${sc.id}`,
|
||||
label: sc.name,
|
||||
iconKey: sc.icon ?? undefined,
|
||||
route: `/campaigns/${campaignId}/arcs/${ch.arcId}/chapters/${ch.id}/scenes/${sc.id}`,
|
||||
dragKind: 'scene' as ReorderKind, dragId: sc.id,
|
||||
...dotProps(gapsOf('SCENE', sc.id))
|
||||
})));
|
||||
return {
|
||||
id: `quest-${q.id}`,
|
||||
label: q.name,
|
||||
iconKey: q.icon ?? 'flag',
|
||||
route: `/campaigns/${campaignId}/quests/${q.id}`,
|
||||
...dotProps(questGaps),
|
||||
children: sceneItems,
|
||||
...(container ? {
|
||||
dropKinds: ['scene'] as ReorderKind[],
|
||||
dropParentId: container.id,
|
||||
createActions: [{
|
||||
id: `new-scene-${container.id}`,
|
||||
label: translate.instant('campaignTree.newScene'),
|
||||
route: `/campaigns/${campaignId}/arcs/${container.arcId}/chapters/${container.id}/scenes/create`,
|
||||
actionIcon: 'plus' as const
|
||||
}]
|
||||
} : {})
|
||||
} as TreeItem;
|
||||
});
|
||||
|
||||
const questsNode: TreeItem = {
|
||||
id: 'quests-root',
|
||||
label: translate.instant('campaignTree.quests'),
|
||||
iconKey: 'flag',
|
||||
route: `/campaigns/${campaignId}/quests`,
|
||||
children: freeQuestNodes,
|
||||
meta: freeQuestNodes.length ? String(freeQuestNodes.length) : undefined,
|
||||
...dotProps(questsFolderGaps),
|
||||
createActions: [{
|
||||
id: 'new-quest-node',
|
||||
label: translate.instant('campaignTree.newQuest'),
|
||||
route: `/campaigns/${campaignId}/quests/create`,
|
||||
actionIcon: 'plus'
|
||||
}]
|
||||
};
|
||||
|
||||
return [...narrationNodes, questsNode, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,7 +474,8 @@ export function buildCampaignSidebarConfig(
|
||||
allCampaigns: Campaign[],
|
||||
treeData: CampaignTreeData,
|
||||
campaignId: string,
|
||||
translate: TranslateService
|
||||
translate: TranslateService,
|
||||
readinessGaps: ReadinessGap[] = []
|
||||
): SecondarySidebarConfig {
|
||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
@@ -303,7 +484,9 @@ export function buildCampaignSidebarConfig(
|
||||
title: campaign.name,
|
||||
// Titre cliquable → accueil de la campagne (raccourci depuis n'importe quelle sous-page).
|
||||
titleRoute: `/campaigns/${campaignId}`,
|
||||
items: buildCampaignTree(campaignId, treeData, translate),
|
||||
// Priorité aux gaps passés explicitement (hub) ; sinon ceux embarqués dans treeData
|
||||
// (chargés par loadCampaignTreeData) → pastilles présentes sur TOUTES les pages.
|
||||
items: buildCampaignTree(campaignId, treeData, translate, readinessGaps.length ? readinessGaps : (treeData.gaps ?? [])),
|
||||
footerLabel: translate.instant('campaignTree.allCampaigns'),
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: translate.instant('campaignTree.newArc'), variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- ============ Guidage co-MJ : « Prochaines étapes » (Pilier B) ============ -->
|
||||
@if (!editing && campaign.id) {
|
||||
<app-readiness-panel [campaignId]="campaign.id" [assessment]="readiness"></app-readiness-panel>
|
||||
}
|
||||
<!-- ============ Header : mode édition inline ============ -->
|
||||
@if (editing) {
|
||||
<div class="detail-header edit-mode">
|
||||
@@ -187,13 +191,19 @@
|
||||
<div class="section-header">
|
||||
<h2>{{ 'campaignDetail.arcsTitle' | translate }}</h2>
|
||||
<div class="section-header-actions">
|
||||
@if (showQuickAddScene) {
|
||||
<button class="btn-add" (click)="quickAddScene()" [disabled]="quickSceneInFlight">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ 'campaignDetail.quickScene' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button class="btn-add" (click)="createArc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ 'campaignDetail.newArc' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (arcs.length > 0) {
|
||||
@if (arcs.length > 0 && !showQuickAddScene) {
|
||||
<div class="arcs-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
(cdkDropListDropped)="dropArc($event)">
|
||||
@for (arc of arcs; track arc.id) {
|
||||
@@ -205,13 +215,23 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Mode plat (un seul arc d'un seul chapitre) : on masque la carte de
|
||||
l'arc par défaut, comme dans la sidebar. Les scènes vivent dans le volet. -->
|
||||
@if (arcs.length > 0 && showQuickAddScene) {
|
||||
<div class="empty-state empty-state--compact">
|
||||
<p>{{ 'campaignDetail.flatModeHint' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (arcs.length === 0) {
|
||||
<div class="empty-state">
|
||||
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<p>{{ 'campaignDetail.noArc' | translate }}</p>
|
||||
<button class="btn-add-first" (click)="createArc()">
|
||||
<button class="btn-add-first" (click)="quickAddScene()" [disabled]="quickSceneInFlight">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ 'campaignDetail.createFirstArc' | translate }}
|
||||
{{ 'campaignDetail.quickSceneFirst' | translate }}
|
||||
</button>
|
||||
<button type="button" class="empty-alt-action" (click)="createArc()">
|
||||
{{ 'campaignDetail.organizeInArcs' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -277,3 +297,10 @@
|
||||
(chapter-edit > « Conditions de déblocage »). -->
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Choix du périmètre de l'export Foundry (tout / cartes+ennemis / journaux / tables). -->
|
||||
<app-foundry-export-dialog
|
||||
[open]="foundryDialogOpen"
|
||||
[exporting]="exportingFoundry"
|
||||
(cancelled)="foundryDialogOpen = false"
|
||||
(confirmed)="onFoundryExportConfirmed($event)"></app-foundry-export-dialog>
|
||||
|
||||
@@ -486,6 +486,19 @@
|
||||
&:hover { background: #5b52e0; }
|
||||
}
|
||||
|
||||
// Action secondaire de l'état vide (« ou créer un arc ») — bouton texte discret.
|
||||
.empty-alt-action {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #8a7bc8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
// ─────────────── Sessions de jeu (Play Context) ───────────────
|
||||
.sessions-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -9,9 +9,9 @@ import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { forkJoin, of, Observable } from 'rxjs';
|
||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CampaignService, FoundryExportOptions } from '../../../services/campaign.service';
|
||||
import { LoreService } from '../../../services/lore.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { GameSystem } from '../../../services/game-system.model';
|
||||
@@ -30,10 +30,13 @@ import { Lore } from '../../../services/lore.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
import { FolderGroup, groupByFolder, byOrder } from '../../../shared/folder-grouping.util';
|
||||
import { CampaignReadinessAssessment } from '../../../services/readiness.model';
|
||||
import { ReadinessPanelComponent } from '../../../shared/readiness-panel/readiness-panel.component';
|
||||
import { FoundryExportDialogComponent } from '../foundry-export-dialog/foundry-export-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-detail',
|
||||
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag],
|
||||
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag, ReadinessPanelComponent, FoundryExportDialogComponent],
|
||||
templateUrl: './campaign-detail.component.html',
|
||||
styleUrls: ['./campaign-detail.component.scss']
|
||||
})
|
||||
@@ -57,6 +60,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Export Foundry en cours (anti double-clic). */
|
||||
exportingFoundry = false;
|
||||
/** Modale de périmètre de l'export Foundry. */
|
||||
foundryDialogOpen = false;
|
||||
/** Export PDF en cours (anti double-clic). */
|
||||
exportingPdf = false;
|
||||
|
||||
@@ -100,6 +105,9 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
/** Parties (Playthroughs) de cette campagne. */
|
||||
playthroughs: Playthrough[] = [];
|
||||
|
||||
/** Bilan de préparation (Pilier B — guidage) : alimente le panneau + les pastilles. */
|
||||
readiness: CampaignReadinessAssessment | null = null;
|
||||
|
||||
/** Mode édition inline. */
|
||||
editing = false;
|
||||
editName = '';
|
||||
@@ -139,7 +147,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
// on recharge les cartes pour rester cohérent sans rafraîchir la page.
|
||||
this.dataSync.onChange(this.destroyRef, () => {
|
||||
if (this.campaign?.id) {
|
||||
this.campaignService.getArcs(this.campaign.id).subscribe(a => this.arcs = [...a].sort(byOrder));
|
||||
this.campaignService.getArcs(this.campaign.id).subscribe(a =>
|
||||
this.arcs = [...a].sort(byOrder).filter(x => x.type !== 'SYSTEM'));
|
||||
this.loadNpcs(this.campaign.id); // PNJ regroupés/ordonnés
|
||||
this.campaignSidebar.show(this.campaign.id); // recharge l'arbre aussi
|
||||
}
|
||||
@@ -193,11 +202,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.playthroughs = playthroughs;
|
||||
// Le bilan complet arrive avec l'arbre agrégé — plus de fetch séparé.
|
||||
this.readiness = treeData.readiness ?? null;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.loadSessions(campaign.id!);
|
||||
this.arcs = [...treeData.arcs].sort(byOrder);
|
||||
// L'arc SYSTEM (« Quêtes libres ») est de la plomberie : pas de carte d'arc pour lui.
|
||||
this.arcs = [...treeData.arcs].sort(byOrder).filter(a => a.type !== 'SYSTEM');
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
this.pageTitleService.set(campaign.name);
|
||||
@@ -315,11 +327,71 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
|
||||
}
|
||||
|
||||
/** Télécharge le bundle Foundry de la campagne via un lien temporaire. */
|
||||
// ─────────────── Ajout rapide d'une scène (conteneur par défaut) ───────────────
|
||||
// Niveau 0 : on ne force plus à créer un arc puis un chapitre avant de poser une
|
||||
// scène. Si la campagne n'a pas encore de structure, on provisionne un
|
||||
// « Arc principal / Chapitre 1 » implicite (masqué dans l'arbre tant qu'il est
|
||||
// seul), puis on ouvre la création de scène. Visible seulement quand la
|
||||
// structure est simple (vide, ou un seul arc d'au plus un chapitre).
|
||||
quickSceneInFlight = false;
|
||||
|
||||
get showQuickAddScene(): boolean {
|
||||
if (this.arcs.length === 0) return true;
|
||||
if (this.arcs.length === 1) return (this.chapterCountByArc[this.arcs[0].id!] ?? 0) <= 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
quickAddScene(): void {
|
||||
if (!this.campaign || this.quickSceneInFlight) return;
|
||||
this.quickSceneInFlight = true;
|
||||
const campaignId = this.campaign.id!;
|
||||
this.ensureDefaultChapter(campaignId).subscribe({
|
||||
next: ({ arcId, chapterId }) => {
|
||||
this.quickSceneInFlight = false;
|
||||
this.router.navigate(['/campaigns', campaignId, 'arcs', arcId, 'chapters', chapterId, 'scenes', 'create']);
|
||||
},
|
||||
error: () => {
|
||||
this.quickSceneInFlight = false;
|
||||
console.error('Erreur lors de la préparation de la scène rapide');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantit l'existence d'un chapitre cible pour une scène : réutilise le premier
|
||||
* arc / chapitre s'ils existent, sinon crée « Arc principal » puis « Chapitre 1 ».
|
||||
*/
|
||||
private ensureDefaultChapter(campaignId: string): Observable<{ arcId: string; chapterId: string }> {
|
||||
const newChapter = (arcId: string) => this.campaignService.createChapter({
|
||||
name: this.translate.instant('campaignDetail.defaultChapterName'),
|
||||
description: '', arcId, order: 1, icon: null
|
||||
}).pipe(map(ch => ({ arcId, chapterId: ch.id! })));
|
||||
|
||||
const firstArc = this.arcs[0];
|
||||
if (!firstArc) {
|
||||
return this.campaignService.createArc({
|
||||
name: this.translate.instant('campaignDetail.defaultArcName'),
|
||||
description: '', campaignId, order: 1, type: 'LINEAR', icon: null
|
||||
}).pipe(switchMap(arc => newChapter(arc.id!)));
|
||||
}
|
||||
return this.campaignService.getChapters(firstArc.id!).pipe(
|
||||
switchMap(chs => chs.length
|
||||
? of({ arcId: firstArc.id!, chapterId: chs[0].id! })
|
||||
: newChapter(firstArc.id!))
|
||||
);
|
||||
}
|
||||
|
||||
/** Ouvre la modale de choix du périmètre de l'export Foundry. */
|
||||
exportFoundry(): void {
|
||||
if (!this.campaign?.id || this.exportingFoundry) return;
|
||||
this.foundryDialogOpen = true;
|
||||
}
|
||||
|
||||
/** Télécharge le bundle Foundry (périmètre choisi) via un lien temporaire. */
|
||||
onFoundryExportConfirmed(opts: FoundryExportOptions): void {
|
||||
if (!this.campaign?.id || this.exportingFoundry) return;
|
||||
this.exportingFoundry = true;
|
||||
this.campaignService.exportFoundry(this.campaign.id).subscribe({
|
||||
this.campaignService.exportFoundry(this.campaign.id, opts).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -333,11 +405,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
// Révocation différée : libère l'URL sans annuler le téléchargement en cours.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
this.exportingFoundry = false;
|
||||
this.foundryDialogOpen = false;
|
||||
},
|
||||
error: (err) => {
|
||||
// Ne pas avaler l'erreur en silence : visible en console pour diagnostic.
|
||||
console.error('Échec de l\'export Foundry', err);
|
||||
this.exportingFoundry = false;
|
||||
this.foundryDialogOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -407,7 +481,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!, this.translate));
|
||||
this.layoutService.show(buildCampaignSidebarConfig(
|
||||
this.campaign!, allCampaigns, data, this.campaign!.id!, this.translate, this.readiness?.gaps ?? []));
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression de la Campagne ───────────────
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
@if (open) {
|
||||
<div class="confirm-backdrop" (click)="onCancel()">
|
||||
<div class="confirm-modal variant-info"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-label]="'foundryDialog.title' | translate"
|
||||
(click)="$event.stopPropagation()">
|
||||
<div class="confirm-header">
|
||||
<div class="confirm-icon">
|
||||
<lucide-icon [img]="Download" [size]="20"></lucide-icon>
|
||||
</div>
|
||||
<h2>{{ 'foundryDialog.title' | translate }}</h2>
|
||||
<button type="button" class="btn-close" (click)="onCancel()" [attr.aria-label]="'common.close' | translate">
|
||||
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="confirm-body">
|
||||
<p class="confirm-message">{{ 'foundryDialog.intro' | translate }}</p>
|
||||
|
||||
<div class="fx-presets">
|
||||
<button type="button" (click)="presetAll()">{{ 'foundryDialog.presetAll' | translate }}</button>
|
||||
<button type="button" (click)="presetMapsOnly()">{{ 'foundryDialog.presetMapsOnly' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<label class="fx-option">
|
||||
<input type="checkbox" [checked]="maps" (change)="maps = !maps" />
|
||||
<span>
|
||||
<strong>{{ 'foundryDialog.optMaps' | translate }}</strong>
|
||||
<small>{{ 'foundryDialog.optMapsHint' | translate }}</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="fx-option">
|
||||
<input type="checkbox" [checked]="journals" (change)="journals = !journals" />
|
||||
<span>
|
||||
<strong>{{ 'foundryDialog.optJournals' | translate }}</strong>
|
||||
<small>{{ 'foundryDialog.optJournalsHint' | translate }}</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="fx-option">
|
||||
<input type="checkbox" [checked]="tables" (change)="tables = !tables" />
|
||||
<span>
|
||||
<strong>{{ 'foundryDialog.optTables' | translate }}</strong>
|
||||
<small>{{ 'foundryDialog.optTablesHint' | translate }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="confirm-actions">
|
||||
<button type="button" class="btn-secondary" (click)="onCancel()" [disabled]="exporting">
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-confirm" (click)="onConfirm()" [disabled]="nothingSelected || exporting">
|
||||
{{ (exporting ? 'campaignDetail.foundryExporting' : 'foundryDialog.confirm') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Même squelette visuel que confirm-dialog (backdrop/modal/header/actions),
|
||||
// avec en plus les cases à cocher du périmètre et les raccourcis.
|
||||
.confirm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.confirm-modal {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
|
||||
&.variant-info { border-top: 4px solid #6c63ff; }
|
||||
}
|
||||
|
||||
.confirm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
|
||||
h2 {
|
||||
flex: 1;
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: rgba(108, 99, 255, 0.15);
|
||||
color: #6c63ff;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
|
||||
.confirm-body {
|
||||
padding: 1.25rem 1.5rem;
|
||||
color: #d1d5db;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.confirm-message { margin: 0 0 0.75rem; }
|
||||
|
||||
.fx-presets {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.9rem;
|
||||
|
||||
button {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 999px;
|
||||
color: #9ca3af;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover { color: #d1d5db; border-color: #6c63ff; }
|
||||
}
|
||||
}
|
||||
|
||||
.fx-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
& + & { margin-top: 0.5rem; }
|
||||
|
||||
input { margin-top: 0.2rem; accent-color: #6c63ff; }
|
||||
|
||||
span { display: flex; flex-direction: column; }
|
||||
strong { color: white; font-size: 0.9rem; font-weight: 600; }
|
||||
small { color: #9ca3af; font-size: 0.8rem; line-height: 1.4; }
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #0d121d;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #1f2937;
|
||||
color: #d1d5db;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #374151; }
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 0.6rem 1.25rem;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: #6c63ff;
|
||||
|
||||
&:hover { background: #5b52e0; }
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { LucideAngularModule, Download, X } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { FoundryExportOptions } from '../../../services/campaign.service';
|
||||
|
||||
/**
|
||||
* Modale de choix du PÉRIMÈTRE de l'export Foundry : tout, ou seulement les
|
||||
* cartes + ennemis (usage table de jeu), ou seulement les journaux… Trois cases
|
||||
* indépendantes + raccourcis. Le parent déclenche le téléchargement au confirm.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-foundry-export-dialog',
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './foundry-export-dialog.component.html',
|
||||
styleUrls: ['./foundry-export-dialog.component.scss']
|
||||
})
|
||||
export class FoundryExportDialogComponent {
|
||||
readonly Download = Download;
|
||||
readonly X = X;
|
||||
|
||||
@Input() open = false;
|
||||
@Input() exporting = false;
|
||||
@Output() cancelled = new EventEmitter<void>();
|
||||
@Output() confirmed = new EventEmitter<FoundryExportOptions>();
|
||||
|
||||
maps = true;
|
||||
journals = true;
|
||||
tables = true;
|
||||
|
||||
get nothingSelected(): boolean { return !this.maps && !this.journals && !this.tables; }
|
||||
|
||||
/** Raccourci « usage table de jeu » : uniquement les cartes + ennemis. */
|
||||
presetMapsOnly(): void {
|
||||
this.maps = true;
|
||||
this.journals = false;
|
||||
this.tables = false;
|
||||
}
|
||||
|
||||
presetAll(): void {
|
||||
this.maps = true;
|
||||
this.journals = true;
|
||||
this.tables = true;
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
if (this.exporting) return;
|
||||
this.cancelled.emit();
|
||||
}
|
||||
|
||||
onConfirm(): void {
|
||||
if (this.nothingSelected || this.exporting) return;
|
||||
this.confirmed.emit({ maps: this.maps, journals: this.journals, tables: this.tables });
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
<div class="chapter-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>{{ (isHub ? 'chapterCreate.titleQuest' : 'chapterCreate.titleChapter') | translate }}</h1>
|
||||
<h1>{{ 'chapterCreate.titleChapter' | translate }}</h1>
|
||||
@if (arcName) {
|
||||
<p class="arc-ref">{{ (isHub ? 'chapterCreate.hub' : 'chapterCreate.arc') | translate }} : {{ arcName }}</p>
|
||||
<p class="arc-ref">{{ 'chapterCreate.arc' | translate }} : {{ arcName }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
|
||||
|
||||
<div class="field">
|
||||
<label for="chapter-create-name">{{ (isHub ? 'chapterCreate.nameQuestLabel' : 'chapterCreate.nameChapterLabel') | translate }}</label>
|
||||
<label for="chapter-create-name">{{ 'chapterCreate.nameChapterLabel' | translate }}</label>
|
||||
<input
|
||||
id="chapter-create-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
[placeholder]="(isHub ? 'chapterCreate.namePlaceholderQuest' : 'chapterCreate.namePlaceholderChapter') | translate"
|
||||
[placeholder]="'chapterCreate.namePlaceholderChapter' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
@@ -25,7 +25,7 @@
|
||||
<textarea
|
||||
id="chapter-create-description"
|
||||
formControlName="description"
|
||||
[placeholder]="(isHub ? 'chapterCreate.descPlaceholderQuest' : 'chapterCreate.descPlaceholderChapter') | translate"
|
||||
[placeholder]="'chapterCreate.descPlaceholderChapter' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
{{ (isHub ? 'chapterCreate.createQuest' : 'chapterCreate.createChapter') | translate }}
|
||||
{{ 'chapterCreate.createChapter' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,6 @@ 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(
|
||||
@@ -67,7 +65,6 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
}).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.translate));
|
||||
|
||||
@@ -26,36 +26,21 @@
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
|
||||
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
|
||||
<div class="hub-section">
|
||||
<h2 class="hub-section-title">
|
||||
{{ (parentArc?.type === 'HUB' ? 'chapterEdit.unlockTitleQuest' : 'chapterEdit.unlockTitleChapter') | translate }}
|
||||
</h2>
|
||||
<small class="field-hint">
|
||||
{{ (parentArc?.type === 'HUB' ? 'chapterEdit.unlockHintQuest' : 'chapterEdit.unlockHintChapter') | translate }}
|
||||
</small>
|
||||
<app-prerequisite-editor
|
||||
[prerequisites]="prerequisites"
|
||||
[availableQuests]="availableQuests"
|
||||
[availableFlags]="availableFlagNames"
|
||||
(prerequisitesChange)="onPrerequisitesChange($event)">
|
||||
</app-prerequisite-editor>
|
||||
</div>
|
||||
<!-- Tout est optionnel sauf le titre : identité visible, le reste replié. -->
|
||||
<p class="optional-hint">{{ 'chapterEdit.optionalHint' | translate }}</p>
|
||||
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>{{ 'chapterEdit.illustrations' | translate }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">{{ 'chapterEdit.illustrationsHint' | translate }}</small>
|
||||
</div>
|
||||
<!-- Co-création (Pilier A) : le co-MJ propose, validation champ par champ → remplit le formulaire. -->
|
||||
<app-entity-assist-panel
|
||||
[entityType]="'chapter'" [entityId]="chapterId" [campaignId]="campaignId"
|
||||
(applied)="onAssistApplied($event)"></app-entity-assist-panel>
|
||||
|
||||
<!-- Co-création (Pilier A, « create ») : peupler ce chapitre en scènes. -->
|
||||
<app-scene-draft-panel
|
||||
[chapterId]="chapterId" [campaignId]="campaignId"
|
||||
[startOpen]="assistParam === 'draft-scenes'"
|
||||
(created)="onScenesCreated($event)"></app-scene-draft-panel>
|
||||
|
||||
<!-- Identité -->
|
||||
<div class="field">
|
||||
<label for="chapter-edit-name">{{ 'chapterEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
@@ -82,42 +67,58 @@
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="chapter-edit-gm-notes">{{ 'chapterEdit.gmNotesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-edit-gm-notes"
|
||||
formControlName="gmNotes"
|
||||
[placeholder]="'chapterEdit.gmNotesPlaceholder' | translate"
|
||||
rows="6">
|
||||
</textarea>
|
||||
<small class="field-hint">{{ 'chapterEdit.gmNotesHint' | translate }}</small>
|
||||
</div>
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<app-expandable-section [title]="'chapterEdit.illustrations' | translate" icon="🖼️" [filled]="illustrationsFilled">
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">{{ 'chapterEdit.illustrationsHint' | translate }}</small>
|
||||
</app-expandable-section>
|
||||
|
||||
<div class="field-row">
|
||||
<!-- Notes du MJ (privé) -->
|
||||
<app-expandable-section [title]="'chapterEdit.gmNotesSectionTitle' | translate" icon="🔒" variant="private" [filled]="gmNotesFilled">
|
||||
<div class="field">
|
||||
<label for="chapter-edit-player-objectives">{{ 'chapterEdit.playerObjectivesLabel' | translate }}</label>
|
||||
<label for="chapter-edit-gm-notes">{{ 'chapterEdit.gmNotesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-edit-player-objectives"
|
||||
formControlName="playerObjectives"
|
||||
[placeholder]="'chapterEdit.playerObjectivesPlaceholder' | translate"
|
||||
rows="4">
|
||||
id="chapter-edit-gm-notes"
|
||||
formControlName="gmNotes"
|
||||
[placeholder]="'chapterEdit.gmNotesPlaceholder' | translate"
|
||||
rows="6">
|
||||
</textarea>
|
||||
<small class="field-hint">{{ 'chapterEdit.gmNotesHint' | translate }}</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter-edit-narrative-stakes">{{ 'chapterEdit.narrativeStakesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-edit-narrative-stakes"
|
||||
formControlName="narrativeStakes"
|
||||
[placeholder]="'chapterEdit.narrativeStakesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Objectifs et enjeux -->
|
||||
<app-expandable-section [title]="'chapterEdit.objectivesStakesSectionTitle' | translate" icon="🎯" [filled]="objectivesStakesFilled">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="chapter-edit-player-objectives">{{ 'chapterEdit.playerObjectivesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-edit-player-objectives"
|
||||
formControlName="playerObjectives"
|
||||
[placeholder]="'chapterEdit.playerObjectivesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter-edit-narrative-stakes">{{ 'chapterEdit.narrativeStakesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="chapter-edit-narrative-stakes"
|
||||
formControlName="narrativeStakes"
|
||||
[placeholder]="'chapterEdit.narrativeStakesPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- ===== Pages Lore associées (B2 cross-context) ===== -->
|
||||
@if (loreId) {
|
||||
<div class="field">
|
||||
<label>{{ 'chapterEdit.relatedPages' | translate }}</label>
|
||||
<app-expandable-section [title]="'chapterEdit.relatedPages' | translate" icon="🔗" [filled]="loreFilled">
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="availablePages"
|
||||
@@ -127,7 +128,7 @@
|
||||
<small class="field-hint">
|
||||
{{ 'chapterEdit.relatedPagesHint' | translate }}
|
||||
</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
}
|
||||
|
||||
@if (!loreId) {
|
||||
|
||||
@@ -73,6 +73,17 @@
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Bandeau « tout est optionnel sauf le titre » en tête du formulaire.
|
||||
.optional-hint {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: rgba(108, 99, 255, 0.08);
|
||||
border: 1px solid rgba(108, 99, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
color: #a5b4fc;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
// Override local : bouton Supprimer poussé à droite (voir _buttons.scss pour la base).
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -13,17 +13,19 @@ 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, Prerequisite, Arc } from '../../../services/campaign.model';
|
||||
import { Chapter } 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 { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
import { EntityAssistPanelComponent } from '../../../shared/entity-assist-panel/entity-assist-panel.component';
|
||||
import { FieldProposal } from '../../../services/entity-assist.model';
|
||||
import { SceneDraftPanelComponent } from '../../../shared/scene-draft-panel/scene-draft-panel.component';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'un Chapitre. Donnée de SCÉNARIO uniquement.
|
||||
@@ -41,7 +43,9 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
AiChatDrawerComponent,
|
||||
ImageGalleryComponent,
|
||||
IconPickerComponent,
|
||||
PrerequisiteEditorComponent,
|
||||
ExpandableSectionComponent,
|
||||
EntityAssistPanelComponent,
|
||||
SceneDraftPanelComponent,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './chapter-edit.component.html',
|
||||
@@ -58,28 +62,45 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||
|
||||
/** Applique au FORMULAIRE les champs étoffés retenus (Pilier A). Non destructif. */
|
||||
onAssistApplied(fields: FieldProposal[]): void {
|
||||
const patch: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
if (this.form.get(f.key)) patch[f.key] = f.proposedValue;
|
||||
}
|
||||
this.form.patchValue(patch);
|
||||
}
|
||||
|
||||
/** Après création de scènes par l'IA : on ouvre le graphe du chapitre pour les voir. */
|
||||
onScenesCreated(n: number): void {
|
||||
if (n > 0) {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'graph']);
|
||||
}
|
||||
}
|
||||
|
||||
form: FormGroup;
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
chapterId = '';
|
||||
chapter: Chapter | null = null;
|
||||
|
||||
/** `?assist=draft-scenes` (bouton « Corriger » du guidage) → panneau IA déployé d'office. */
|
||||
assistParam: string | null = null;
|
||||
|
||||
availablePages: Page[] = [];
|
||||
loreId: string | null = null;
|
||||
relatedPageIds: string[] = [];
|
||||
illustrationImageIds: 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;
|
||||
// ─────────────── État « rempli » par section (pastille de l'en-tête) ───────────────
|
||||
// Seul le titre est requis ; ces getters signalent ce qui contient déjà du contenu.
|
||||
get illustrationsFilled(): boolean { return this.illustrationImageIds.length > 0; }
|
||||
get gmNotesFilled(): boolean { return !!this.form.value.gmNotes; }
|
||||
get objectivesStakesFilled(): boolean {
|
||||
const v = this.form.value;
|
||||
return !!(v.playerObjectives || v.narrativeStakes);
|
||||
}
|
||||
get loreFilled(): boolean { return this.relatedPageIds.length > 0; }
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
@@ -93,7 +114,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private campaignFlagService: CampaignFlagService,
|
||||
private translate: TranslateService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -121,6 +141,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
this.campaignId = newCampaignId;
|
||||
this.arcId = newArcId;
|
||||
this.chapterId = newChapterId;
|
||||
this.assistParam = this.route.snapshot.queryParamMap.get('assist');
|
||||
this.loadAll();
|
||||
}
|
||||
});
|
||||
@@ -147,20 +168,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
this.selectedIcon = chapter.icon ?? null;
|
||||
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
|
||||
|
||||
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 ?? '',
|
||||
@@ -173,10 +180,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
onPrerequisitesChange(next: Prerequisite[]): void {
|
||||
this.prerequisites = next;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || !this.chapter) return;
|
||||
this.campaignService.updateChapter(this.chapterId, {
|
||||
@@ -187,7 +190,6 @@ 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,
|
||||
icon: this.selectedIcon
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ 'chapterGraph.title' | translate:{ name: chapter?.name || ('chapterGraph.chapter' | translate) } }}</h1>
|
||||
<h1>{{ isFlatMode ? ('chapterGraph.titleFlat' | translate) : ('chapterGraph.title' | translate:{ name: chapter?.name || ('chapterGraph.chapter' | translate) }) }}</h1>
|
||||
<p class="subtitle">{{ 'chapterGraph.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
{{ 'chapterGraph.back' | translate }}
|
||||
</button>
|
||||
<div class="graph-actions">
|
||||
<button type="button" class="btn-primary" (click)="addScene()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ 'chapterGraph.addScene' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
{{ (isFlatMode ? 'chapterGraph.backFlat' : 'chapterGraph.back') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (scenes.length === 0) {
|
||||
@@ -20,24 +26,35 @@
|
||||
@if (scenes.length > 0) {
|
||||
<div class="graph-container">
|
||||
<svg #svgEl
|
||||
[attr.width]="svgWidth" [attr.height]="svgHeight"
|
||||
[attr.viewBox]="viewBox"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="graph-svg"
|
||||
[class.panning]="false"
|
||||
(pointerdown)="onSvgPointerDown($event)"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerUp($event)"
|
||||
(pointercancel)="onPointerUp($event)">
|
||||
(pointercancel)="onPointerUp($event)"
|
||||
(wheel)="onWheel($event)">
|
||||
<defs>
|
||||
<!-- fill=context-stroke : la pointe de flèche prend la couleur de l'arête. -->
|
||||
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
|
||||
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" />
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" />
|
||||
</marker>
|
||||
</defs>
|
||||
<g class="edges">
|
||||
@for (edge of edges; track edge) {
|
||||
<g class="edge">
|
||||
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
|
||||
<g class="edge" [class.selected]="selectedEdgeKey === edge.key">
|
||||
<line class="edge-line"
|
||||
[attr.x1]="edge.x1" [attr.y1]="edge.y1"
|
||||
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
|
||||
stroke="#b8c0cc" stroke-width="2"
|
||||
[attr.stroke]="edgeColor(edge.kind)" stroke-width="2"
|
||||
marker-end="url(#arrowhead)" />
|
||||
<!-- Zone de clic élargie et transparente pour sélectionner le lien -->
|
||||
<line class="edge-hit"
|
||||
[attr.x1]="edge.x1" [attr.y1]="edge.y1"
|
||||
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
|
||||
(pointerdown)="onEdgeSelect($event, edge)" />
|
||||
@if (edge.label) {
|
||||
<text
|
||||
[attr.x]="edge.labelX"
|
||||
@@ -60,21 +77,109 @@
|
||||
<title>{{ node.name }}</title>
|
||||
<rect [attr.x]="node.x" [attr.y]="node.y"
|
||||
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
|
||||
rx="8" ry="8" class="node-box" />
|
||||
rx="8" ry="8" [attr.class]="'node-box node-box--' + node.type" />
|
||||
<text [attr.x]="node.x + NODE_WIDTH / 2"
|
||||
[attr.y]="node.y + NODE_HEIGHT / 2 + 5"
|
||||
text-anchor="middle"
|
||||
class="node-label">
|
||||
{{ node.displayName }}
|
||||
</text>
|
||||
<!-- Pastille de connexion : tirer un lien vers un autre nœud. -->
|
||||
<circle class="node-connect"
|
||||
[attr.cx]="node.x + NODE_WIDTH / 2"
|
||||
[attr.cy]="node.y + NODE_HEIGHT"
|
||||
r="6"
|
||||
(pointerdown)="onConnectStart($event, node)" />
|
||||
<!-- Poignée de renommage (visible au survol du nœud). -->
|
||||
<g class="node-rename"
|
||||
[attr.transform]="'translate(' + (node.x + NODE_WIDTH - 26) + ',' + (node.y + 6) + ')'"
|
||||
(pointerdown)="startRenameNode($event, node)">
|
||||
<title>{{ 'chapterGraph.rename' | translate }}</title>
|
||||
<circle cx="9" cy="9" r="10" class="node-rename-bg" />
|
||||
<text x="9" y="13" text-anchor="middle" class="node-rename-glyph">✎</text>
|
||||
</g>
|
||||
</g>
|
||||
}
|
||||
</g>
|
||||
@if (linkingFromId) {
|
||||
<line class="link-temp"
|
||||
[attr.x1]="linkSourceX()" [attr.y1]="linkSourceY()"
|
||||
[attr.x2]="linkPointerX" [attr.y2]="linkPointerY" />
|
||||
}
|
||||
</svg>
|
||||
<small class="graph-hint">
|
||||
{{ 'chapterGraph.hint' | translate }}
|
||||
</small>
|
||||
|
||||
<!-- Renommage inline : champ positionné par-dessus le nœud édité. -->
|
||||
@if (editingNodeId) {
|
||||
<input id="node-rename-input" class="node-rename-input"
|
||||
[style.left.px]="renameBox.left" [style.top.px]="renameBox.top"
|
||||
[style.width.px]="renameBox.width" [style.height.px]="renameBox.height"
|
||||
[(ngModel)]="editNodeName"
|
||||
[placeholder]="'chapterGraph.renamePlaceholder' | translate"
|
||||
(keydown.enter)="saveRename()"
|
||||
(keydown.escape)="cancelRename()"
|
||||
(blur)="saveRename()" />
|
||||
}
|
||||
|
||||
<!-- Contrôles de caméra (zoom + recadrer) -->
|
||||
<div class="graph-controls">
|
||||
<button type="button" (click)="zoomBy(1 / 1.2)" [title]="'chapterGraph.zoomIn' | translate">
|
||||
<lucide-icon [img]="ZoomIn" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" (click)="zoomBy(1.2)" [title]="'chapterGraph.zoomOut' | translate">
|
||||
<lucide-icon [img]="ZoomOut" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" (click)="fitView()" [title]="'chapterGraph.fit' | translate">
|
||||
<lucide-icon [img]="Maximize" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Éditeur du lien sélectionné -->
|
||||
@if (selectedEdge; as edge) {
|
||||
<div class="edge-editor">
|
||||
<div class="edge-editor-head">
|
||||
@if (selectedEdgeEndpoints; as ep) {
|
||||
<span class="edge-editor-title" [title]="ep.from + ' → ' + ep.to">{{ ep.from }} → {{ ep.to }}</span>
|
||||
}
|
||||
<button type="button" class="edge-editor-close" (click)="closeEdgeEditor()"
|
||||
[title]="'chapterGraph.linkEditor.close' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
<label class="edge-field">
|
||||
<span>{{ 'chapterGraph.linkEditor.label' | translate }}</span>
|
||||
<input type="text" [(ngModel)]="editEdgeLabel"
|
||||
[placeholder]="'chapterGraph.linkEditor.labelPlaceholder' | translate" />
|
||||
</label>
|
||||
<label class="edge-field">
|
||||
<span>{{ 'chapterGraph.linkEditor.condition' | translate }}</span>
|
||||
<input type="text" [(ngModel)]="editEdgeCondition"
|
||||
[placeholder]="'chapterGraph.linkEditor.conditionPlaceholder' | translate" />
|
||||
</label>
|
||||
<label class="edge-field">
|
||||
<span>{{ 'chapterGraph.linkEditor.type' | translate }}</span>
|
||||
<select [(ngModel)]="editEdgeKind">
|
||||
@for (k of linkKindOptions; track k) {
|
||||
<option [ngValue]="k">{{ 'chapterGraph.linkEditor.kind.' + k | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<div class="edge-editor-actions">
|
||||
<button type="button" class="btn-del" (click)="deleteEdge()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
{{ 'chapterGraph.linkEditor.delete' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-save" (click)="saveEdge()">
|
||||
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||
{{ 'common.save' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<small class="graph-hint">
|
||||
{{ 'chapterGraph.hint' | translate }}
|
||||
</small>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.graph-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.graph-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -27,47 +33,42 @@
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
// Fond legerement plus sombre que la couleur des noeuds : creuse l'image
|
||||
// sans aller jusqu'au noir pur (qui « brulerait » par contraste).
|
||||
position: relative; // ancre les contrôles + l'éditeur de lien
|
||||
background: #0d0d18;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 72vh; // caméra : hauteur fixe, le viewBox gère le contenu
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
// Empeche le browser de clipper le contenu qui depasserait le viewport SVG
|
||||
// pendant un drag — le scroll du conteneur prend le relais.
|
||||
overflow: visible;
|
||||
// Évite que le navigateur intercepte le drag pour faire de la sélection texte
|
||||
// ou du panning natif sur les nœuds.
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab; // le fond se déplace au glisser (pan)
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
||||
&:active { cursor: grabbing; }
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: grab;
|
||||
// Ombre portee douce pour detacher chaque noeud du fond. Faible alpha pour
|
||||
// rester subtil sur fond sombre, large diffusion pour rester organique.
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
|
||||
|
||||
.node-box {
|
||||
// Indigo desature : reprend la palette accent (#6c63ff) en version assombrie
|
||||
// pour donner du caractere aux noeuds sans saturer la vue. Bordure assortie
|
||||
// un peu plus claire pour bien dessiner le contour.
|
||||
fill: #1f1d3a;
|
||||
stroke: #4f4a7a;
|
||||
stroke-width: 2;
|
||||
transition: fill 0.15s ease, stroke 0.15s ease;
|
||||
}
|
||||
|
||||
.node-box--LOCATION { fill: #14342a; stroke: #2f8f6b; }
|
||||
.node-box--ENCOUNTER { fill: #3a1d1d; stroke: #b4554f; }
|
||||
.node-box--NPC { fill: #1a2942; stroke: #4f7ab4; }
|
||||
.node-box--EVENT { fill: #3a301a; stroke: #b48a4f; }
|
||||
.node-box--REVELATION { fill: #2e1d3a; stroke: #8b5fb4; }
|
||||
|
||||
.node-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
@@ -75,6 +76,28 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node-connect {
|
||||
fill: #6c63ff;
|
||||
stroke: #0d0d18;
|
||||
stroke-width: 1.5;
|
||||
cursor: crosshair;
|
||||
opacity: 0.35;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
&:hover .node-connect { opacity: 1; }
|
||||
|
||||
// Poignée de renommage (coin haut-droit), révélée au survol du nœud.
|
||||
.node-rename {
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.node-rename-bg { fill: #2c2952; stroke: #8b80ff; stroke-width: 1.5; }
|
||||
.node-rename-glyph { fill: #e5e7eb; font-size: 12px; pointer-events: none; }
|
||||
&:hover .node-rename-bg { fill: #3a3568; }
|
||||
}
|
||||
&:hover .node-rename { opacity: 1; }
|
||||
|
||||
&:hover .node-box {
|
||||
fill: #2c2952;
|
||||
stroke: #8b80ff;
|
||||
@@ -90,13 +113,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// L'arête visible ne capte pas le pointeur ; c'est la « zone de clic » qui le fait.
|
||||
.edge-line {
|
||||
pointer-events: none;
|
||||
transition: stroke-width 0.1s ease;
|
||||
}
|
||||
|
||||
// Zone de clic large et invisible au-dessus de chaque arête (sélection facile).
|
||||
.edge-hit {
|
||||
stroke: transparent;
|
||||
stroke-width: 16;
|
||||
fill: none;
|
||||
cursor: pointer;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
|
||||
.edge.selected .edge-line {
|
||||
stroke-width: 3.5;
|
||||
filter: drop-shadow(0 0 4px rgba(139, 128, 255, 0.7));
|
||||
}
|
||||
|
||||
.edge-label {
|
||||
font-size: 0.75rem;
|
||||
fill: #e5e7eb;
|
||||
font-style: italic;
|
||||
cursor: grab;
|
||||
// Halo sombre autour du texte pour rester lisible quand un label passe
|
||||
// par-dessus une arête ou un autre nœud. Aligne sur la couleur du fond.
|
||||
paint-order: stroke;
|
||||
stroke: #0d0d18;
|
||||
stroke-width: 4px;
|
||||
@@ -109,6 +150,158 @@
|
||||
}
|
||||
}
|
||||
|
||||
.link-temp {
|
||||
stroke: #8b80ff;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Champ de renommage inline, posé par-dessus le nœud édité.
|
||||
.node-rename-input {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
padding: 0 0.6rem;
|
||||
background: #2c2952;
|
||||
border: 2px solid #8b80ff;
|
||||
border-radius: 8px;
|
||||
color: #f3f4f6;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
z-index: 5;
|
||||
|
||||
&:focus { outline: none; }
|
||||
&::placeholder { color: #9ca3af; }
|
||||
}
|
||||
|
||||
// ─────────────── Contrôles de caméra ───────────────
|
||||
.graph-controls {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover { background: #24243a; color: #fff; border-color: #6c63ff; }
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── Éditeur d'un lien sélectionné ───────────────
|
||||
.edge-editor {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 260px;
|
||||
background: #14141f;
|
||||
border: 1px solid #3a3658;
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 0.8rem 0.8rem;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.edge-editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.edge-editor-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #cbb9ff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edge-editor-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3658;
|
||||
border-radius: 6px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
&:hover { background: #24243a; color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.edge-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.76rem;
|
||||
color: #9ca3af;
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.83rem;
|
||||
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
}
|
||||
|
||||
.edge-editor-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.15rem;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 7px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-del {
|
||||
background: #3a1d1d;
|
||||
border-color: #b4554f;
|
||||
color: #ffb4ae;
|
||||
&:hover { background: #4a2424; color: #fff; }
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #163326;
|
||||
border-color: #2f6b46;
|
||||
color: #a7f3d0;
|
||||
&:hover { background: #1c4531; color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.graph-hint {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
|
||||
@@ -1,39 +1,50 @@
|
||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, ZoomIn, ZoomOut, Maximize, Trash2, Check, X } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.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 { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { Chapter, Scene, SceneType, LinkType, SceneBranch, SceneCreate } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
|
||||
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
|
||||
interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
|
||||
interface GraphNode { id: string; name: string; displayName: string; type: SceneType; x: number; y: number; }
|
||||
interface GraphEdge { key: string; label: string; kind: LinkType; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
|
||||
|
||||
/**
|
||||
* Vue graphique d'un chapitre : organigramme des scènes et branches narratives.
|
||||
* Layout custom (BFS par niveaux) en SVG — évite une dépendance lourde type ngx-graph.
|
||||
* Caméra pan/zoom via viewBox ; les liens sont éditables/supprimables au clic.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-chapter-graph',
|
||||
imports: [RouterModule, LucideAngularModule, TranslatePipe],
|
||||
imports: [RouterModule, FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './chapter-graph.component.html',
|
||||
styleUrls: ['./chapter-graph.component.scss']
|
||||
})
|
||||
export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Plus = Plus;
|
||||
readonly ZoomIn = ZoomIn;
|
||||
readonly ZoomOut = ZoomOut;
|
||||
readonly Maximize = Maximize;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Check = Check;
|
||||
readonly X = X;
|
||||
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
chapterId = '';
|
||||
chapter: Chapter | null = null;
|
||||
scenes: Scene[] = [];
|
||||
/** Mode plat (1 arc, 1 chapitre) : la notion de chapitre est masquée dans cette vue. */
|
||||
isFlatMode = false;
|
||||
|
||||
nodes: GraphNode[] = [];
|
||||
edges: GraphEdge[] = [];
|
||||
@@ -44,8 +55,32 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
readonly V_SPACING = 90;
|
||||
readonly MAX_LABEL_CHARS = 26;
|
||||
|
||||
svgWidth = 600;
|
||||
svgHeight = 400;
|
||||
// ─────────────── Caméra (viewBox) : pan + zoom ───────────────
|
||||
viewX = 0;
|
||||
viewY = 0;
|
||||
viewW = 600;
|
||||
viewH = 400;
|
||||
private readonly MIN_VIEW_W = 240; // zoom max (avant)
|
||||
private readonly MAX_VIEW_W = 9000; // zoom max (arrière)
|
||||
private hasFitted = false;
|
||||
private panning = false;
|
||||
private panStartX = 0;
|
||||
private panStartY = 0;
|
||||
private panStartViewX = 0;
|
||||
private panStartViewY = 0;
|
||||
|
||||
// ─────────────── Édition d'un lien sélectionné ───────────────
|
||||
selectedEdgeKey: string | null = null;
|
||||
editEdgeLabel = '';
|
||||
editEdgeCondition = '';
|
||||
editEdgeKind: LinkType = 'EXIT';
|
||||
readonly linkKindOptions: LinkType[] = ['EXIT', 'CLUE', 'LEAD'];
|
||||
|
||||
// ─────────────── Renommage inline d'une scène ───────────────
|
||||
editingNodeId: string | null = null;
|
||||
editNodeName = '';
|
||||
// Boîte de l'input (px, relatifs au conteneur) calculée une fois à l'ouverture.
|
||||
renameBox = { left: 0, top: 0, width: 0, height: 0 };
|
||||
|
||||
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
|
||||
|
||||
@@ -54,15 +89,17 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
// significatif a eu lieu (pour distinguer clic vs glisser).
|
||||
draggingId: string | null = null;
|
||||
draggingLabelKey: string | null = null;
|
||||
// Création de lien par glisser (2c) : nœud source + position courante du pointeur.
|
||||
linkingFromId: string | null = null;
|
||||
linkPointerX = 0;
|
||||
linkPointerY = 0;
|
||||
private dragOffsetX = 0;
|
||||
private dragOffsetY = 0;
|
||||
private dragMoved = false;
|
||||
private readonly DRAG_THRESHOLD = 4;
|
||||
|
||||
// Decalage manuel applique a chaque label d'arete, indexe par cle stable
|
||||
// (sourceId|targetId|branchIdx). Persiste a travers les recalculs d'aretes
|
||||
// pour que le label suive son arete quand on deplace un noeud, tout en
|
||||
// conservant le repositionnement manuel de l'utilisateur.
|
||||
// (sourceId|targetId|branchIdx). Persiste a travers les recalculs d'aretes.
|
||||
private labelOffsets = new Map<string, { dx: number; dy: number }>();
|
||||
|
||||
constructor(
|
||||
@@ -96,21 +133,29 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||
this.chapter = chapter;
|
||||
this.scenes = scenes;
|
||||
this.pageTitleService.set(this.translate.instant('chapterGraph.title', { name: chapter.name }));
|
||||
// Mode plat = campagne simple (1 arc, 1 chapitre) SANS compter la plomberie
|
||||
// (arc SYSTEM des quêtes libres et ses conteneurs).
|
||||
const narrativeArcs = treeData.arcs.filter(a => a.type !== 'SYSTEM');
|
||||
const narrativeArcIds = new Set(narrativeArcs.map(a => a.id));
|
||||
const allChapters = Object.entries(treeData.chaptersByArc)
|
||||
.filter(([arcId]) => narrativeArcIds.has(arcId))
|
||||
.flatMap(([, chs]) => chs);
|
||||
this.isFlatMode = narrativeArcs.length === 1 && allChapters.length === 1;
|
||||
this.pageTitleService.set(this.isFlatMode
|
||||
? this.translate.instant('chapterGraph.titleFlat')
|
||||
: this.translate.instant('chapterGraph.title', { name: chapter.name }));
|
||||
this.buildGraph();
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData, this.translate),
|
||||
footerLabel: this.translate.instant('chapterGraph.allCampaigns'),
|
||||
createActions: [],
|
||||
globalItems,
|
||||
globalBackLabel: this.translate.instant('chapterGraph.allCampaigns'),
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
// Recadrage automatique UNIQUEMENT au premier chargement : on préserve
|
||||
// ensuite la caméra (pan/zoom) de l'utilisateur à travers les éditions.
|
||||
if (!this.hasFitted && this.nodes.length > 0) {
|
||||
this.fitView(); // provisoire (aspect de repli si le SVG n'est pas encore rendu)
|
||||
setTimeout(() => this.fitView(), 0); // précis une fois le SVG dans le DOM
|
||||
this.hasFitted = true;
|
||||
}
|
||||
|
||||
// Sidebar standard de campagne (titre cliquable + home, « + Nouvel arc », DnD).
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,7 +166,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
private buildGraph(): void {
|
||||
if (this.scenes.length === 0) {
|
||||
this.nodes = []; this.edges = [];
|
||||
this.svgWidth = 600; this.svgHeight = 200;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,35 +210,31 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
const levelWidth = count * this.NODE_WIDTH + (count - 1) * this.H_SPACING;
|
||||
const startX = (rowWidth - levelWidth) / 2;
|
||||
arr.forEach((s, i) => {
|
||||
const autoX = startX + i * (this.NODE_WIDTH + this.H_SPACING);
|
||||
const autoY = lvl * (this.NODE_HEIGHT + this.V_SPACING);
|
||||
nodes.push({
|
||||
id: s.id!,
|
||||
name: s.name,
|
||||
displayName: this.truncate(s.name),
|
||||
x: startX + i * (this.NODE_WIDTH + this.H_SPACING),
|
||||
y: lvl * (this.NODE_HEIGHT + this.V_SPACING)
|
||||
type: s.type ?? 'GENERIC',
|
||||
// Position sauvegardée (Niveau 2) si présente, sinon layout auto (BFS).
|
||||
x: s.graphX != null ? s.graphX : autoX,
|
||||
y: s.graphY != null ? s.graphY : autoY
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.nodes = nodes;
|
||||
this.recomputeEdges();
|
||||
this.svgWidth = Math.max(rowWidth + 40, 600);
|
||||
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcule la geometrie des aretes a partir des positions courantes des noeuds.
|
||||
* Appele apres le layout initial et apres chaque deplacement manuel d'un noeud.
|
||||
*/
|
||||
/** Recalcule la geometrie des aretes a partir des positions courantes des noeuds. */
|
||||
private recomputeEdges(): void {
|
||||
const nodeMap = new Map(this.nodes.map(n => [n.id, n]));
|
||||
const edges: GraphEdge[] = [];
|
||||
for (const scene of this.scenes) {
|
||||
const from = nodeMap.get(scene.id!);
|
||||
if (!from || !scene.branches) continue;
|
||||
// On positionne chaque label a une fraction t differente de l'arete selon
|
||||
// son index parmi les sorties du meme noeud source. Evite le chevauchement
|
||||
// des labels au milieu quand plusieurs aretes convergent/divergent.
|
||||
const siblings = scene.branches.filter(b => nodeMap.has(b.targetSceneId));
|
||||
const count = siblings.length;
|
||||
siblings.forEach((b, idx) => {
|
||||
@@ -203,13 +243,13 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
const y1 = from.y + this.NODE_HEIGHT;
|
||||
const x2 = to.x + this.NODE_WIDTH / 2;
|
||||
const y2 = to.y;
|
||||
// t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes.
|
||||
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
|
||||
const key = `${scene.id}|${b.targetSceneId}|${idx}`;
|
||||
const offset = this.labelOffsets.get(key) ?? { dx: 0, dy: 0 };
|
||||
edges.push({
|
||||
key,
|
||||
label: b.label,
|
||||
kind: b.kind ?? 'EXIT',
|
||||
x1, y1, x2, y2,
|
||||
labelX: x1 + (x2 - x1) * t + offset.dx,
|
||||
labelY: y1 + (y2 - y1) * t - 4 + offset.dy
|
||||
@@ -219,10 +259,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
this.edges = edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit des coordonnees ecran (PointerEvent) en coordonnees SVG via la CTM
|
||||
* inverse. Necessaire car le SVG peut etre redimensionne par max-width.
|
||||
*/
|
||||
/** Convertit des coordonnees ecran (PointerEvent) en coordonnees SVG via la CTM inverse. */
|
||||
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
|
||||
const svg = this.svgEl?.nativeElement;
|
||||
if (!svg) return { x: evt.clientX, y: evt.clientY };
|
||||
@@ -235,21 +272,122 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
return { x: local.x, y: local.y };
|
||||
}
|
||||
|
||||
onPointerDown(evt: PointerEvent, node: GraphNode): void {
|
||||
// Bouton gauche uniquement.
|
||||
// ─────────────── Caméra : viewBox, pan, zoom ───────────────
|
||||
|
||||
get viewBox(): string {
|
||||
return `${this.viewX} ${this.viewY} ${this.viewW} ${this.viewH}`;
|
||||
}
|
||||
|
||||
/** Recadre la caméra sur l'ensemble des nœuds (aspect du conteneur → pas de letterbox). */
|
||||
fitView(): void {
|
||||
if (this.nodes.length === 0) {
|
||||
this.viewX = 0; this.viewY = 0; this.viewW = 600; this.viewH = 400;
|
||||
return;
|
||||
}
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const n of this.nodes) {
|
||||
minX = Math.min(minX, n.x);
|
||||
minY = Math.min(minY, n.y);
|
||||
maxX = Math.max(maxX, n.x + this.NODE_WIDTH);
|
||||
maxY = Math.max(maxY, n.y + this.NODE_HEIGHT);
|
||||
}
|
||||
const margin = 80;
|
||||
minX -= margin; minY -= margin; maxX += margin; maxY += margin;
|
||||
const w = maxX - minX, h = maxY - minY;
|
||||
const rect = this.svgEl?.nativeElement.getBoundingClientRect();
|
||||
const aspect = rect && rect.height > 0 ? rect.width / rect.height : 16 / 9;
|
||||
let vw = w, vh = h;
|
||||
if (w / h > aspect) { vh = w / aspect; } else { vw = h * aspect; }
|
||||
this.viewX = minX - (vw - w) / 2;
|
||||
this.viewY = minY - (vh - h) / 2;
|
||||
this.viewW = vw;
|
||||
this.viewH = vh;
|
||||
}
|
||||
|
||||
/** Zoom autour du centre (boutons). factor < 1 = zoom avant. */
|
||||
zoomBy(factor: number): void {
|
||||
const cx = this.viewX + this.viewW / 2;
|
||||
const cy = this.viewY + this.viewH / 2;
|
||||
const newW = Math.max(this.MIN_VIEW_W, Math.min(this.MAX_VIEW_W, this.viewW * factor));
|
||||
const ratio = newW / this.viewW;
|
||||
this.viewW = newW;
|
||||
this.viewH = this.viewH * ratio;
|
||||
this.viewX = cx - this.viewW / 2;
|
||||
this.viewY = cy - this.viewH / 2;
|
||||
}
|
||||
|
||||
/** Zoom à la molette, centré sur le curseur. */
|
||||
onWheel(evt: WheelEvent): void {
|
||||
evt.preventDefault();
|
||||
const svg = this.svgEl?.nativeElement;
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const mx = (evt.clientX - rect.left) / rect.width;
|
||||
const my = (evt.clientY - rect.top) / rect.height;
|
||||
const svgX = this.viewX + mx * this.viewW;
|
||||
const svgY = this.viewY + my * this.viewH;
|
||||
const factor = evt.deltaY > 0 ? 1.12 : 1 / 1.12;
|
||||
const newW = Math.max(this.MIN_VIEW_W, Math.min(this.MAX_VIEW_W, this.viewW * factor));
|
||||
const ratio = newW / this.viewW;
|
||||
this.viewW = newW;
|
||||
this.viewH = this.viewH * ratio;
|
||||
this.viewX = svgX - mx * this.viewW;
|
||||
this.viewY = svgY - my * this.viewH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Réajuste la hauteur du viewBox à l'aspect du conteneur après un redimensionnement,
|
||||
* en préservant le centre et l'échelle horizontale — évite le letterbox et le décalage
|
||||
* du zoom-molette quand la fenêtre change de taille (la caméra reste où elle est).
|
||||
*/
|
||||
private applyAspect(): void {
|
||||
const rect = this.svgEl?.nativeElement.getBoundingClientRect();
|
||||
if (!rect || rect.width === 0 || rect.height === 0) return;
|
||||
const cy = this.viewY + this.viewH / 2;
|
||||
const newH = this.viewW / (rect.width / rect.height);
|
||||
this.viewH = newH;
|
||||
this.viewY = cy - newH / 2;
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onWindowResize(): void {
|
||||
this.applyAspect();
|
||||
}
|
||||
|
||||
/** Pointerdown sur le FOND du SVG : désélectionne un lien et démarre le pan. */
|
||||
onSvgPointerDown(evt: PointerEvent): void {
|
||||
if (evt.button !== 0) return;
|
||||
evt.preventDefault();
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
this.draggingId = node.id;
|
||||
this.dragOffsetX = x - node.x;
|
||||
this.dragOffsetY = y - node.y;
|
||||
this.dragMoved = false;
|
||||
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||
this.selectedEdgeKey = null; // clic dans le vide → ferme l'éditeur de lien
|
||||
if (this.linkingFromId || this.draggingId || this.draggingLabelKey) return;
|
||||
this.panning = true;
|
||||
this.panStartX = evt.clientX;
|
||||
this.panStartY = evt.clientY;
|
||||
this.panStartViewX = this.viewX;
|
||||
this.panStartViewY = this.viewY;
|
||||
(evt.currentTarget as Element).setPointerCapture?.(evt.pointerId);
|
||||
}
|
||||
|
||||
onPointerMove(evt: PointerEvent): void {
|
||||
// Pan (fond) : delta écran → unités viewBox selon l'échelle courante.
|
||||
if (this.panning) {
|
||||
const rect = this.svgEl?.nativeElement.getBoundingClientRect();
|
||||
if (rect && rect.width > 0 && rect.height > 0) {
|
||||
this.viewX = this.panStartViewX - (evt.clientX - this.panStartX) * (this.viewW / rect.width);
|
||||
this.viewY = this.panStartViewY - (evt.clientY - this.panStartY) * (this.viewH / rect.height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
|
||||
if (this.linkingFromId) {
|
||||
this.linkPointerX = x;
|
||||
this.linkPointerY = y;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.draggingLabelKey) {
|
||||
const edge = this.edges.find(e => e.key === this.draggingLabelKey);
|
||||
if (!edge) return;
|
||||
@@ -257,8 +395,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
const newY = y - this.dragOffsetY;
|
||||
if (!this.dragMoved && Math.hypot(newX - edge.labelX, newY - edge.labelY) < this.DRAG_THRESHOLD) return;
|
||||
this.dragMoved = true;
|
||||
// Recalcule la position automatique courante puis stocke la difference,
|
||||
// pour que l'offset reste valable meme apres deplacement d'un noeud.
|
||||
const auto = this.autoLabelPosition(edge.key);
|
||||
if (auto) {
|
||||
this.labelOffsets.set(edge.key, { dx: newX - auto.x, dy: newY - auto.y });
|
||||
@@ -271,11 +407,10 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
if (!this.draggingId) return;
|
||||
const node = this.nodes.find(n => n.id === this.draggingId);
|
||||
if (!node) return;
|
||||
// Empeche le noeud de partir en coordonnees negatives : sinon il sort
|
||||
// du viewport SVG et se fait clipper par le navigateur (le SVG a
|
||||
// overflow: hidden par defaut quand on lui donne width/height explicites).
|
||||
const newX = Math.max(0, x - this.dragOffsetX);
|
||||
const newY = Math.max(0, y - this.dragOffsetY);
|
||||
// Avec une caméra viewBox, plus besoin de brider les coordonnées à 0 :
|
||||
// le pan/zoom permet d'atteindre n'importe quelle zone.
|
||||
const newX = x - this.dragOffsetX;
|
||||
const newY = y - this.dragOffsetY;
|
||||
if (!this.dragMoved) {
|
||||
const dx = newX - node.x;
|
||||
const dy = newY - node.y;
|
||||
@@ -285,13 +420,8 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
node.x = newX;
|
||||
node.y = newY;
|
||||
this.recomputeEdges();
|
||||
this.fitSvgToNodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcule la position "auto" (sans offset manuel) du label d'une arete
|
||||
* a partir de sa cle. Utilise pour deriver le delta a stocker pendant le drag.
|
||||
*/
|
||||
private autoLabelPosition(key: string): { x: number; y: number } | null {
|
||||
const [sourceId, targetId, idxStr] = key.split('|');
|
||||
const idx = Number(idxStr);
|
||||
@@ -311,10 +441,20 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t - 4 };
|
||||
}
|
||||
|
||||
onPointerDown(evt: PointerEvent, node: GraphNode): void {
|
||||
if (evt.button !== 0) return;
|
||||
evt.stopPropagation(); // évite de démarrer un pan du fond
|
||||
evt.preventDefault();
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
this.draggingId = node.id;
|
||||
this.dragOffsetX = x - node.x;
|
||||
this.dragOffsetY = y - node.y;
|
||||
this.dragMoved = false;
|
||||
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||
}
|
||||
|
||||
onLabelPointerDown(evt: PointerEvent, edge: GraphEdge): void {
|
||||
if (evt.button !== 0) return;
|
||||
// Empeche l'event de remonter au <g class="node"> ou au svg, sinon on
|
||||
// declencherait aussi un drag de noeud.
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
@@ -325,24 +465,22 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrandit le SVG si un noeud s'approche du bord droit ou bas, pour eviter
|
||||
* que le contenu deplace soit rogne. On ne reduit jamais en-dessous de la
|
||||
* taille initiale du layout pour rester stable visuellement.
|
||||
*/
|
||||
private fitSvgToNodes(): void {
|
||||
const margin = 40;
|
||||
let maxX = 600;
|
||||
let maxY = 200;
|
||||
for (const n of this.nodes) {
|
||||
if (n.x + this.NODE_WIDTH + margin > maxX) maxX = n.x + this.NODE_WIDTH + margin;
|
||||
if (n.y + this.NODE_HEIGHT + margin > maxY) maxY = n.y + this.NODE_HEIGHT + margin;
|
||||
}
|
||||
if (maxX > this.svgWidth) this.svgWidth = maxX;
|
||||
if (maxY > this.svgHeight) this.svgHeight = maxY;
|
||||
}
|
||||
|
||||
onPointerUp(evt: PointerEvent): void {
|
||||
if (this.panning) {
|
||||
this.panning = false;
|
||||
// Le pan capture le pointeur sur le SVG (currentTarget), pas sur une cible enfant.
|
||||
(evt.currentTarget as Element).releasePointerCapture?.(evt.pointerId);
|
||||
return;
|
||||
}
|
||||
if (this.linkingFromId) {
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
const target = this.nodeAt(x, y);
|
||||
const fromId = this.linkingFromId;
|
||||
this.linkingFromId = null;
|
||||
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
|
||||
if (target && target.id !== fromId) this.createLink(fromId, target.id);
|
||||
return;
|
||||
}
|
||||
if (this.draggingLabelKey) {
|
||||
this.draggingLabelKey = null;
|
||||
this.dragMoved = false;
|
||||
@@ -355,8 +493,19 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
this.draggingId = null;
|
||||
this.dragMoved = false;
|
||||
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
|
||||
// Si le pointeur n'a pas reellement bouge, on traite comme un clic d'ouverture.
|
||||
if (!moved) this.openScene(id);
|
||||
// Pointeur immobile → clic d'ouverture ; sinon on persiste la position (Niveau 2).
|
||||
if (!moved) { this.openScene(id); return; }
|
||||
this.persistNodePosition(id);
|
||||
}
|
||||
|
||||
private persistNodePosition(nodeId: string): void {
|
||||
const node = this.nodes.find(n => n.id === nodeId);
|
||||
const scene = this.scenes.find(s => s.id === nodeId);
|
||||
if (!node || !scene) return;
|
||||
scene.graphX = node.x;
|
||||
scene.graphY = node.y;
|
||||
this.campaignService.updateScene(nodeId, { ...scene, order: scene.order ?? 0 })
|
||||
.subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
private truncate(text: string): string {
|
||||
@@ -365,18 +514,203 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
: text;
|
||||
}
|
||||
|
||||
/** Couleur d'une arête selon son type de lien (Niveau 2). */
|
||||
edgeColor(kind: LinkType): string {
|
||||
switch (kind) {
|
||||
case 'CLUE': return '#f0b429'; // indice — ambre
|
||||
case 'LEAD': return '#34d399'; // piste — vert
|
||||
default: return '#b8c0cc'; // sortie — gris neutre (historique)
|
||||
}
|
||||
}
|
||||
|
||||
openScene(sceneId: string): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', sceneId]);
|
||||
}
|
||||
|
||||
back(): void {
|
||||
if (this.isFlatMode) {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
|
||||
}
|
||||
|
||||
// ─────────────── Édition dans le graphe (Niveau 2) ───────────────
|
||||
|
||||
/** Crée une nouvelle scène dans ce chapitre et recharge la carte. */
|
||||
addScene(): void {
|
||||
const payload: SceneCreate = {
|
||||
name: this.translate.instant('chapterGraph.newSceneName'),
|
||||
chapterId: this.chapterId,
|
||||
order: this.scenes.length
|
||||
};
|
||||
this.campaignService.createScene(payload).subscribe({
|
||||
next: () => this.load(),
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le renommage inline d'une scène : positionne un champ HTML par-dessus le
|
||||
* nœud (conversion coords SVG → écran, figée le temps de l'édition) et le focus.
|
||||
*/
|
||||
startRenameNode(evt: PointerEvent, node: GraphNode): void {
|
||||
if (evt.button !== 0) return;
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const svg = this.svgEl?.nativeElement;
|
||||
const container = svg?.parentElement;
|
||||
const ctm = svg?.getScreenCTM();
|
||||
if (svg && container && ctm) {
|
||||
const p1 = svg.createSVGPoint(); p1.x = node.x; p1.y = node.y;
|
||||
const p2 = svg.createSVGPoint(); p2.x = node.x + this.NODE_WIDTH; p2.y = node.y + this.NODE_HEIGHT;
|
||||
const s1 = p1.matrixTransform(ctm);
|
||||
const s2 = p2.matrixTransform(ctm);
|
||||
const crect = container.getBoundingClientRect();
|
||||
this.renameBox = { left: s1.x - crect.left, top: s1.y - crect.top, width: s2.x - s1.x, height: s2.y - s1.y };
|
||||
}
|
||||
this.editingNodeId = node.id;
|
||||
this.editNodeName = node.name;
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById('node-rename-input') as HTMLInputElement | null;
|
||||
el?.focus();
|
||||
el?.select();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Valide le renommage : met à jour l'affichage localement et persiste (name seul). */
|
||||
saveRename(): void {
|
||||
const id = this.editingNodeId;
|
||||
if (!id) return;
|
||||
this.editingNodeId = null; // avant tout : évite un double-save via le (blur)
|
||||
const name = this.editNodeName.trim();
|
||||
const node = this.nodes.find(n => n.id === id);
|
||||
const scene = this.scenes.find(s => s.id === id);
|
||||
if (!name || !node || !scene || name === scene.name) return;
|
||||
node.name = name;
|
||||
node.displayName = this.truncate(name);
|
||||
scene.name = name;
|
||||
this.campaignService.updateScene(id, { ...scene, order: scene.order ?? 0 }).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
cancelRename(): void {
|
||||
this.editingNodeId = null;
|
||||
}
|
||||
|
||||
/** Début d'un lien tiré depuis la pastille d'un nœud (sans déclencher son drag). */
|
||||
onConnectStart(evt: PointerEvent, node: GraphNode): void {
|
||||
if (evt.button !== 0) return;
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
this.linkingFromId = node.id;
|
||||
this.linkPointerX = x;
|
||||
this.linkPointerY = y;
|
||||
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||
}
|
||||
|
||||
linkSourceX(): number {
|
||||
const n = this.nodes.find(x => x.id === this.linkingFromId);
|
||||
return n ? n.x + this.NODE_WIDTH / 2 : 0;
|
||||
}
|
||||
linkSourceY(): number {
|
||||
const n = this.nodes.find(x => x.id === this.linkingFromId);
|
||||
return n ? n.y + this.NODE_HEIGHT : 0;
|
||||
}
|
||||
|
||||
/** Nœud dont la boîte contient le point (coords SVG), ou null. */
|
||||
private nodeAt(x: number, y: number): GraphNode | null {
|
||||
return this.nodes.find(n =>
|
||||
x >= n.x && x <= n.x + this.NODE_WIDTH &&
|
||||
y >= n.y && y <= n.y + this.NODE_HEIGHT) ?? null;
|
||||
}
|
||||
|
||||
/** Crée une branche (SORTIE) de fromId vers toId si pas déjà liée, puis sauvegarde. */
|
||||
private createLink(fromId: string, toId: string): void {
|
||||
const scene = this.scenes.find(s => s.id === fromId);
|
||||
if (!scene) return;
|
||||
const existing = scene.branches ?? [];
|
||||
if (existing.some(b => b.targetSceneId === toId)) return;
|
||||
const next: SceneBranch[] = [...existing, { label: '', targetSceneId: toId, condition: '', kind: 'EXIT' }];
|
||||
this.campaignService.updateScene(fromId, { ...scene, order: scene.order ?? 0, branches: next })
|
||||
.subscribe({ next: () => this.load(), error: () => {} });
|
||||
}
|
||||
|
||||
// ─────────────── Sélection / édition / suppression d'un lien ───────────────
|
||||
|
||||
get selectedEdge(): GraphEdge | null {
|
||||
return this.edges.find(e => e.key === this.selectedEdgeKey) ?? null;
|
||||
}
|
||||
|
||||
/** Noms des extrémités du lien sélectionné (pour l'en-tête de l'éditeur). */
|
||||
get selectedEdgeEndpoints(): { from: string; to: string } | null {
|
||||
if (!this.selectedEdgeKey) return null;
|
||||
const [sourceId, targetId] = this.selectedEdgeKey.split('|');
|
||||
const from = this.nodes.find(n => n.id === sourceId)?.name ?? '?';
|
||||
const to = this.nodes.find(n => n.id === targetId)?.name ?? '?';
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/**
|
||||
* Localise la branche EXACTE désignée par une clé d'arête (`sourceId|targetId|idx`).
|
||||
* L'idx est la position parmi les branches dont la cible est un nœud affiché — ce qui
|
||||
* cible le bon lien même si deux branches pointent la même scène (data legacy).
|
||||
*/
|
||||
private branchForKey(key: string): { scene: Scene; branchIndex: number } | null {
|
||||
const [sourceId, , idxStr] = key.split('|');
|
||||
const idx = Number(idxStr);
|
||||
const scene = this.scenes.find(s => s.id === sourceId);
|
||||
if (!scene?.branches) return null;
|
||||
const siblings = scene.branches.filter(b => this.nodes.some(n => n.id === b.targetSceneId));
|
||||
const branch = siblings[idx];
|
||||
if (!branch) return null;
|
||||
const branchIndex = scene.branches.indexOf(branch);
|
||||
return branchIndex >= 0 ? { scene, branchIndex } : null;
|
||||
}
|
||||
|
||||
/** Sélectionne un lien au clic et pré-remplit l'éditeur depuis sa branche. */
|
||||
onEdgeSelect(evt: PointerEvent, edge: GraphEdge): void {
|
||||
if (evt.button !== 0) return;
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
this.selectedEdgeKey = edge.key;
|
||||
const found = this.branchForKey(edge.key);
|
||||
const branch = found ? found.scene.branches![found.branchIndex] : undefined;
|
||||
this.editEdgeLabel = branch?.label ?? '';
|
||||
this.editEdgeCondition = branch?.condition ?? '';
|
||||
this.editEdgeKind = branch?.kind ?? 'EXIT';
|
||||
}
|
||||
|
||||
closeEdgeEditor(): void {
|
||||
this.selectedEdgeKey = null;
|
||||
}
|
||||
|
||||
/** Enregistre le libellé / la condition / le type du lien sélectionné. */
|
||||
saveEdge(): void {
|
||||
if (!this.selectedEdgeKey) return;
|
||||
const found = this.branchForKey(this.selectedEdgeKey);
|
||||
if (!found) return;
|
||||
const { scene, branchIndex } = found;
|
||||
const branches = (scene.branches ?? []).map((b, i) =>
|
||||
i === branchIndex
|
||||
? { ...b, label: this.editEdgeLabel.trim(), condition: this.editEdgeCondition.trim(), kind: this.editEdgeKind }
|
||||
: b);
|
||||
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
|
||||
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} });
|
||||
}
|
||||
|
||||
/** Supprime (sépare) le lien sélectionné. */
|
||||
deleteEdge(): void {
|
||||
if (!this.selectedEdgeKey) return;
|
||||
const found = this.branchForKey(this.selectedEdgeKey);
|
||||
if (!found) return;
|
||||
const { scene, branchIndex } = found;
|
||||
const branches = (scene.branches ?? []).filter((_, i) => i !== branchIndex);
|
||||
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
|
||||
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} });
|
||||
}
|
||||
|
||||
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.
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,7 @@
|
||||
{{ chapter.name }}
|
||||
</h1>
|
||||
<p class="view-subtitle">
|
||||
{{ (parentArc?.type === 'HUB' ? 'chapterView.questHub' : 'chapterView.chapter') | translate }}
|
||||
@if ((chapter.prerequisites?.length ?? 0) > 0) {
|
||||
<span class="cond-badge"
|
||||
[title]="'chapterView.conditionalBadgeTitle' | translate">
|
||||
<lucide-icon [img]="Lock" [size]="12"></lucide-icon>
|
||||
{{ 'chapterView.conditional' | translate }}
|
||||
</span>
|
||||
}
|
||||
{{ 'chapterView.chapter' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
@@ -35,29 +28,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête)
|
||||
OU dès qu'un chapitre linéaire porte des conditions. -->
|
||||
@if (parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> {{ 'chapterView.unlockConditions' | translate }}</h2>
|
||||
@if ((chapter.prerequisites?.length ?? 0) > 0) {
|
||||
<ul class="view-section-body">
|
||||
@for (p of chapter.prerequisites; track p) {
|
||||
<li>{{ describePrerequisite(p) }}</li>
|
||||
}
|
||||
</ul>
|
||||
<small class="view-section-empty">
|
||||
{{ (parentArc?.type === 'HUB' ? 'chapterView.unlockHintQuest' : 'chapterView.unlockHintChapter') | translate }}
|
||||
</small>
|
||||
} @else {
|
||||
<p class="view-section-empty">
|
||||
{{ 'chapterView.noConditionQuest' | translate }}
|
||||
</p>
|
||||
<p class="view-section-empty" [innerHTML]="'chapterView.noConditionEdit' | translate">
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
<!-- Illustrations (rendu editorial magazine) -->
|
||||
@if ((chapter.illustrationImageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angular';
|
||||
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
@@ -13,7 +13,7 @@ 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, Prerequisite, Arc } from '../../../services/campaign.model';
|
||||
import { Chapter } 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';
|
||||
@@ -33,18 +33,15 @@ 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<string, Chapter> = {};
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -94,30 +91,10 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
this.availablePages = pages;
|
||||
this.pageTitleService.set(chapter.name);
|
||||
|
||||
// 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
|
||||
?? this.translate.instant('chapterView.deletedPage');
|
||||
|
||||
54
web/src/app/campaigns/gap-action.helper.ts
Normal file
54
web/src/app/campaigns/gap-action.helper.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ReadinessGap } from '../services/readiness.model';
|
||||
|
||||
/**
|
||||
* Action de correction d'un manque de guidage : lien profond + query params.
|
||||
*
|
||||
* Le bouton « Corriger » ne se contente plus de NAVIGUER : selon la règle, il ouvre
|
||||
* directement le BON OUTIL sur la page cible —
|
||||
* - chapitre vide → l'éditeur du chapitre avec le panneau « Générer des scènes » déployé ;
|
||||
* - manques de scène → l'ÉDITEUR de la scène avec la section fautive dépliée
|
||||
* (combat / branches / lieu explorable) ;
|
||||
* - manques de quête (nœud mort, prérequis cassé, sans nœud) → l'éditeur de la quête.
|
||||
*/
|
||||
export interface GapAction {
|
||||
link: string[];
|
||||
queryParams?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function gapAction(gap: ReadinessGap, campaignId: string): GapAction {
|
||||
const base = ['/campaigns', campaignId];
|
||||
switch (gap.entityType) {
|
||||
case 'SCENE': {
|
||||
const link = [...base, 'arcs', gap.arcId ?? '', 'chapters', gap.chapterId ?? '', 'scenes', gap.entityId, 'edit'];
|
||||
const focus = sceneFocusFor(gap.ruleId);
|
||||
return { link, queryParams: focus ? { focus } : undefined };
|
||||
}
|
||||
case 'CHAPTER':
|
||||
if (gap.ruleId === 'CHAP-001-NO-SCENE') {
|
||||
return {
|
||||
link: [...base, 'arcs', gap.arcId ?? '', 'chapters', gap.entityId, 'edit'],
|
||||
queryParams: { assist: 'draft-scenes' }
|
||||
};
|
||||
}
|
||||
return { link: [...base, 'arcs', gap.arcId ?? '', 'chapters', gap.entityId] };
|
||||
case 'ARC':
|
||||
return { link: [...base, 'arcs', gap.entityId] };
|
||||
case 'QUEST':
|
||||
// Nœuds et prérequis s'éditent sur quest-edit — pas sur la fiche de lecture.
|
||||
return { link: [...base, 'quests', gap.entityId, 'edit'] };
|
||||
case 'NPC':
|
||||
return { link: [...base, 'npcs', gap.entityId] };
|
||||
case 'ENEMY':
|
||||
return { link: [...base, 'enemies', gap.entityId] };
|
||||
default:
|
||||
return { link: base };
|
||||
}
|
||||
}
|
||||
|
||||
/** Section de l'éditeur de scène à déplier selon la règle en défaut. */
|
||||
function sceneFocusFor(ruleId: string): string | undefined {
|
||||
if (ruleId.startsWith('SCENE-011') || ruleId.startsWith('SCENE-012')) return 'combat';
|
||||
if (ruleId.startsWith('SCENE-010')) return 'branches';
|
||||
if (ruleId.startsWith('SCENE-041') || ruleId.startsWith('SCENE-042')) return 'rooms';
|
||||
return undefined;
|
||||
}
|
||||
@@ -84,7 +84,11 @@ export class NotebookDetailComponent implements OnInit {
|
||||
private loadTree(): void {
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, undefined, this.enemyService)
|
||||
.subscribe({
|
||||
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
|
||||
next: (data) => {
|
||||
// Pas d'envoi de contenu généré vers l'arc SYSTEM (plomberie des quêtes libres).
|
||||
this.arcs = data.arcs.filter(a => a.type !== 'SYSTEM');
|
||||
this.chaptersByArc = data.chaptersByArc;
|
||||
},
|
||||
error: () => { /* cibles indisponibles : les cartes le signaleront */ }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +40,16 @@
|
||||
{{ 'playthroughDetail.facts' | translate }}
|
||||
</button>
|
||||
</section>
|
||||
<!-- Préparer la prochaine séance (Phase 3 co-MJ) : position + contenu probable + manques ciblés -->
|
||||
<section class="block">
|
||||
<h2>{{ 'playthroughDetail.prepTitle' | translate }}</h2>
|
||||
<app-session-prep-panel [playthroughId]="playthroughId" [campaignId]="campaignId"></app-session-prep-panel>
|
||||
</section>
|
||||
<!-- Horloges de progression (état émergent de la partie) -->
|
||||
<section class="block">
|
||||
<h2>{{ 'playthroughDetail.clocksTitle' | translate }}</h2>
|
||||
<app-clocks-manager [playthroughId]="playthroughId" [campaignId]="campaignId"></app-clocks-manager>
|
||||
</section>
|
||||
<!-- PJ -->
|
||||
<section class="block">
|
||||
<div class="block-header">
|
||||
|
||||
@@ -19,6 +19,8 @@ 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';
|
||||
import { ClocksManagerComponent } from '../../../shared/clocks-manager/clocks-manager.component';
|
||||
import { SessionPrepPanelComponent } from '../../../shared/session-prep-panel/session-prep-panel.component';
|
||||
|
||||
/**
|
||||
* Vue détail d'une Partie (Playthrough).
|
||||
@@ -27,7 +29,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-playthrough-detail',
|
||||
imports: [RouterModule, LucideAngularModule, TranslatePipe],
|
||||
imports: [RouterModule, LucideAngularModule, TranslatePipe, ClocksManagerComponent, SessionPrepPanelComponent],
|
||||
templateUrl: './playthrough-detail.component.html',
|
||||
styleUrls: ['./playthrough-detail.component.scss']
|
||||
})
|
||||
|
||||
141
web/src/app/campaigns/quest/quest-edit/quest-edit.component.html
Normal file
141
web/src/app/campaigns/quest/quest-edit/quest-edit.component.html
Normal file
@@ -0,0 +1,141 @@
|
||||
<div class="edit-page">
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ quest?.name || (isCreate ? ('questEdit.newTitle' | translate) : ('questEdit.fallbackTitle' | translate)) }}</h1>
|
||||
<p class="subtitle">{{ 'questEdit.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
|
||||
@if (!isCreate) {
|
||||
<button type="button" class="btn-danger" (click)="delete()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
|
||||
{{ (isCreate ? 'common.create' : 'common.save') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<p class="optional-hint">{{ 'questEdit.optionalHint' | translate }}</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="quest-name">{{ 'questEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
id="quest-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
[placeholder]="'questEdit.namePlaceholder' | translate"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="quest-description">{{ 'questEdit.descriptionLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="quest-description"
|
||||
formControlName="description"
|
||||
[placeholder]="'questEdit.descriptionPlaceholder' | translate"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ 'questEdit.iconLabel' | translate }}</label>
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<app-expandable-section [title]="'questEdit.illustrationsLabel' | translate" icon="🖼️" [filled]="illustrationsFilled">
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">{{ 'questEdit.illustrationsHint' | translate }}</small>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Cœur de la quête : conditions de déblocage -->
|
||||
<app-expandable-section [title]="'questEdit.unlockSectionTitle' | translate" icon="🔓" [initiallyOpen]="true" [filled]="prereqFilled">
|
||||
<small class="field-hint">{{ 'questEdit.unlockHint' | translate }}</small>
|
||||
<app-prerequisite-editor
|
||||
[prerequisites]="prerequisites"
|
||||
[availableQuests]="availableQuests"
|
||||
[availableFlags]="availableFlagNames"
|
||||
(prerequisitesChange)="onPrerequisitesChange($event)">
|
||||
</app-prerequisite-editor>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Nœuds narratifs traversés — quêtes TRANSVERSES uniquement : une quête d'arc HUB
|
||||
a son conteneur de scènes automatique, inutile d'exposer cette plomberie. -->
|
||||
@if (!isAttached) {
|
||||
<app-expandable-section [title]="'questEdit.nodesSectionTitle' | translate" icon="📍" [filled]="nodesFilled">
|
||||
<small class="field-hint">{{ 'questEdit.nodesHint' | translate }}</small>
|
||||
<app-node-picker
|
||||
[nodes]="nodes"
|
||||
[chapters]="chapters"
|
||||
[scenes]="scenes"
|
||||
(nodesChange)="nodes = $event">
|
||||
</app-node-picker>
|
||||
</app-expandable-section>
|
||||
}
|
||||
|
||||
<!-- Notes du MJ (privé) -->
|
||||
<app-expandable-section [title]="'questEdit.gmNotesSectionTitle' | translate" icon="🔒" variant="private" [filled]="gmNotesFilled">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="gmNotes"
|
||||
[placeholder]="'questEdit.gmNotesPlaceholder' | translate"
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Objectifs et enjeux -->
|
||||
<app-expandable-section [title]="'questEdit.objectivesSectionTitle' | translate" icon="🎯" [filled]="objectivesFilled">
|
||||
<div class="field">
|
||||
<label for="quest-objectives">{{ 'questEdit.playerObjectivesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="quest-objectives"
|
||||
formControlName="playerObjectives"
|
||||
[placeholder]="'questEdit.playerObjectivesPlaceholder' | translate"
|
||||
rows="3">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="quest-stakes">{{ 'questEdit.narrativeStakesLabel' | translate }}</label>
|
||||
<textarea
|
||||
id="quest-stakes"
|
||||
formControlName="narrativeStakes"
|
||||
[placeholder]="'questEdit.narrativeStakesPlaceholder' | translate"
|
||||
rows="3">
|
||||
</textarea>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Pages Lore associées (cross-context) -->
|
||||
@if (loreId) {
|
||||
<app-expandable-section [title]="'questEdit.relatedPagesLabel' | translate" icon="🔗" [filled]="loreFilled">
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="availablePages"
|
||||
[loreId]="loreId"
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<small class="field-hint">{{ 'questEdit.relatedPagesHint' | translate }}</small>
|
||||
</app-expandable-section>
|
||||
}
|
||||
|
||||
@if (!loreId) {
|
||||
<div class="field">
|
||||
<small class="field-hint">{{ 'questEdit.noLoreHint' | translate }}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
.edit-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Bandeau « tout est optionnel sauf le titre ».
|
||||
.optional-hint {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: rgba(108, 99, 255, 0.08);
|
||||
border: 1px solid rgba(108, 99, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
color: #a5b4fc;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
235
web/src/app/campaigns/quest/quest-edit/quest-edit.component.ts
Normal file
235
web/src/app/campaigns/quest/quest-edit/quest-edit.component.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
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 } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { QuestService } from '../../../services/quest.service';
|
||||
import { CampaignFlagService } from '../../../services/campaign-flag.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 { Quest, QuestCreate, Prerequisite, QuestNodeRef, Chapter, Scene } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
||||
import { PrerequisiteEditorComponent } from '../../../shared/prerequisite-editor/prerequisite-editor.component';
|
||||
import { NodePickerComponent } from '../../../shared/node-picker/node-picker.component';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Création / édition d'une Quête (Niveau 1). Un seul composant pour les routes :
|
||||
* - /campaigns/:campaignId/quests/create (questId absent → mode création)
|
||||
* - /campaigns/:campaignId/quests/:questId/edit (mode édition)
|
||||
*
|
||||
* Identité + prérequis (cœur d'une quête) + champs narratifs + illustrations,
|
||||
* pages Lore liées et nœuds narratifs traversés (chapitres / scènes).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-quest-edit',
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent, ExpandableSectionComponent, PrerequisiteEditorComponent, NodePickerComponent, LoreLinkPickerComponent, ImageGalleryComponent, TranslatePipe],
|
||||
templateUrl: './quest-edit.component.html',
|
||||
styleUrls: ['./quest-edit.component.scss']
|
||||
})
|
||||
export class QuestEditComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
selectedIcon: string | null = null;
|
||||
|
||||
form: FormGroup;
|
||||
campaignId = '';
|
||||
questId: string | null = null; // null = mode création
|
||||
quest: Quest | null = null;
|
||||
/** Arc de rattachement : query param à la création (arc HUB), conservé à l'édition. */
|
||||
arcIdParam: string | null = null;
|
||||
|
||||
prerequisites: Prerequisite[] = [];
|
||||
/** Autres quêtes de la campagne (cibles candidates pour QUEST_COMPLETED). */
|
||||
availableQuests: Quest[] = [];
|
||||
availableFlagNames: string[] = [];
|
||||
|
||||
/** Nœuds narratifs (chapitres / scènes) traversés par la quête. */
|
||||
nodes: QuestNodeRef[] = [];
|
||||
/**
|
||||
* Nœuds de PLOMBERIE (conteneur de scènes en arc SYSTEM) : jamais montrés dans le
|
||||
* sélecteur, ré-injectés tels quels à la sauvegarde pour ne pas casser le lien.
|
||||
*/
|
||||
private containerNodes: QuestNodeRef[] = [];
|
||||
/** Chapitres / scènes de la campagne (alimentent le node-picker). */
|
||||
chapters: Chapter[] = [];
|
||||
scenes: Scene[] = [];
|
||||
|
||||
illustrationImageIds: string[] = [];
|
||||
relatedPageIds: string[] = [];
|
||||
loreId: string | null = null;
|
||||
availablePages: Page[] = [];
|
||||
|
||||
get isCreate(): boolean { return this.questId === null; }
|
||||
/**
|
||||
* Quête rattachée à un arc HUB : son CONTENEUR de scènes est géré automatiquement
|
||||
* (créé avec la quête, fusionné dans l'arbre) → le sélecteur de nœuds manuels est
|
||||
* masqué pour ne pas exposer cette plomberie. Il ne sert qu'aux quêtes TRANSVERSES.
|
||||
*/
|
||||
get isAttached(): boolean {
|
||||
return !!(this.questId ? this.quest?.arcId : this.arcIdParam);
|
||||
}
|
||||
get prereqFilled(): boolean { return this.prerequisites.length > 0; }
|
||||
get gmNotesFilled(): boolean { return !!this.form.value.gmNotes; }
|
||||
get objectivesFilled(): boolean {
|
||||
const v = this.form.value;
|
||||
return !!(v.playerObjectives || v.narrativeStakes);
|
||||
}
|
||||
get nodesFilled(): boolean { return this.nodes.length > 0; }
|
||||
get illustrationsFilled(): boolean { return this.illustrationImageIds.length > 0; }
|
||||
get loreFilled(): boolean { return this.relatedPageIds.length > 0; }
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private questService: QuestService,
|
||||
private campaignFlagService: CampaignFlagService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
gmNotes: [''],
|
||||
playerObjectives: [''],
|
||||
narrativeStakes: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.campaignId = pm.get('campaignId')!;
|
||||
this.questId = pm.get('questId'); // null sur la route /create
|
||||
this.arcIdParam = this.route.snapshot.queryParamMap.get('arcId'); // ?arcId= depuis un arc HUB
|
||||
this.loadAll();
|
||||
});
|
||||
}
|
||||
|
||||
private loadAll(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
quests: this.questService.getByCampaign(this.campaignId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
|
||||
})
|
||||
).subscribe(({ campaign, allCampaigns, quests, treeData, pages, loreId }) => {
|
||||
this.loreId = loreId;
|
||||
this.availablePages = pages;
|
||||
// L'arc SYSTEM (« Quêtes libres ») est de la plomberie : ses chapitres-conteneurs
|
||||
// et leurs scènes ne sont pas des cibles de lien pour le node-picker.
|
||||
const systemArcIds = new Set((treeData.arcs ?? []).filter(a => a.type === 'SYSTEM').map(a => a.id));
|
||||
const containerChapterIds = new Set(Object.values(treeData.chaptersByArc).flat()
|
||||
.filter(ch => systemArcIds.has(ch.arcId)).map(ch => ch.id));
|
||||
this.chapters = Object.values(treeData.chaptersByArc).flat()
|
||||
.filter(ch => !systemArcIds.has(ch.arcId));
|
||||
this.scenes = Object.values(treeData.scenesByChapter).flat()
|
||||
.filter(sc => !containerChapterIds.has(sc.chapterId));
|
||||
|
||||
this.availableQuests = quests.filter(q => q.id !== this.questId);
|
||||
const current = this.questId ? quests.find(q => q.id === this.questId) ?? null : null;
|
||||
this.quest = current;
|
||||
if (current) {
|
||||
this.selectedIcon = current.icon ?? null;
|
||||
this.prerequisites = [...(current.prerequisites ?? [])];
|
||||
const allNodes = current.nodes ?? [];
|
||||
this.containerNodes = allNodes.filter(n => n.nodeType === 'CHAPTER' && containerChapterIds.has(n.nodeId));
|
||||
this.nodes = allNodes.filter(n => !this.containerNodes.includes(n));
|
||||
this.relatedPageIds = [...(current.relatedPageIds ?? [])];
|
||||
this.illustrationImageIds = [...(current.illustrationImageIds ?? [])];
|
||||
this.pageTitleService.set(current.name);
|
||||
this.form.patchValue({
|
||||
name: current.name,
|
||||
description: current.description ?? '',
|
||||
gmNotes: current.gmNotes ?? '',
|
||||
playerObjectives: current.playerObjectives ?? '',
|
||||
narrativeStakes: current.narrativeStakes ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
this.campaignFlagService.listReferenced(this.campaignId).subscribe({
|
||||
next: names => this.availableFlagNames = names
|
||||
});
|
||||
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate));
|
||||
});
|
||||
}
|
||||
|
||||
onPrerequisitesChange(next: Prerequisite[]): void { this.prerequisites = next; }
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const payload: QuestCreate = {
|
||||
name: this.form.value.name,
|
||||
// Création : arcId du query param (arc HUB) ; édition : on conserve le rattachement existant.
|
||||
arcId: this.questId ? (this.quest?.arcId ?? null) : this.arcIdParam,
|
||||
description: this.form.value.description,
|
||||
icon: this.selectedIcon,
|
||||
order: this.quest?.order ?? this.availableQuests.length + 1,
|
||||
prerequisites: this.prerequisites,
|
||||
nodes: [...this.containerNodes, ...this.nodes],
|
||||
gmNotes: this.form.value.gmNotes,
|
||||
playerObjectives: this.form.value.playerObjectives,
|
||||
narrativeStakes: this.form.value.narrativeStakes,
|
||||
relatedPageIds: this.relatedPageIds,
|
||||
illustrationImageIds: this.illustrationImageIds
|
||||
};
|
||||
|
||||
const op$ = this.questId
|
||||
? this.questService.update(this.campaignId, this.questId, { ...payload, campaignId: this.campaignId } as Quest)
|
||||
: this.questService.create(this.campaignId, payload);
|
||||
|
||||
op$.subscribe({
|
||||
next: created => this.router.navigate(['/campaigns', this.campaignId, 'quests', this.questId ?? created.id]),
|
||||
error: () => console.error('Erreur lors de l\'enregistrement de la quête')
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!this.questId) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: this.translate.instant('questEdit.deleteTitle'),
|
||||
message: this.translate.instant('questEdit.deleteMessage', { name: this.quest?.name }),
|
||||
details: [this.translate.instant('questEdit.irreversible')],
|
||||
confirmLabel: this.translate.instant('common.delete'),
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok || !this.questId) return;
|
||||
this.questService.delete(this.campaignId, this.questId).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'quests']),
|
||||
error: () => console.error('Erreur lors de la suppression de la quête')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'quests']);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<div class="quest-page">
|
||||
<div class="quest-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-create" (click)="create()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> {{ 'questList.create' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="quest-header">
|
||||
<h1><lucide-icon [img]="Flag" [size]="22"></lucide-icon> {{ 'questList.title' | translate }}</h1>
|
||||
<p class="quest-hint">{{ 'questList.hint' | translate }}</p>
|
||||
</header>
|
||||
|
||||
@if (quests.length === 0) {
|
||||
<p class="quest-empty">{{ 'questList.empty' | translate }}</p>
|
||||
}
|
||||
|
||||
<div class="quest-grid">
|
||||
@for (q of quests; track q.id) {
|
||||
<button class="quest-card" (click)="open(q)">
|
||||
@if (q.icon) {
|
||||
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="20" class="quest-card-icon"></lucide-icon>
|
||||
} @else {
|
||||
<lucide-icon [img]="Flag" [size]="20" class="quest-card-icon"></lucide-icon>
|
||||
}
|
||||
<span class="quest-card-name">{{ q.name }}</span>
|
||||
@if ((q.prerequisites?.length ?? 0) > 0) {
|
||||
<span class="quest-card-cond" [title]="'questList.conditional' | translate">🔒</span>
|
||||
}
|
||||
<span class="quest-del" (click)="remove(q, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
.quest-page {
|
||||
padding: 1.75rem 1.5rem;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.quest-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.spacer { flex: 1; }
|
||||
}
|
||||
|
||||
.btn-back,
|
||||
.btn-create {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #374151;
|
||||
background: #1f2937;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
background: #6c63ff;
|
||||
border-color: #6c63ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quest-header {
|
||||
h1 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quest-hint {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quest-empty {
|
||||
color: #6b7280;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.quest-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.quest-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover { border-color: #6c63ff; }
|
||||
|
||||
.quest-card-icon { color: #a5b4fc; flex: 0 0 auto; }
|
||||
.quest-card-name { flex: 1; font-weight: 600; }
|
||||
.quest-card-cond { opacity: 0.85; }
|
||||
.quest-del {
|
||||
opacity: 0.6;
|
||||
display: inline-flex;
|
||||
&:hover { opacity: 1; color: #f87171; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Flag } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { QuestService } from '../../../services/quest.service';
|
||||
import { Quest } from '../../../services/campaign.model';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
import { DataSyncService } from '../../../services/data-sync.service';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Liste des quêtes d'une campagne (Niveau 1). Quêtes ORTHOGONALES à l'arbre.
|
||||
* Route : /campaigns/:campaignId/quests
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-quest-list',
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './quest-list.component.html',
|
||||
styleUrls: ['./quest-list.component.scss']
|
||||
})
|
||||
export class QuestListComponent implements OnInit {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Flag = Flag;
|
||||
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||
|
||||
campaignId = '';
|
||||
quests: Quest[] = [];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: QuestService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
|
||||
if (this.campaignId) {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
this.load();
|
||||
}
|
||||
this.dataSync.onChange(this.destroyRef, () => {
|
||||
this.load();
|
||||
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
|
||||
});
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.service.getByCampaign(this.campaignId).subscribe({
|
||||
// Seules les quêtes TRANSVERSES (non rattachées à un arc HUB) : celles d'un arc
|
||||
// apparaissent sous cet arc dans l'arbre / sur la fiche de l'arc.
|
||||
next: list => this.quests = [...list].filter(q => !q.arcId).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
error: () => this.quests = []
|
||||
});
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'quests', 'create']);
|
||||
}
|
||||
|
||||
open(q: Quest): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'quests', q.id]);
|
||||
}
|
||||
|
||||
remove(q: Quest, ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
this.confirmDialog.confirm({
|
||||
title: this.translate.instant('questList.deleteTitle'),
|
||||
message: this.translate.instant('questList.deleteMessage', { name: q.name }),
|
||||
details: [this.translate.instant('questList.irreversible')],
|
||||
confirmLabel: this.translate.instant('common.delete'),
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok || !q.id) return;
|
||||
this.service.delete(this.campaignId, q.id).subscribe(() => this.load());
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
}
|
||||
117
web/src/app/campaigns/quest/quest-view/quest-view.component.html
Normal file
117
web/src/app/campaigns/quest/quest-view/quest-view.component.html
Normal file
@@ -0,0 +1,117 @@
|
||||
@if (quest) {
|
||||
<div class="view-page">
|
||||
<header class="view-header">
|
||||
<div>
|
||||
<h1>
|
||||
@if (quest.icon) {
|
||||
<lucide-icon [img]="resolveCampaignIcon(quest.icon)" [size]="22" class="title-icon"></lucide-icon>
|
||||
}
|
||||
{{ quest.name }}
|
||||
</h1>
|
||||
<p class="view-subtitle">{{ 'questView.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
@if (containerChapter) {
|
||||
<button type="button" class="btn-secondary" (click)="openGraph()">
|
||||
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
|
||||
{{ 'questView.mapButton' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn-primary" (click)="editMode()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
{{ 'common.edit' | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteQuest()" [title]="'questView.deleteTitle' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Illustrations (rendu editorial magazine) -->
|
||||
@if ((quest.illustrationImageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<app-image-gallery [imageIds]="quest.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Description -->
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📜</span> {{ 'questView.synopsisTitle' | translate }}</h2>
|
||||
@if (quest.description?.trim()) {
|
||||
<p class="view-section-body">{{ quest.description }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">{{ 'questView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Objectifs / enjeux -->
|
||||
<div class="view-row">
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> {{ 'questView.playerObjectivesTitle' | translate }}</h2>
|
||||
@if (quest.playerObjectives?.trim()) {
|
||||
<p class="view-section-body">{{ quest.playerObjectives }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">{{ 'questView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⚡</span> {{ 'questView.narrativeStakesTitle' | translate }}</h2>
|
||||
@if (quest.narrativeStakes?.trim()) {
|
||||
<p class="view-section-body">{{ quest.narrativeStakes }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">{{ 'questView.notProvided' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Conditions de déblocage -->
|
||||
@if ((quest.prerequisites?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔓</span> {{ 'questView.prerequisitesTitle' | translate }}</h2>
|
||||
<div class="view-chips">
|
||||
@for (prereq of quest.prerequisites; track $index) {
|
||||
<span class="view-chip">{{ prereqLabel(prereq) }}</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Nœuds narratifs traversés -->
|
||||
@if (visibleNodes.length > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">📍</span> {{ 'questView.nodesTitle' | translate }}</h2>
|
||||
<div class="view-chips">
|
||||
@for (n of visibleNodes; track $index) {
|
||||
<span class="view-chip">
|
||||
<lucide-icon [img]="n.nodeType === 'SCENE' ? MapPin : BookOpen" [size]="13"></lucide-icon>
|
||||
{{ nodeLabel(n) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Notes du MJ (privé) -->
|
||||
@if (quest.gmNotes?.trim()) {
|
||||
<section class="view-section view-section--private">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> {{ 'questView.gmNotesTitle' | translate }}</h2>
|
||||
<p class="view-section-body">{{ quest.gmNotes }}</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Pages Lore liées -->
|
||||
@if (loreId && (quest.relatedPageIds?.length ?? 0) > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> {{ 'questView.relatedPagesTitle' | translate }}</h2>
|
||||
<div class="view-chips">
|
||||
@for (relId of quest.relatedPageIds; track relId) {
|
||||
<a class="view-chip" [routerLink]="['/lore', loreId, 'pages', relId]">
|
||||
{{ titleOfRelated(relId) }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// La mise en page réutilise les classes partagées de styles/_view.scss
|
||||
// (.view-page, .view-header, .view-section, .view-chips, .view-chip…).
|
||||
// Les chips de prérequis / nœuds sont des <span class="view-chip"> statiques
|
||||
// (pas de :host hover, pas d'href) — aucun style spécifique requis ici.
|
||||
:host { display: block; }
|
||||
204
web/src/app/campaigns/quest/quest-view/quest-view.component.ts
Normal file
204
web/src/app/campaigns/quest/quest-view/quest-view.component.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil, Trash2, BookOpen, MapPin, Network } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { QuestService } from '../../../services/quest.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 { Quest, Prerequisite, QuestNodeRef, Chapter, Scene } 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';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'une Quête (lecture seule, Niveau 1).
|
||||
* Route : /campaigns/:campaignId/quests/:questId
|
||||
*
|
||||
* Les quêtes sont ORTHOGONALES à l'arbre Arc→Chapitre→Scène. La vue résout les
|
||||
* références faibles (prérequis, nœuds CHAPTER/SCENE, pages Lore) en libellés.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-quest-view',
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe],
|
||||
templateUrl: './quest-view.component.html',
|
||||
styleUrls: ['./quest-view.component.scss']
|
||||
})
|
||||
export class QuestViewComponent implements OnInit, OnDestroy {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly BookOpen = BookOpen;
|
||||
readonly MapPin = MapPin;
|
||||
readonly Network = Network;
|
||||
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||
|
||||
campaignId = '';
|
||||
questId = '';
|
||||
quest: Quest | null = null;
|
||||
|
||||
loreId: string | null = null;
|
||||
availablePages: Page[] = [];
|
||||
/** Autres quêtes — pour résoudre les prérequis QUEST_COMPLETED en noms. */
|
||||
availableQuests: Quest[] = [];
|
||||
/** Chapitres / scènes de la campagne — pour résoudre les nœuds en libellés. */
|
||||
chapters: Chapter[] = [];
|
||||
scenes: Scene[] = [];
|
||||
/** Arcs SYSTEM (« Quêtes libres ») : leurs chapitres-conteneurs sont de la plomberie. */
|
||||
private systemArcIds = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private questService: QuestService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newQuestId = pm.get('questId')!;
|
||||
if (newQuestId !== this.questId || newCampaignId !== this.campaignId) {
|
||||
this.campaignId = newCampaignId;
|
||||
this.questId = newQuestId;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
quest: this.questService.getById(this.campaignId, this.questId),
|
||||
quests: this.questService.getByCampaign(this.campaignId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
|
||||
})
|
||||
).subscribe(({ campaign, allCampaigns, quest, quests, treeData, pages, loreId }) => {
|
||||
this.quest = quest;
|
||||
this.loreId = loreId;
|
||||
this.availablePages = pages;
|
||||
this.availableQuests = quests;
|
||||
this.chapters = Object.values(treeData.chaptersByArc).flat();
|
||||
this.scenes = Object.values(treeData.scenesByChapter).flat();
|
||||
this.systemArcIds = new Set((treeData.arcs ?? [])
|
||||
.filter(a => a.type === 'SYSTEM').map(a => a.id!));
|
||||
this.pageTitleService.set(quest.name);
|
||||
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId, this.translate));
|
||||
});
|
||||
}
|
||||
|
||||
titleOfRelated(pageId: string): string {
|
||||
return this.availablePages.find(p => p.id === pageId)?.title
|
||||
?? this.translate.instant('questView.deletedPage');
|
||||
}
|
||||
|
||||
/** Libellé lisible d'un prérequis (réutilisé en lecture seule). */
|
||||
prereqLabel(prereq: Prerequisite): string {
|
||||
switch (prereq.kind) {
|
||||
case 'QUEST_COMPLETED': {
|
||||
const q = this.availableQuests.find(x => x.id === prereq.questId);
|
||||
const name = q?.name ?? this.translate.instant('questView.deletedQuest');
|
||||
return this.translate.instant('questView.prereqQuestCompleted', { name });
|
||||
}
|
||||
case 'SESSION_REACHED':
|
||||
return this.translate.instant('questView.prereqSessionReached', { n: prereq.minSessionNumber });
|
||||
case 'FLAG_SET':
|
||||
return this.translate.instant('questView.prereqFlagSet', { flag: prereq.flagName });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nœuds à AFFICHER : les vrais liens narratifs. Le conteneur de scènes de la quête
|
||||
* (chapitre d'un arc SYSTEM) est de la plomberie — ses scènes sont déjà visibles
|
||||
* dans l'arbre sous la quête, inutile de montrer le lien technique.
|
||||
*/
|
||||
get visibleNodes(): QuestNodeRef[] {
|
||||
return (this.quest?.nodes ?? []).filter(n => {
|
||||
if (n.nodeType !== 'CHAPTER') return true;
|
||||
const ch = this.chapters.find(c => c.id === n.nodeId);
|
||||
return !ch || !this.systemArcIds.has(ch.arcId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Libellé d'un nœud narratif (chapitre, ou « chapitre › scène »). */
|
||||
nodeLabel(n: QuestNodeRef): string {
|
||||
if (n.nodeType === 'CHAPTER') {
|
||||
return this.chapters.find(c => c.id === n.nodeId)?.name
|
||||
?? this.translate.instant('questView.deletedNode');
|
||||
}
|
||||
const scene = this.scenes.find(s => s.id === n.nodeId);
|
||||
if (!scene) return this.translate.instant('questView.deletedNode');
|
||||
const chapter = this.chapters.find(c => c.id === scene.chapterId);
|
||||
return chapter ? `${chapter.name} › ${scene.name}` : scene.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conteneur de scènes de la quête : premier nœud CHAPTER résolu. C'est lui qui porte
|
||||
* la CARTE (graphe des scènes) — indispensable depuis la fusion quête/conteneur dans
|
||||
* l'arbre, sinon le graphe d'une quête de hub serait inaccessible.
|
||||
*/
|
||||
get containerChapter(): Chapter | null {
|
||||
for (const n of this.quest?.nodes ?? []) {
|
||||
if (n.nodeType !== 'CHAPTER') continue;
|
||||
const ch = this.chapters.find(c => c.id === n.nodeId);
|
||||
if (ch) return ch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ouvre la carte (graphe) des scènes de la quête — création libre de scènes incluse. */
|
||||
openGraph(): void {
|
||||
const ch = this.containerChapter;
|
||||
if (!ch) return;
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', ch.arcId, 'chapters', ch.id, 'graph']);
|
||||
}
|
||||
|
||||
editMode(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'quests', this.questId, 'edit']);
|
||||
}
|
||||
|
||||
/** Suppression simple : une quête n'a pas d'enfants (les nœuds sont des références faibles). */
|
||||
deleteQuest(): void {
|
||||
if (!this.quest) return;
|
||||
const quest = this.quest;
|
||||
this.confirmDialog.confirm({
|
||||
title: this.translate.instant('questView.deleteTitle'),
|
||||
message: this.translate.instant('questView.deleteMessage', { name: quest.name }),
|
||||
details: [this.translate.instant('questView.irreversible')],
|
||||
confirmLabel: this.translate.instant('common.delete'),
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok || !quest.id) return;
|
||||
this.questService.delete(this.campaignId, quest.id).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'quests']),
|
||||
error: () => console.error('Erreur lors de la suppression de la quête')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
|
||||
}
|
||||
}
|
||||
@@ -39,18 +39,25 @@ export class RandomTableViewComponent implements OnInit {
|
||||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId');
|
||||
const newTableId = pm.get('tableId');
|
||||
if (newCampaignId === this.campaignId && newTableId === this.tableId) return;
|
||||
this.campaignId = newCampaignId;
|
||||
this.tableId = newTableId;
|
||||
this.table = null;
|
||||
this.lastRoll = null;
|
||||
this.matched = null;
|
||||
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 {
|
||||
|
||||
@@ -26,93 +26,17 @@
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>{{ 'sceneEdit.illustrationsLabel' | translate }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">{{ 'sceneEdit.illustrationsHint' | translate }}</small>
|
||||
</div>
|
||||
<!-- Tout est optionnel sauf le titre : on n'affiche que l'identité, le reste
|
||||
est dans des sections repliables avec pastille « rempli ». -->
|
||||
<p class="optional-hint">{{ 'sceneEdit.optionalHint' | translate }}</p>
|
||||
|
||||
<!-- Battlemap Foundry : selon la source (Dungeon Alchemist = media + .json,
|
||||
DungeonDraft = .dd2vtt unique). Non affichee dans l'appli (export only). -->
|
||||
<div class="field">
|
||||
<label>{{ 'sceneEdit.battlemapLabel' | translate }}</label>
|
||||
<small class="field-hint">{{ 'sceneEdit.battlemapHint' | translate }}</small>
|
||||
|
||||
<div class="battlemap-source" role="group" [attr.aria-label]="'sceneEdit.battlemapSourceLabel' | translate">
|
||||
<button type="button" [class.active]="battlemapSource === 'DUNGEON_ALCHEMIST'"
|
||||
(click)="setBattlemapSource('DUNGEON_ALCHEMIST')">
|
||||
{{ 'sceneEdit.battlemapSourceDA' | translate }}
|
||||
</button>
|
||||
<button type="button" [class.active]="battlemapSource === 'DUNGEONDRAFT'"
|
||||
(click)="setBattlemapSource('DUNGEONDRAFT')">
|
||||
{{ 'sceneEdit.battlemapSourceDD' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (battlemapSource === 'DUNGEON_ALCHEMIST') {
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="battlemapUploadingMedia"
|
||||
(filesDropped)="onBattlemapMediaDropped($event)">
|
||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapMedia' | translate }}</span>
|
||||
@if (battlemapMediaFileId) {
|
||||
<span class="battlemap-file">{{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||
<button type="button" class="battlemap-remove" (click)="removeBattlemapMedia()">
|
||||
{{ 'sceneEdit.battlemapRemove' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<label class="battlemap-pick">
|
||||
{{ (battlemapUploadingMedia ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
|
||||
<input type="file" accept="image/*,video/mp4,video/webm" hidden (change)="onBattlemapMediaSelected($event)" />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="battlemapUploadingData"
|
||||
(filesDropped)="onBattlemapDataDropped($event)">
|
||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapData' | translate }}</span>
|
||||
@if (battlemapDataFileId) {
|
||||
<span class="battlemap-file">{{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||
<button type="button" class="battlemap-remove" (click)="removeBattlemapData()">
|
||||
{{ 'sceneEdit.battlemapRemove' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<label class="battlemap-pick">
|
||||
{{ (battlemapUploadingData ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
|
||||
<input type="file" accept=".json,.dd2vtt,.uvtt,application/json" hidden (change)="onBattlemapDataSelected($event)" />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="battlemapUploadingData"
|
||||
(filesDropped)="onDd2vttDropped($event)">
|
||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapDd2vttSlot' | translate }}</span>
|
||||
@if (battlemapDataFileId) {
|
||||
<span class="battlemap-file">{{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||
<button type="button" class="battlemap-remove" (click)="removeDd2vtt()">
|
||||
{{ 'sceneEdit.battlemapRemove' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<label class="battlemap-pick">
|
||||
{{ (battlemapUploadingData ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
|
||||
<input type="file" accept=".dd2vtt,.uvtt,application/json" hidden (change)="onDd2vttSelected($event)" />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
@if (battlemapDd2vttNoImage) {
|
||||
<small class="field-hint battlemap-warn">{{ 'sceneEdit.battlemapDd2vttNoImage' | translate }}</small>
|
||||
}
|
||||
<small class="field-hint">{{ 'sceneEdit.battlemapDd2vttHint' | translate }}</small>
|
||||
}
|
||||
</div>
|
||||
<!-- Co-création (Pilier A) : le co-MJ propose, l'utilisateur valide champ par champ.
|
||||
Les champs retenus remplissent le formulaire ; rien n'est enregistré avant la sauvegarde. -->
|
||||
<app-entity-assist-panel
|
||||
[entityType]="'scene'" [entityId]="sceneId" [campaignId]="campaignId"
|
||||
(applied)="onAssistApplied($event)"></app-entity-assist-panel>
|
||||
|
||||
<!-- Identité -->
|
||||
<div class="field">
|
||||
<label for="scene-edit-name">{{ 'sceneEdit.nameLabel' | translate }}</label>
|
||||
<input
|
||||
@@ -139,8 +63,122 @@
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
</div>
|
||||
|
||||
<!-- Type narratif du nœud (Niveau 2 — graphe de nœuds typés) -->
|
||||
<div class="field">
|
||||
<label for="scene-edit-type">{{ 'sceneEdit.nodeTypeLabel' | translate }}</label>
|
||||
<select id="scene-edit-type" formControlName="type">
|
||||
@for (opt of sceneTypeOptions; track opt) {
|
||||
<option [value]="opt">{{ 'sceneType.' + opt | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
<small class="field-hint">{{ 'sceneEdit.nodeTypeHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<app-expandable-section [title]="'sceneEdit.illustrationsLabel' | translate" icon="🖼️" [filled]="illustrationsFilled">
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</app-image-gallery>
|
||||
<small class="field-hint">{{ 'sceneEdit.illustrationsHint' | translate }}</small>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Battlemaps Foundry : LISTE de variantes (Jour/Nuit, étages…), chacune selon
|
||||
sa source (Dungeon Alchemist = media + .json, DungeonDraft = .dd2vtt unique).
|
||||
Non affichées dans l'appli (export only). -->
|
||||
<app-expandable-section [title]="'sceneEdit.battlemapLabel' | translate" icon="🗺️" [filled]="battlemapFilled">
|
||||
<small class="field-hint">{{ 'sceneEdit.battlemapHint' | translate }}</small>
|
||||
|
||||
@for (bm of battlemaps; track $index; let i = $index) {
|
||||
<div class="battlemap-card">
|
||||
<div class="battlemap-card-head">
|
||||
<input class="battlemap-label-input" type="text" [value]="bm.label"
|
||||
(input)="updateBattlemapLabel(i, $any($event.target).value)"
|
||||
[placeholder]="'sceneEdit.battlemapLabelPlaceholder' | translate" />
|
||||
<button type="button" class="battlemap-remove" (click)="removeBattlemap(i)">
|
||||
{{ 'sceneEdit.battlemapRemoveMap' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="battlemap-source" role="group" [attr.aria-label]="'sceneEdit.battlemapSourceLabel' | translate">
|
||||
<button type="button" [class.active]="bm.source === 'DUNGEON_ALCHEMIST'"
|
||||
(click)="setBattlemapSource(bm, 'DUNGEON_ALCHEMIST')">
|
||||
{{ 'sceneEdit.battlemapSourceDA' | translate }}
|
||||
</button>
|
||||
<button type="button" [class.active]="bm.source === 'DUNGEONDRAFT'"
|
||||
(click)="setBattlemapSource(bm, 'DUNGEONDRAFT')">
|
||||
{{ 'sceneEdit.battlemapSourceDD' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (bm.source === 'DUNGEON_ALCHEMIST') {
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="bm.uploadingMedia"
|
||||
(filesDropped)="onBattlemapMediaDropped(bm, $event)">
|
||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapMedia' | translate }}</span>
|
||||
@if (bm.mediaFileId) {
|
||||
<span class="battlemap-file">{{ bm.mediaName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||
<button type="button" class="battlemap-remove" (click)="removeBattlemapMedia(bm)">
|
||||
{{ 'sceneEdit.battlemapRemove' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<label class="battlemap-pick">
|
||||
{{ (bm.uploadingMedia ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
|
||||
<input type="file" accept="image/*,video/mp4,video/webm" hidden (change)="onBattlemapMediaSelected(bm, $event)" />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="bm.uploadingData"
|
||||
(filesDropped)="onBattlemapDataDropped(bm, $event)">
|
||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapData' | translate }}</span>
|
||||
@if (bm.dataFileId) {
|
||||
<span class="battlemap-file">{{ bm.dataName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||
<button type="button" class="battlemap-remove" (click)="removeBattlemapData(bm)">
|
||||
{{ 'sceneEdit.battlemapRemove' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<label class="battlemap-pick">
|
||||
{{ (bm.uploadingData ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
|
||||
<input type="file" accept=".json,.dd2vtt,.uvtt,application/json" hidden (change)="onBattlemapDataSelected(bm, $event)" />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="bm.uploadingData"
|
||||
(filesDropped)="onDd2vttDropped(bm, $event)">
|
||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapDd2vttSlot' | translate }}</span>
|
||||
@if (bm.dataFileId) {
|
||||
<span class="battlemap-file">{{ bm.dataName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||
<button type="button" class="battlemap-remove" (click)="removeDd2vtt(bm)">
|
||||
{{ 'sceneEdit.battlemapRemove' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<label class="battlemap-pick">
|
||||
{{ (bm.uploadingData ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
|
||||
<input type="file" accept=".dd2vtt,.uvtt,application/json" hidden (change)="onDd2vttSelected(bm, $event)" />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
@if (bm.dd2vttNoImage) {
|
||||
<small class="field-hint battlemap-warn">{{ 'sceneEdit.battlemapDd2vttNoImage' | translate }}</small>
|
||||
}
|
||||
<small class="field-hint">{{ 'sceneEdit.battlemapDd2vttHint' | translate }}</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="battlemap-add" (click)="addBattlemap()">
|
||||
+ {{ 'sceneEdit.battlemapAdd' | translate }}
|
||||
</button>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Contexte et ambiance -->
|
||||
<app-expandable-section [title]="'sceneEdit.contextSectionTitle' | translate" icon="📍" [initiallyOpen]="true">
|
||||
<app-expandable-section [title]="'sceneEdit.contextSectionTitle' | translate" icon="📍" [initiallyOpen]="true" [filled]="contextFilled">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="scene-edit-location">{{ 'sceneEdit.locationLabel' | translate }}</label>
|
||||
@@ -163,7 +201,7 @@
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Narration pour les joueurs -->
|
||||
<app-expandable-section [title]="'sceneEdit.narrationSectionTitle' | translate" icon="📖">
|
||||
<app-expandable-section [title]="'sceneEdit.narrationSectionTitle' | translate" icon="📖" [filled]="narrationFilled">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="playerNarration"
|
||||
@@ -175,7 +213,7 @@
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Notes et secrets du MJ (privé) -->
|
||||
<app-expandable-section [title]="'sceneEdit.gmNotesSectionTitle' | translate" icon="🔒" variant="private">
|
||||
<app-expandable-section [title]="'sceneEdit.gmNotesSectionTitle' | translate" icon="🔒" variant="private" [filled]="gmNotesFilled">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="gmSecretNotes"
|
||||
@@ -187,7 +225,7 @@
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Choix et conséquences -->
|
||||
<app-expandable-section [title]="'sceneEdit.choicesSectionTitle' | translate" icon="🔀">
|
||||
<app-expandable-section [title]="'sceneEdit.choicesSectionTitle' | translate" icon="🔀" [filled]="choicesFilled">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="choicesConsequences"
|
||||
@@ -198,7 +236,7 @@
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Branches narratives (graphe intra-chapitre) -->
|
||||
<app-expandable-section [title]="'sceneEdit.branchesSectionTitle' | translate" icon="🌿">
|
||||
<app-expandable-section [title]="'sceneEdit.branchesSectionTitle' | translate" icon="🌿" [initiallyOpen]="focusSection === 'branches'" [filled]="branchesFilled">
|
||||
@if (siblingScenes.length === 0) {
|
||||
<div class="branches-hint">
|
||||
<small class="field-hint">
|
||||
@@ -221,7 +259,11 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ 'sceneEdit.branchTargetLabel' | translate }}</label>
|
||||
@if (isBranchTargetBroken(branch)) {
|
||||
<small class="branch-broken">⚠ {{ 'sceneEdit.branchTargetBroken' | translate }}</small>
|
||||
}
|
||||
<select
|
||||
[class.invalid]="isBranchTargetBroken(branch)"
|
||||
(change)="updateBranchTarget(i, $any($event.target).value)">
|
||||
<option value="" [selected]="!branch.targetSceneId">{{ 'sceneEdit.branchTargetPlaceholder' | translate }}</option>
|
||||
@for (s of siblingScenes; track s) {
|
||||
@@ -239,6 +281,14 @@
|
||||
(input)="updateBranchCondition(i, $any($event.target).value)"
|
||||
[placeholder]="'sceneEdit.branchConditionPlaceholder' | translate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ 'sceneEdit.branchKindLabel' | translate }}</label>
|
||||
<select (change)="updateBranchKind(i, $any($event.target).value)">
|
||||
@for (opt of linkTypeOptions; track opt) {
|
||||
<option [value]="opt" [selected]="opt === (branch.kind || 'EXIT')">{{ 'linkType.' + opt | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn-remove-branch" (click)="removeBranch(i)"
|
||||
[title]="'sceneEdit.branchRemoveTitle' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
@@ -257,7 +307,7 @@
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Combat ou rencontre -->
|
||||
<app-expandable-section [title]="'sceneEdit.combatSectionTitle' | translate" icon="⚔️">
|
||||
<app-expandable-section [title]="'sceneEdit.combatSectionTitle' | translate" icon="⚔️" [initiallyOpen]="focusSection === 'combat'" [filled]="combatFilled">
|
||||
<div class="field">
|
||||
<label for="scene-edit-combat-difficulty">{{ 'sceneEdit.combatDifficultyLabel' | translate }}</label>
|
||||
<input id="scene-edit-combat-difficulty" type="text" formControlName="combatDifficulty" [placeholder]="'sceneEdit.combatDifficultyPlaceholder' | translate" />
|
||||
@@ -287,7 +337,7 @@
|
||||
|
||||
<!-- Section : Pages Lore associées (B2 cross-context) -->
|
||||
@if (loreId) {
|
||||
<app-expandable-section [title]="'sceneEdit.loreSectionTitle' | translate" icon="🔗">
|
||||
<app-expandable-section [title]="'sceneEdit.loreSectionTitle' | translate" icon="🔗" [filled]="loreFilled">
|
||||
<div class="field">
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
@@ -311,7 +361,7 @@
|
||||
}
|
||||
|
||||
<!-- Lieu explorable : pièces / donjon -->
|
||||
<app-expandable-section [title]="'sceneEdit.dungeonSectionTitle' | translate" icon="🏰" [initiallyOpen]="rooms.length > 0">
|
||||
<app-expandable-section [title]="'sceneEdit.dungeonSectionTitle' | translate" icon="🏰" [initiallyOpen]="rooms.length > 0 || focusSection === 'rooms'" [filled]="dungeonFilled">
|
||||
<small class="field-hint">
|
||||
{{ 'sceneEdit.dungeonHint' | translate }}
|
||||
</small>
|
||||
|
||||
@@ -3,6 +3,57 @@
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
// Carte d'UNE battlemap (variante Jour/Nuit, étage…) : libellé + source + slots.
|
||||
.battlemap-card {
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
margin: 0.6rem 0;
|
||||
|
||||
.battlemap-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.battlemap-label-input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.88rem;
|
||||
|
||||
&::placeholder { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.battlemap-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ajout d'une nouvelle carte à la liste.
|
||||
.battlemap-add {
|
||||
background: none;
|
||||
border: 1px dashed #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: #9ca3af;
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover { color: #d1d5db; border-color: #6c63ff; }
|
||||
}
|
||||
|
||||
// Sélecteur de source de carte (Dungeon Alchemist / DungeonDraft).
|
||||
.battlemap-source {
|
||||
display: inline-flex;
|
||||
@@ -96,6 +147,17 @@
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Bandeau « tout est optionnel sauf le titre » en tête du formulaire.
|
||||
.optional-hint {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: rgba(108, 99, 255, 0.08);
|
||||
border: 1px solid rgba(108, 99, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
color: #a5b4fc;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
// Override local : bouton Supprimer poussé à droite (voir _buttons.scss pour la base).
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
@@ -122,6 +184,14 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
// Destination de branche morte (scène supprimée) : avertissement ambre + select marqué.
|
||||
.branch-broken {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #b45309;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-add-branch {
|
||||
align-self: flex-start;
|
||||
padding: 0.5rem 0.9rem;
|
||||
|
||||
@@ -14,7 +14,7 @@ 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, Room } from '../../../services/campaign.model';
|
||||
import { Scene, SceneBattlemap, SceneBranch, Room, SceneType, LinkType } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { Enemy } from '../../../services/enemy.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
@@ -25,17 +25,44 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
|
||||
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 { EntityAssistPanelComponent } from '../../../shared/entity-assist-panel/entity-assist-panel.component';
|
||||
import { FieldProposal } from '../../../services/entity-assist.model';
|
||||
import { FileDropDirective } from '../../../shared/file-drop.directive';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* État d'édition d'UNE battlemap de la scène (une carte par variante : Jour, Nuit,
|
||||
* étage…). Enveloppe locale de SceneBattlemap + méta d'UI (noms de fichiers résolus,
|
||||
* source du fichier, uploads en cours) — seuls label/mediaFileId/dataFileId persistent.
|
||||
*/
|
||||
interface BattlemapEdit {
|
||||
label: string;
|
||||
mediaFileId: string | null;
|
||||
dataFileId: string | null;
|
||||
mediaName: string | null;
|
||||
dataName: string | null;
|
||||
/**
|
||||
* Source de CETTE carte :
|
||||
* - 'DUNGEON_ALCHEMIST' : export Foundry = image/video + .json (2 fichiers).
|
||||
* - 'DUNGEONDRAFT' : .dd2vtt unique (Universal VTT, image embarquee) ;
|
||||
* on extrait l'image a l'upload -> media, et on range le .dd2vtt en donnees.
|
||||
* Non persistee : deduite au chargement de l'extension du fichier de donnees.
|
||||
*/
|
||||
source: 'DUNGEON_ALCHEMIST' | 'DUNGEONDRAFT';
|
||||
/** Le .dd2vtt deposse ne contenait pas d'image embarquee (carte sans fond). */
|
||||
dd2vttNoImage: boolean;
|
||||
uploadingMedia: boolean;
|
||||
uploadingData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'une Scène.
|
||||
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-scene-edit',
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent, FileDropDirective, TranslatePipe],
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent, EntityAssistPanelComponent, FileDropDirective, TranslatePipe],
|
||||
templateUrl: './scene-edit.component.html',
|
||||
styleUrls: ['./scene-edit.component.scss']
|
||||
})
|
||||
@@ -43,6 +70,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
readonly sceneTypeOptions: SceneType[] = ['GENERIC', 'LOCATION', 'ENCOUNTER', 'NPC', 'EVENT', 'REVELATION'];
|
||||
readonly linkTypeOptions: LinkType[] = ['EXIT', 'CLUE', 'LEAD'];
|
||||
selectedIcon: string | null = null;
|
||||
|
||||
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
||||
@@ -64,6 +93,12 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
sceneId = '';
|
||||
scene: Scene | null = null;
|
||||
|
||||
/**
|
||||
* Section à déplier d'office (query param `?focus=combat|branches|rooms`) — posé par
|
||||
* le bouton « Corriger » du guidage pour atterrir directement sur l'outil fautif.
|
||||
*/
|
||||
focusSection: string | null = null;
|
||||
|
||||
availablePages: Page[] = [];
|
||||
loreId: string | null = null;
|
||||
relatedPageIds: string[] = [];
|
||||
@@ -72,24 +107,11 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
enemyIds: string[] = [];
|
||||
illustrationImageIds: string[] = [];
|
||||
|
||||
/** Battlemap Foundry : paire { media + sidecar JSON Universal VTT }. Non affichee. */
|
||||
battlemapMediaFileId: string | null = null;
|
||||
battlemapDataFileId: string | null = null;
|
||||
battlemapMediaName: string | null = null;
|
||||
battlemapDataName: string | null = null;
|
||||
battlemapUploadingMedia = false;
|
||||
battlemapUploadingData = false;
|
||||
|
||||
/**
|
||||
* Source de carte choisie pour CETTE scene :
|
||||
* - 'DUNGEON_ALCHEMIST' : export Foundry = image/video + .json (2 fichiers).
|
||||
* - 'DUNGEONDRAFT' : .dd2vtt unique (Universal VTT, image embarquee) ;
|
||||
* on extrait l'image a l'upload -> media, et on range le .dd2vtt en donnees.
|
||||
* Non persistee : deduite au chargement de l'extension du fichier de donnees.
|
||||
* Battlemaps Foundry de la scene : une entree par variante (Jour/Nuit, etage…).
|
||||
* Non affichees dans l'appli — transportees a l'export Foundry.
|
||||
*/
|
||||
battlemapSource: 'DUNGEON_ALCHEMIST' | 'DUNGEONDRAFT' = 'DUNGEON_ALCHEMIST';
|
||||
/** Le .dd2vtt deposse ne contenait pas d'image embarquee (carte sans fond). */
|
||||
battlemapDd2vttNoImage = false;
|
||||
battlemaps: BattlemapEdit[] = [];
|
||||
|
||||
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
|
||||
siblingScenes: Scene[] = [];
|
||||
@@ -101,6 +123,39 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
onRoomsChange(next: Room[]): void { this.rooms = next; }
|
||||
|
||||
/**
|
||||
* Applique au FORMULAIRE les champs étoffés retenus par l'utilisateur (Pilier A).
|
||||
* Non destructif : ne touche que les contrôles proposés ; l'utilisateur enregistre
|
||||
* ensuite normalement (rien n'est persisté par ce geste).
|
||||
*/
|
||||
onAssistApplied(fields: FieldProposal[]): void {
|
||||
const patch: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
if (this.form.get(f.key)) patch[f.key] = f.proposedValue;
|
||||
}
|
||||
this.form.patchValue(patch);
|
||||
}
|
||||
|
||||
// ─────────────── État « rempli » par section (pastille de l'en-tête) ───────────────
|
||||
// Tout est optionnel sauf le titre : ces getters signalent visuellement ce qui
|
||||
// contient déjà du contenu, pour ne pas donner l'impression qu'il faut tout remplir.
|
||||
get illustrationsFilled(): boolean { return this.illustrationImageIds.length > 0; }
|
||||
get battlemapFilled(): boolean { return this.battlemaps.some(b => !!b.mediaFileId || !!b.dataFileId); }
|
||||
get contextFilled(): boolean {
|
||||
const v = this.form.value;
|
||||
return !!(v.location || v.timing || v.atmosphere);
|
||||
}
|
||||
get narrationFilled(): boolean { return !!this.form.value.playerNarration; }
|
||||
get gmNotesFilled(): boolean { return !!this.form.value.gmSecretNotes; }
|
||||
get choicesFilled(): boolean { return !!this.form.value.choicesConsequences; }
|
||||
get branchesFilled(): boolean { return this.branches.length > 0; }
|
||||
get combatFilled(): boolean {
|
||||
const v = this.form.value;
|
||||
return !!(v.combatDifficulty || v.enemies) || this.enemyIds.length > 0;
|
||||
}
|
||||
get loreFilled(): boolean { return this.relatedPageIds.length > 0; }
|
||||
get dungeonFilled(): boolean { return this.rooms.length > 0; }
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
@@ -118,6 +173,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
type: ['GENERIC'],
|
||||
description: [''],
|
||||
// Contexte et ambiance
|
||||
location: [''],
|
||||
@@ -152,6 +208,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
this.arcId = newArcId;
|
||||
this.chapterId = newChapterId;
|
||||
this.sceneId = newSceneId;
|
||||
this.focusSection = this.route.snapshot.queryParamMap.get('focus');
|
||||
this.loadAll();
|
||||
}
|
||||
});
|
||||
@@ -180,31 +237,37 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
this.enemyIds = [...(scene.enemyIds ?? [])];
|
||||
this.selectedIcon = scene.icon ?? null;
|
||||
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
|
||||
this.battlemapMediaFileId = scene.battlemapMediaFileId ?? null;
|
||||
this.battlemapDataFileId = scene.battlemapDataFileId ?? null;
|
||||
this.battlemapMediaName = null;
|
||||
this.battlemapDataName = null;
|
||||
this.battlemapSource = 'DUNGEON_ALCHEMIST';
|
||||
this.battlemapDd2vttNoImage = false;
|
||||
if (this.battlemapMediaFileId) {
|
||||
this.storedFileService.getById(this.battlemapMediaFileId)
|
||||
.subscribe({ next: f => this.battlemapMediaName = f.filename, error: () => {} });
|
||||
}
|
||||
if (this.battlemapDataFileId) {
|
||||
this.storedFileService.getById(this.battlemapDataFileId).subscribe({
|
||||
next: f => {
|
||||
this.battlemapDataName = f.filename;
|
||||
// Deduit la source : un .dd2vtt/.uvtt => DungeonDraft.
|
||||
if (/\.(dd2vtt|uvtt)$/i.test(f.filename)) this.battlemapSource = 'DUNGEONDRAFT';
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
this.battlemaps = (scene.battlemaps ?? []).map(bm => {
|
||||
const entry: BattlemapEdit = {
|
||||
label: bm.label ?? '',
|
||||
mediaFileId: bm.mediaFileId ?? null,
|
||||
dataFileId: bm.dataFileId ?? null,
|
||||
mediaName: null, dataName: null,
|
||||
source: 'DUNGEON_ALCHEMIST', dd2vttNoImage: false,
|
||||
uploadingMedia: false, uploadingData: false
|
||||
};
|
||||
if (entry.mediaFileId) {
|
||||
this.storedFileService.getById(entry.mediaFileId)
|
||||
.subscribe({ next: f => entry.mediaName = f.filename, error: () => {} });
|
||||
}
|
||||
if (entry.dataFileId) {
|
||||
this.storedFileService.getById(entry.dataFileId).subscribe({
|
||||
next: f => {
|
||||
entry.dataName = f.filename;
|
||||
// Deduit la source : un .dd2vtt/.uvtt => DungeonDraft.
|
||||
if (/\.(dd2vtt|uvtt)$/i.test(f.filename)) entry.source = 'DUNGEONDRAFT';
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
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,
|
||||
type: scene.type ?? 'GENERIC',
|
||||
description: scene.description ?? '',
|
||||
location: scene.location ?? '',
|
||||
timing: scene.timing ?? '',
|
||||
@@ -224,6 +287,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
if (this.form.invalid || !this.scene) return;
|
||||
this.campaignService.updateScene(this.sceneId, {
|
||||
name: this.form.value.name,
|
||||
type: this.form.value.type,
|
||||
description: this.form.value.description,
|
||||
chapterId: this.chapterId,
|
||||
order: this.scene.order ?? 1,
|
||||
@@ -238,8 +302,11 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
enemyIds: this.enemyIds,
|
||||
relatedPageIds: this.relatedPageIds,
|
||||
illustrationImageIds: this.illustrationImageIds,
|
||||
battlemapMediaFileId: this.battlemapMediaFileId,
|
||||
battlemapDataFileId: this.battlemapDataFileId,
|
||||
// Seules les cartes portant au moins un fichier sont persistees (une entree
|
||||
// ajoutee puis laissee vide n'est pas une carte).
|
||||
battlemaps: this.battlemaps
|
||||
.filter(b => b.mediaFileId || b.dataFileId)
|
||||
.map(b => ({ label: b.label.trim(), mediaFileId: b.mediaFileId, dataFileId: b.dataFileId } as SceneBattlemap)),
|
||||
branches: this.branches,
|
||||
rooms: this.rooms,
|
||||
icon: this.selectedIcon
|
||||
@@ -272,8 +339,18 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
// ─────────────── Gestion des branches narratives ───────────────
|
||||
|
||||
|
||||
/**
|
||||
* Destination de branche morte : renseignée mais ne résolvant vers aucune scène sœur
|
||||
* (scène supprimée). Sans ce signal, la branche est invisible (le <select> affiche le
|
||||
* placeholder) alors que le guidage la signale « cassée » ET que la sauvegarde serait
|
||||
* refusée par le backend — incompréhensible pour l'utilisateur.
|
||||
*/
|
||||
isBranchTargetBroken(branch: SceneBranch): boolean {
|
||||
return !!branch.targetSceneId && !this.siblingScenes.some(s => s.id === branch.targetSceneId);
|
||||
}
|
||||
|
||||
addBranch(): void {
|
||||
this.branches.push({ label: '', targetSceneId: '', condition: '' });
|
||||
this.branches.push({ label: '', targetSceneId: '', condition: '', kind: 'EXIT' });
|
||||
}
|
||||
|
||||
removeBranch(index: number): void {
|
||||
@@ -292,98 +369,120 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
this.branches[index].condition = value;
|
||||
}
|
||||
|
||||
// ─────────────── Battlemap Foundry (media + sidecar JSON) ───────────────
|
||||
updateBranchKind(index: number, value: string): void {
|
||||
this.branches[index].kind = value as LinkType;
|
||||
}
|
||||
|
||||
// ─────────────── Battlemaps Foundry (liste de variantes) ───────────────
|
||||
// On NE supprime PAS le binaire au "retirer" (juste la reference locale) :
|
||||
// si l'utilisateur annule le formulaire, la scene garde son fichier intact.
|
||||
// Le binaire orphelin eventuel est inoffensif (nettoyage ulterieur possible).
|
||||
|
||||
onBattlemapMediaSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) this.uploadBattlemapMedia(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onBattlemapMediaDropped(files: File[]): void {
|
||||
if (files[0]) this.uploadBattlemapMedia(files[0]);
|
||||
}
|
||||
|
||||
private uploadBattlemapMedia(file: File): void {
|
||||
this.battlemapUploadingMedia = true;
|
||||
this.storedFileService.upload(file).subscribe({
|
||||
next: f => {
|
||||
this.battlemapMediaFileId = f.id;
|
||||
this.battlemapMediaName = f.filename;
|
||||
this.battlemapUploadingMedia = false;
|
||||
},
|
||||
error: () => { this.battlemapUploadingMedia = false; }
|
||||
/** Ajoute une carte vierge (variante Jour/Nuit, etage…). */
|
||||
addBattlemap(): void {
|
||||
this.battlemaps.push({
|
||||
label: '', mediaFileId: null, dataFileId: null, mediaName: null, dataName: null,
|
||||
source: 'DUNGEON_ALCHEMIST', dd2vttNoImage: false,
|
||||
uploadingMedia: false, uploadingData: false
|
||||
});
|
||||
}
|
||||
|
||||
onBattlemapDataSelected(event: Event): void {
|
||||
/** Retire la carte entiere (references locales seulement, binaires conserves). */
|
||||
removeBattlemap(index: number): void {
|
||||
this.battlemaps.splice(index, 1);
|
||||
}
|
||||
|
||||
updateBattlemapLabel(index: number, value: string): void {
|
||||
this.battlemaps[index].label = value;
|
||||
}
|
||||
|
||||
onBattlemapMediaSelected(bm: BattlemapEdit, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) this.uploadBattlemapData(file);
|
||||
if (file) this.uploadBattlemapMedia(bm, file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onBattlemapDataDropped(files: File[]): void {
|
||||
if (files[0]) this.uploadBattlemapData(files[0]);
|
||||
onBattlemapMediaDropped(bm: BattlemapEdit, files: File[]): void {
|
||||
if (files[0]) this.uploadBattlemapMedia(bm, files[0]);
|
||||
}
|
||||
|
||||
private uploadBattlemapData(file: File): void {
|
||||
this.battlemapUploadingData = true;
|
||||
private uploadBattlemapMedia(bm: BattlemapEdit, file: File): void {
|
||||
bm.uploadingMedia = true;
|
||||
this.storedFileService.upload(file).subscribe({
|
||||
next: f => {
|
||||
this.battlemapDataFileId = f.id;
|
||||
this.battlemapDataName = f.filename;
|
||||
this.battlemapUploadingData = false;
|
||||
bm.mediaFileId = f.id;
|
||||
bm.mediaName = f.filename;
|
||||
bm.uploadingMedia = false;
|
||||
},
|
||||
error: () => { this.battlemapUploadingData = false; }
|
||||
error: () => { bm.uploadingMedia = false; }
|
||||
});
|
||||
}
|
||||
|
||||
removeBattlemapMedia(): void {
|
||||
this.battlemapMediaFileId = null;
|
||||
this.battlemapMediaName = null;
|
||||
onBattlemapDataSelected(bm: BattlemapEdit, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) this.uploadBattlemapData(bm, file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
removeBattlemapData(): void {
|
||||
this.battlemapDataFileId = null;
|
||||
this.battlemapDataName = null;
|
||||
onBattlemapDataDropped(bm: BattlemapEdit, files: File[]): void {
|
||||
if (files[0]) this.uploadBattlemapData(bm, files[0]);
|
||||
}
|
||||
|
||||
private uploadBattlemapData(bm: BattlemapEdit, file: File): void {
|
||||
bm.uploadingData = true;
|
||||
this.storedFileService.upload(file).subscribe({
|
||||
next: f => {
|
||||
bm.dataFileId = f.id;
|
||||
bm.dataName = f.filename;
|
||||
bm.uploadingData = false;
|
||||
},
|
||||
error: () => { bm.uploadingData = false; }
|
||||
});
|
||||
}
|
||||
|
||||
removeBattlemapMedia(bm: BattlemapEdit): void {
|
||||
bm.mediaFileId = null;
|
||||
bm.mediaName = null;
|
||||
}
|
||||
|
||||
removeBattlemapData(bm: BattlemapEdit): void {
|
||||
bm.dataFileId = null;
|
||||
bm.dataName = null;
|
||||
}
|
||||
|
||||
// ─────────────── Source de carte (Dungeon Alchemist / DungeonDraft) ──────────
|
||||
|
||||
/** Change la source ; repart d'une carte vierge (les deux formats diffèrent). */
|
||||
setBattlemapSource(src: 'DUNGEON_ALCHEMIST' | 'DUNGEONDRAFT'): void {
|
||||
if (src === this.battlemapSource) return;
|
||||
this.battlemapSource = src;
|
||||
this.battlemapMediaFileId = null;
|
||||
this.battlemapMediaName = null;
|
||||
this.battlemapDataFileId = null;
|
||||
this.battlemapDataName = null;
|
||||
this.battlemapDd2vttNoImage = false;
|
||||
/** Change la source de CETTE carte ; repart d'une carte vierge (les deux formats diffèrent). */
|
||||
setBattlemapSource(bm: BattlemapEdit, src: 'DUNGEON_ALCHEMIST' | 'DUNGEONDRAFT'): void {
|
||||
if (src === bm.source) return;
|
||||
bm.source = src;
|
||||
bm.mediaFileId = null;
|
||||
bm.mediaName = null;
|
||||
bm.dataFileId = null;
|
||||
bm.dataName = null;
|
||||
bm.dd2vttNoImage = false;
|
||||
}
|
||||
|
||||
/** Retire la carte DungeonDraft (data + media dérivé). */
|
||||
removeDd2vtt(): void {
|
||||
this.battlemapMediaFileId = null;
|
||||
this.battlemapMediaName = null;
|
||||
this.battlemapDataFileId = null;
|
||||
this.battlemapDataName = null;
|
||||
this.battlemapDd2vttNoImage = false;
|
||||
removeDd2vtt(bm: BattlemapEdit): void {
|
||||
bm.mediaFileId = null;
|
||||
bm.mediaName = null;
|
||||
bm.dataFileId = null;
|
||||
bm.dataName = null;
|
||||
bm.dd2vttNoImage = false;
|
||||
}
|
||||
|
||||
onDd2vttSelected(event: Event): void {
|
||||
onDd2vttSelected(bm: BattlemapEdit, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) void this.uploadDd2vtt(file);
|
||||
if (file) void this.uploadDd2vtt(bm, file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onDd2vttDropped(files: File[]): void {
|
||||
if (files[0]) void this.uploadDd2vtt(files[0]);
|
||||
onDd2vttDropped(bm: BattlemapEdit, files: File[]): void {
|
||||
if (files[0]) void this.uploadDd2vtt(bm, files[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,40 +491,40 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
* sidecar (murs/lumieres/grille, SANS l'image pour ne pas dupliquer le binaire)
|
||||
* comme fichier de donnees.
|
||||
*/
|
||||
private async uploadDd2vtt(file: File): Promise<void> {
|
||||
this.battlemapUploadingData = true;
|
||||
this.battlemapDd2vttNoImage = false;
|
||||
private async uploadDd2vtt(bm: BattlemapEdit, file: File): Promise<void> {
|
||||
bm.uploadingData = true;
|
||||
bm.dd2vttNoImage = false;
|
||||
try {
|
||||
const json = JSON.parse(await file.text());
|
||||
const { image, ...sidecar } = json ?? {};
|
||||
// Sidecar allégé (sans l'image embarquée) ; on garde le nom .dd2vtt.
|
||||
const dataFile = new File([JSON.stringify(sidecar)], file.name, { type: 'application/json' });
|
||||
const storedData = await firstValueFrom(this.storedFileService.upload(dataFile));
|
||||
this.battlemapDataFileId = storedData.id;
|
||||
this.battlemapDataName = storedData.filename;
|
||||
bm.dataFileId = storedData.id;
|
||||
bm.dataName = storedData.filename;
|
||||
|
||||
if (typeof image === 'string' && image.length > 0) {
|
||||
const blob = this.base64ToImageBlob(image);
|
||||
const imgName = this.baseName(file.name) + this.extForType(blob.type);
|
||||
const imgFile = new File([blob], imgName, { type: blob.type });
|
||||
const storedMedia = await firstValueFrom(this.storedFileService.upload(imgFile));
|
||||
this.battlemapMediaFileId = storedMedia.id;
|
||||
this.battlemapMediaName = storedMedia.filename;
|
||||
bm.mediaFileId = storedMedia.id;
|
||||
bm.mediaName = storedMedia.filename;
|
||||
} else {
|
||||
this.battlemapMediaFileId = null;
|
||||
this.battlemapMediaName = null;
|
||||
this.battlemapDd2vttNoImage = true;
|
||||
bm.mediaFileId = null;
|
||||
bm.mediaName = null;
|
||||
bm.dd2vttNoImage = true;
|
||||
}
|
||||
} catch {
|
||||
// JSON illisible : on stocke le fichier brut comme données, sans image.
|
||||
try {
|
||||
const stored = await firstValueFrom(this.storedFileService.upload(file));
|
||||
this.battlemapDataFileId = stored.id;
|
||||
this.battlemapDataName = stored.filename;
|
||||
this.battlemapDd2vttNoImage = true;
|
||||
bm.dataFileId = stored.id;
|
||||
bm.dataName = stored.filename;
|
||||
bm.dd2vttNoImage = true;
|
||||
} catch { /* upload échoué : on ignore */ }
|
||||
} finally {
|
||||
this.battlemapUploadingData = false;
|
||||
bm.uploadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export class CampaignSidebarService {
|
||||
return forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
// L'arbre agrégé embarque déjà quêtes + readiness (pastilles) en une requête.
|
||||
treeData: loadCampaignTreeData(
|
||||
this.campaignService,
|
||||
campaignId,
|
||||
@@ -50,7 +51,9 @@ export class CampaignSidebarService {
|
||||
this.enemyService
|
||||
)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId, this.translate));
|
||||
this.layoutService.show(
|
||||
buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId, this.translate)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@ export interface CampaignCreate {
|
||||
gameSystemId?: string | null;
|
||||
}
|
||||
|
||||
/** Type structurel d'un Arc (miroir de l'enum Java ArcType). */
|
||||
export type ArcType = 'LINEAR' | 'HUB';
|
||||
/**
|
||||
* Type structurel d'un Arc (miroir de l'enum Java ArcType).
|
||||
* SYSTEM = arc technique « Quêtes libres » (conteneurs des quêtes hors arc), masqué de la narration.
|
||||
*/
|
||||
export type ArcType = 'LINEAR' | 'HUB' | 'SYSTEM';
|
||||
|
||||
/** Statut de progression piloté manuellement par le MJ (persisté). */
|
||||
export type ProgressionStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED';
|
||||
@@ -89,6 +92,65 @@ export interface ArcCreate {
|
||||
illustrationImageIds?: string[];
|
||||
}
|
||||
|
||||
/** Type de nœud narratif référencé par une quête (miroir Java NodeType). */
|
||||
export type NodeType = 'CHAPTER' | 'SCENE';
|
||||
|
||||
/** Lien faible d'une quête vers un nœud narratif (miroir Java QuestNodeRef). */
|
||||
export interface QuestNodeRef {
|
||||
nodeType: NodeType;
|
||||
nodeId: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quête (Niveau 1) — entité de première classe, ORTHOGONALE à l'arbre
|
||||
* Arc→Chapitre→Scène, rattachée à la campagne. Porte ses prérequis (réutilise
|
||||
* l'union Prerequisite) et référence des nœuds via QuestNodeRef.
|
||||
*/
|
||||
export interface Quest {
|
||||
id?: string;
|
||||
campaignId: string;
|
||||
/** Arc de rattachement (nullable). Non nul ⇒ quête d'un arc HUB ; null ⇒ transverse. */
|
||||
arcId?: string | null;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string | null;
|
||||
order?: number;
|
||||
|
||||
/** Conditions de déblocage (ET logique). Donnée de SCÉNARIO. */
|
||||
prerequisites?: Prerequisite[];
|
||||
|
||||
/** Nœuds narratifs (Chapitres / Scènes) traversés par la quête. */
|
||||
nodes?: QuestNodeRef[];
|
||||
|
||||
/** Read-only — peuplé par le backend quand un playthroughId est passé. */
|
||||
progressionStatus?: ProgressionStatus;
|
||||
effectiveStatus?: QuestStatus;
|
||||
|
||||
gmNotes?: string;
|
||||
playerObjectives?: string;
|
||||
narrativeStakes?: string;
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
}
|
||||
|
||||
export interface QuestCreate {
|
||||
name: string;
|
||||
/** Arc de rattachement (nullable). Renseigné quand la quête est créée depuis un arc HUB. */
|
||||
arcId?: string | null;
|
||||
description?: string;
|
||||
icon?: string | null;
|
||||
order?: number;
|
||||
prerequisites?: Prerequisite[];
|
||||
nodes?: QuestNodeRef[];
|
||||
gmNotes?: string;
|
||||
playerObjectives?: string;
|
||||
narrativeStakes?: string;
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -97,20 +159,6 @@ export interface Chapter {
|
||||
order?: number;
|
||||
icon?: string | null;
|
||||
|
||||
/** Conditions de déblocage (ET logique). Donnée de SCÉNARIO. */
|
||||
prerequisites?: Prerequisite[];
|
||||
|
||||
/**
|
||||
* Statut de progression read-only — peuplé par le backend uniquement quand
|
||||
* un playthroughId est passé en query param. Ne pas inclure en écriture.
|
||||
*/
|
||||
progressionStatus?: ProgressionStatus;
|
||||
|
||||
/**
|
||||
* Statut effectif read-only — idem, dépend du Playthrough.
|
||||
*/
|
||||
effectiveStatus?: QuestStatus;
|
||||
|
||||
// Champs narratifs enrichis
|
||||
gmNotes?: string;
|
||||
playerObjectives?: string;
|
||||
@@ -127,8 +175,6 @@ export interface ChapterCreate {
|
||||
order: number;
|
||||
icon?: string | null;
|
||||
|
||||
prerequisites?: Prerequisite[];
|
||||
|
||||
gmNotes?: string;
|
||||
playerObjectives?: string;
|
||||
narrativeStakes?: string;
|
||||
@@ -166,6 +212,12 @@ export interface PlaythroughFlag {
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
/** Type narratif d'un nœud (Scène) — Niveau 2. Miroir de l'enum Java SceneType. */
|
||||
export type SceneType = 'GENERIC' | 'LOCATION' | 'ENCOUNTER' | 'NPC' | 'EVENT' | 'REVELATION';
|
||||
|
||||
/** Type d'un lien narratif entre scènes — Niveau 2. Miroir de l'enum Java LinkType. */
|
||||
export type LinkType = 'EXIT' | 'CLUE' | 'LEAD';
|
||||
|
||||
/**
|
||||
* Branche narrative : sortie possible d'une scène vers une autre du même chapitre.
|
||||
* Pendant TS du Value Object Java SceneBranch.
|
||||
@@ -174,6 +226,21 @@ export interface SceneBranch {
|
||||
label: string;
|
||||
targetSceneId: string;
|
||||
condition?: string;
|
||||
/** Type de lien (Niveau 2). Absent => EXIT côté backend. */
|
||||
kind?: LinkType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Battlemap étiquetée d'une scène (export Foundry) : variante Jour/Nuit, étage…
|
||||
* Pendant TS du record domaine SceneBattlemap.
|
||||
*/
|
||||
export interface SceneBattlemap {
|
||||
/** Libellé libre de la variante (ex : "Jour", "Nuit"). Peut être vide. */
|
||||
label: string;
|
||||
/** ID du fichier media (image/video). Null = carte sans fond. */
|
||||
mediaFileId: string | null;
|
||||
/** ID du fichier sidecar Universal VTT (.json/.dd2vtt). Null si absent. */
|
||||
dataFileId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,6 +284,9 @@ export interface Scene {
|
||||
order?: number;
|
||||
icon?: string | null;
|
||||
|
||||
/** Type narratif du nœud (Niveau 2). Absent => GENERIC. */
|
||||
type?: SceneType;
|
||||
|
||||
// Champs narratifs enrichis
|
||||
location?: string;
|
||||
timing?: string;
|
||||
@@ -234,11 +304,15 @@ export interface Scene {
|
||||
illustrationImageIds?: string[];
|
||||
|
||||
/**
|
||||
* Battlemap Foundry : ID du fichier media (image/video) + ID du sidecar JSON
|
||||
* Universal VTT. Non affichee dans l'appli ; transportee a l'export Foundry.
|
||||
* Battlemaps Foundry : variantes étiquetées (Jour/Nuit, étages…), chacune =
|
||||
* media (image/video) + sidecar JSON Universal VTT. Non affichées dans
|
||||
* l'appli ; transportées à l'export Foundry.
|
||||
*/
|
||||
battlemapMediaFileId?: string | null;
|
||||
battlemapDataFileId?: string | null;
|
||||
battlemaps?: SceneBattlemap[];
|
||||
|
||||
/** Position du nœud dans la vue graphe du chapitre (Niveau 2). Absent => layout auto. */
|
||||
graphX?: number;
|
||||
graphY?: number;
|
||||
|
||||
/** Sorties narratives (graphe intra-chapitre). */
|
||||
branches?: SceneBranch[];
|
||||
@@ -254,6 +328,9 @@ export interface SceneCreate {
|
||||
order: number;
|
||||
icon?: string | null;
|
||||
|
||||
/** Type narratif du nœud (Niveau 2). */
|
||||
type?: SceneType;
|
||||
|
||||
location?: string;
|
||||
timing?: string;
|
||||
atmosphere?: string;
|
||||
@@ -268,8 +345,9 @@ export interface SceneCreate {
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
battlemapMediaFileId?: string | null;
|
||||
battlemapDataFileId?: string | null;
|
||||
battlemaps?: SceneBattlemap[];
|
||||
graphX?: number;
|
||||
graphY?: number;
|
||||
branches?: SceneBranch[];
|
||||
rooms?: Room[];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate } from './campaign.model';
|
||||
import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate, Quest } from './campaign.model';
|
||||
import { CampaignReadinessAssessment } from './readiness.model';
|
||||
import { Npc } from './npc.model';
|
||||
import { RandomTable } from './random-table.model';
|
||||
import { Enemy } from './enemy.model';
|
||||
|
||||
/**
|
||||
* Arbre de campagne AGRÉGÉ (une seule requête HTTP) — miroir de CampaignTreeDTO.
|
||||
* Remplace la rafale d'appels (~15-20) que la sidebar déclenchait à chaque navigation.
|
||||
*/
|
||||
export interface CampaignTreeResponse {
|
||||
arcs: Arc[];
|
||||
chaptersByArc: Record<string, Chapter[]>;
|
||||
scenesByChapter: Record<string, Scene[]>;
|
||||
npcs: Npc[];
|
||||
randomTables: RandomTable[];
|
||||
enemies: Enemy[];
|
||||
quests: Quest[];
|
||||
readiness: CampaignReadinessAssessment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Périmètre de l'export Foundry (modale) :
|
||||
* - maps : Scenes Foundry (battlemaps) + acteurs/tokens des ennemis liés.
|
||||
* - journals : journaux narratifs (arcs, chapitres, scènes, PNJ, bestiaire).
|
||||
* - tables : tables aléatoires (RollTables).
|
||||
*/
|
||||
export interface FoundryExportOptions {
|
||||
maps: boolean;
|
||||
journals: boolean;
|
||||
tables: boolean;
|
||||
}
|
||||
|
||||
/** Compte des entités qui seront supprimées en cascade avec la campagne. */
|
||||
export interface CampaignDeletionImpact {
|
||||
@@ -42,9 +73,29 @@ export class CampaignService {
|
||||
return this.http.get<Campaign>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Télécharge le bundle d'export Foundry de la campagne (.zip). */
|
||||
exportFoundry(id: string): Observable<Blob> {
|
||||
return this.http.get(`${this.apiUrl}/${id}/foundry-export`, { responseType: 'blob' });
|
||||
/** Bilan de préparation (Pilier B) — alimente les pastilles de l'arbre de la sidebar. */
|
||||
getReadiness(id: string): Observable<CampaignReadinessAssessment> {
|
||||
return this.http.get<CampaignReadinessAssessment>(`${this.apiUrl}/${id}/readiness`);
|
||||
}
|
||||
|
||||
/** Arbre complet de la campagne en UNE requête (sidebar : structure + quêtes + readiness). */
|
||||
getTree(id: string): Observable<CampaignTreeResponse> {
|
||||
return this.http.get<CampaignTreeResponse>(`${this.apiUrl}/${id}/tree`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Télécharge le bundle d'export Foundry de la campagne (.zip).
|
||||
* Périmètre optionnel (tout par défaut) : cartes+ennemis / journaux / tables.
|
||||
*/
|
||||
exportFoundry(id: string, opts?: FoundryExportOptions): Observable<Blob> {
|
||||
let params = new HttpParams();
|
||||
if (opts) {
|
||||
params = params
|
||||
.set('maps', String(opts.maps))
|
||||
.set('journals', String(opts.journals))
|
||||
.set('tables', String(opts.tables));
|
||||
}
|
||||
return this.http.get(`${this.apiUrl}/${id}/foundry-export`, { responseType: 'blob', params });
|
||||
}
|
||||
|
||||
/** Génère et télécharge le livret PDF de la campagne. */
|
||||
@@ -100,20 +151,14 @@ export class CampaignService {
|
||||
}
|
||||
|
||||
// ========== CHAPTER ==========
|
||||
/**
|
||||
* Liste les chapitres d'un arc. Si {@code playthroughId} est fourni, le backend
|
||||
* enrichit les DTOs avec progressionStatus + effectiveStatus relatifs à la Partie.
|
||||
*/
|
||||
getChapters(arcId: string, playthroughId?: string): Observable<Chapter[]> {
|
||||
let params = new HttpParams().set('arcId', arcId);
|
||||
if (playthroughId) params = params.set('playthroughId', playthroughId);
|
||||
/** Liste les chapitres d'un arc (donnée de scénario pure). */
|
||||
getChapters(arcId: string): Observable<Chapter[]> {
|
||||
const params = new HttpParams().set('arcId', arcId);
|
||||
return this.http.get<Chapter[]>('/api/chapters', { params });
|
||||
}
|
||||
|
||||
getChapterById(id: string, playthroughId?: string): Observable<Chapter> {
|
||||
let params = new HttpParams();
|
||||
if (playthroughId) params = params.set('playthroughId', playthroughId);
|
||||
return this.http.get<Chapter>(`/api/chapters/${id}`, { params });
|
||||
getChapterById(id: string): Observable<Chapter> {
|
||||
return this.http.get<Chapter>(`/api/chapters/${id}`);
|
||||
}
|
||||
|
||||
createChapter(payload: ChapterCreate): Observable<Chapter> {
|
||||
|
||||
32
web/src/app/services/clock.model.ts
Normal file
32
web/src/app/services/clock.model.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Déclencheur d'avancement auto d'une horloge (co-MJ). Miroir de l'enum Java ClockTrigger. */
|
||||
export type ClockTrigger = 'NONE' | 'FLAG_SET' | 'QUEST_COMPLETED' | 'SESSION_ENDED';
|
||||
|
||||
/**
|
||||
* Horloge de progression (Clock) — état dynamique d'une Partie. Miroir du domaine Java.
|
||||
*/
|
||||
export interface Clock {
|
||||
id: string;
|
||||
playthroughId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
segments: number;
|
||||
filled: number;
|
||||
order: number;
|
||||
/** Déclencheur auto (co-MJ). Absent => NONE. */
|
||||
triggerType?: ClockTrigger;
|
||||
/** Cible : nom du fait (FLAG_SET) ou id de quête (QUEST_COMPLETED). */
|
||||
triggerRef?: string;
|
||||
/** Front (menace) auquel l'horloge appartient, ou undefined (libre). */
|
||||
frontId?: string;
|
||||
/** Read-only : filled >= segments. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface ClockCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
segments: number;
|
||||
triggerType?: ClockTrigger;
|
||||
triggerRef?: string;
|
||||
frontId?: string;
|
||||
}
|
||||
42
web/src/app/services/clock.service.ts
Normal file
42
web/src/app/services/clock.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Clock, ClockCreate } from './clock.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour les Horloges de progression (Clocks) d'une Partie (Play Context).
|
||||
* API imbriquée sous le Playthrough : /api/playthroughs/{playthroughId}/clocks.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClockService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
private base(playthroughId: string): string {
|
||||
return `/api/playthroughs/${playthroughId}/clocks`;
|
||||
}
|
||||
|
||||
list(playthroughId: string): Observable<Clock[]> {
|
||||
return this.http.get<Clock[]>(this.base(playthroughId));
|
||||
}
|
||||
|
||||
create(playthroughId: string, payload: ClockCreate): Observable<Clock> {
|
||||
return this.http.post<Clock>(this.base(playthroughId), payload);
|
||||
}
|
||||
|
||||
update(playthroughId: string, clockId: string, payload: ClockCreate): Observable<Clock> {
|
||||
return this.http.put<Clock>(`${this.base(playthroughId)}/${clockId}`, payload);
|
||||
}
|
||||
|
||||
advance(playthroughId: string, clockId: string): Observable<Clock> {
|
||||
return this.http.put<Clock>(`${this.base(playthroughId)}/${clockId}/advance`, {});
|
||||
}
|
||||
|
||||
regress(playthroughId: string, clockId: string): Observable<Clock> {
|
||||
return this.http.put<Clock>(`${this.base(playthroughId)}/${clockId}/regress`, {});
|
||||
}
|
||||
|
||||
delete(playthroughId: string, clockId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base(playthroughId)}/${clockId}`);
|
||||
}
|
||||
}
|
||||
18
web/src/app/services/entity-assist.model.ts
Normal file
18
web/src/app/services/entity-assist.model.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Modèles du Pilier A (co-création « propose → applique »), génériques par type
|
||||
* d'entité narrative (arc / chapitre / scène). Miroir des records domaine.
|
||||
*/
|
||||
|
||||
/** Proposition IA pour un champ (clé alignée sur les contrôles du formulaire de l'entité). */
|
||||
export interface FieldProposal {
|
||||
key: string;
|
||||
currentValue: string;
|
||||
proposedValue: string;
|
||||
}
|
||||
|
||||
export interface EntityFieldPatchProposal {
|
||||
target: string;
|
||||
targetId: string;
|
||||
type: string;
|
||||
fields: FieldProposal[];
|
||||
}
|
||||
27
web/src/app/services/entity-assist.service.ts
Normal file
27
web/src/app/services/entity-assist.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { EntityFieldPatchProposal } from './entity-assist.model';
|
||||
|
||||
/**
|
||||
* Service HTTP du Pilier A (co-création), générique par type d'entité narrative
|
||||
* ({@code arc|chapter|scene}). One-shot (pas de SSE). `generate` propose (non persisté) ;
|
||||
* `apply` patche l'entité persistée (primitif pour les flux hors-éditeur — l'éditeur, lui,
|
||||
* patche le formulaire).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EntityAssistService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
generateFields(entityType: string, entityId: string, campaignId: string, instruction?: string): Observable<EntityFieldPatchProposal> {
|
||||
return this.http.post<EntityFieldPatchProposal>(
|
||||
`/api/assist/${entityType}/${entityId}/generate`,
|
||||
{ campaignId, instruction: instruction ?? '' }
|
||||
);
|
||||
}
|
||||
|
||||
applyFields(entityType: string, entityId: string, proposal: EntityFieldPatchProposal): Observable<unknown> {
|
||||
return this.http.post(`/api/assist/${entityType}/${entityId}/apply`, proposal);
|
||||
}
|
||||
}
|
||||
15
web/src/app/services/front.model.ts
Normal file
15
web/src/app/services/front.model.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Front (menace regroupant des horloges) d'une Partie. Miroir du domaine Java.
|
||||
*/
|
||||
export interface Front {
|
||||
id: string;
|
||||
playthroughId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface FrontCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
34
web/src/app/services/front.service.ts
Normal file
34
web/src/app/services/front.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Front, FrontCreate } from './front.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour les Fronts (menaces regroupant des horloges) d'une Partie.
|
||||
* API imbriquée sous le Playthrough : /api/playthroughs/{playthroughId}/fronts.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FrontService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
private base(playthroughId: string): string {
|
||||
return `/api/playthroughs/${playthroughId}/fronts`;
|
||||
}
|
||||
|
||||
list(playthroughId: string): Observable<Front[]> {
|
||||
return this.http.get<Front[]>(this.base(playthroughId));
|
||||
}
|
||||
|
||||
create(playthroughId: string, payload: FrontCreate): Observable<Front> {
|
||||
return this.http.post<Front>(this.base(playthroughId), payload);
|
||||
}
|
||||
|
||||
update(playthroughId: string, frontId: string, payload: FrontCreate): Observable<Front> {
|
||||
return this.http.put<Front>(`${this.base(playthroughId)}/${frontId}`, payload);
|
||||
}
|
||||
|
||||
delete(playthroughId: string, frontId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base(playthroughId)}/${frontId}`);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,14 @@ export interface TreeItem {
|
||||
dropParentId?: string | null;
|
||||
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
|
||||
meta?: string;
|
||||
/**
|
||||
* Pastille de readiness (Pilier B — guidage) : 'blocking' (rouge) ou
|
||||
* 'recommended' (orange), agrégée depuis les manques de l'entité ET de ses
|
||||
* descendants. Absente = rien à signaler. Alimentée par buildCampaignTree.
|
||||
*/
|
||||
statusDot?: 'blocking' | 'recommended';
|
||||
/** Tooltip de la pastille : le(s) message(s) réel(s) du manque (multi-lignes). */
|
||||
statusDotTitle?: string;
|
||||
/**
|
||||
* Libellé de section affiché AU-DESSUS du nœud, avec un filet de séparation.
|
||||
* Utilisé pour grouper visuellement des nœuds racines (ex: "Personnages" vs "Narration").
|
||||
@@ -46,6 +54,8 @@ export interface TreeCreateAction {
|
||||
id: string;
|
||||
label: string; // tooltip au hover, texte complet en empty-state
|
||||
route: string;
|
||||
/** Query params optionnels (ex: { arcId } pour créer une quête rattachée à un arc HUB). */
|
||||
queryParams?: Record<string, string>;
|
||||
/** Cle d'icone cote sidebar (plus | folder-plus | file-plus). */
|
||||
actionIcon?: 'plus' | 'folder-plus' | 'file-plus';
|
||||
}
|
||||
|
||||
55
web/src/app/services/quest.service.ts
Normal file
55
web/src/app/services/quest.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Quest, QuestCreate } from './campaign.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour les Quêtes (Niveau 1). API nestée sous la campagne :
|
||||
* /api/campaigns/{campaignId}/quests. Si {@code playthroughId} est fourni, les
|
||||
* quêtes renvoyées sont enrichies de leur progressionStatus / effectiveStatus.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class QuestService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
private base(campaignId: string): string {
|
||||
return `/api/campaigns/${campaignId}/quests`;
|
||||
}
|
||||
|
||||
getByCampaign(campaignId: string, playthroughId?: string): Observable<Quest[]> {
|
||||
const options = playthroughId ? { params: { playthroughId } } : {};
|
||||
return this.http.get<Quest[]>(this.base(campaignId), options);
|
||||
}
|
||||
|
||||
getById(campaignId: string, questId: string, playthroughId?: string): Observable<Quest> {
|
||||
const options = playthroughId ? { params: { playthroughId } } : {};
|
||||
return this.http.get<Quest>(`${this.base(campaignId)}/${questId}`, options);
|
||||
}
|
||||
|
||||
create(campaignId: string, payload: QuestCreate): Observable<Quest> {
|
||||
return this.http.post<Quest>(this.base(campaignId), payload);
|
||||
}
|
||||
|
||||
update(campaignId: string, questId: string, payload: Quest): Observable<Quest> {
|
||||
return this.http.put<Quest>(`${this.base(campaignId)}/${questId}`, payload);
|
||||
}
|
||||
|
||||
delete(campaignId: string, questId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base(campaignId)}/${questId}`);
|
||||
}
|
||||
|
||||
reorder(campaignId: string, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.base(campaignId)}/reorder`, { orderedIds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Progression d'une quête DANS UNE PARTIE (Play Context) : NOT_STARTED efface la
|
||||
* ligne (modèle « absence = non commencée »), IN_PROGRESS / COMPLETED la posent.
|
||||
* C'est ce qui pilote le statut effectif (Disponible / En cours / Terminée).
|
||||
*/
|
||||
setProgression(playthroughId: string, questId: string,
|
||||
status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED'): Observable<void> {
|
||||
return this.http.put<void>(`/api/playthroughs/${playthroughId}/quest-progressions/${questId}`, { status });
|
||||
}
|
||||
}
|
||||
30
web/src/app/services/readiness.model.ts
Normal file
30
web/src/app/services/readiness.model.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Modèles du Pilier B (« guidage / readiness ») : bilan de préparation d'une
|
||||
* campagne renvoyé par GET /api/campaigns/{id}/readiness.
|
||||
*/
|
||||
|
||||
export type ReadinessStatus = 'DRAFT' | 'PLAYABLE' | 'POLISHED';
|
||||
export type ReadinessSeverity = 'BLOCKING' | 'RECOMMENDED' | 'OPTIONAL';
|
||||
export type ReadinessEntityType =
|
||||
| 'CAMPAIGN' | 'ARC' | 'CHAPTER' | 'SCENE' | 'QUEST' | 'NPC' | 'ENEMY';
|
||||
|
||||
/** Un manque de préparation détecté, cliquable vers l'éditeur concerné. */
|
||||
export interface ReadinessGap {
|
||||
entityType: ReadinessEntityType;
|
||||
entityId: string;
|
||||
entityName?: string | null;
|
||||
ruleId: string;
|
||||
message: string;
|
||||
severity: ReadinessSeverity;
|
||||
/** Contexte de navigation (le front construit le lien profond). */
|
||||
arcId?: string | null;
|
||||
chapterId?: string | null;
|
||||
}
|
||||
|
||||
export interface CampaignReadinessAssessment {
|
||||
campaignId: string;
|
||||
overallStatus: ReadinessStatus;
|
||||
/** Nombre de gaps par sévérité : { BLOCKING, RECOMMENDED, OPTIONAL }. */
|
||||
counts: Record<string, number>;
|
||||
gaps: ReadinessGap[];
|
||||
}
|
||||
18
web/src/app/services/readiness.service.ts
Normal file
18
web/src/app/services/readiness.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { CampaignReadinessAssessment } from './readiness.model';
|
||||
|
||||
/**
|
||||
* Service HTTP du Pilier B (« guidage / readiness ») : récupère le bilan de
|
||||
* préparation d'une campagne. Read-model pur, déterministe, sans effet de bord.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ReadinessService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getReadiness(campaignId: string): Observable<CampaignReadinessAssessment> {
|
||||
return this.http.get<CampaignReadinessAssessment>(`/api/campaigns/${campaignId}/readiness`);
|
||||
}
|
||||
}
|
||||
15
web/src/app/services/scene-draft.model.ts
Normal file
15
web/src/app/services/scene-draft.model.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Modèles du Pilier A — capacité « create » : ébauches de scènes pour un chapitre.
|
||||
* Miroir des records domaine SceneDraft / SceneDraftProposal.
|
||||
*/
|
||||
|
||||
export interface SceneDraft {
|
||||
name: string;
|
||||
description?: string;
|
||||
playerNarration?: string;
|
||||
}
|
||||
|
||||
export interface SceneDraftProposal {
|
||||
chapterId: string;
|
||||
scenes: SceneDraft[];
|
||||
}
|
||||
26
web/src/app/services/scene-draft.service.ts
Normal file
26
web/src/app/services/scene-draft.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SceneDraftProposal } from './scene-draft.model';
|
||||
import { Scene } from './campaign.model';
|
||||
|
||||
/**
|
||||
* Service HTTP du Pilier A — capacité « create » : peupler un chapitre en scènes.
|
||||
* `generate` propose (non persisté) ; `apply` CRÉE les scènes retenues dans le chapitre.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SceneDraftService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
generate(chapterId: string, campaignId: string, instruction: string, count: number): Observable<SceneDraftProposal> {
|
||||
return this.http.post<SceneDraftProposal>(
|
||||
`/api/chapters/${chapterId}/draft-scenes/generate`,
|
||||
{ campaignId, instruction: instruction ?? '', count }
|
||||
);
|
||||
}
|
||||
|
||||
apply(chapterId: string, proposal: SceneDraftProposal): Observable<Scene[]> {
|
||||
return this.http.post<Scene[]>(`/api/chapters/${chapterId}/draft-scenes/apply`, proposal);
|
||||
}
|
||||
}
|
||||
48
web/src/app/services/session-prep.model.ts
Normal file
48
web/src/app/services/session-prep.model.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ReadinessGap } from './readiness.model';
|
||||
|
||||
/**
|
||||
* Modèles « Préparer la prochaine séance » (Phase 3 co-MJ) — miroir de SessionPrepReport.
|
||||
*/
|
||||
|
||||
export interface PrepLastSession {
|
||||
id: string;
|
||||
name: string;
|
||||
startedAt?: string | null;
|
||||
endedAt?: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface PrepQuest {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
/** Chapitre / scène probable, avec le contexte pour le lien profond. */
|
||||
export interface PrepNode {
|
||||
nodeType: 'CHAPTER' | 'SCENE';
|
||||
id: string;
|
||||
name: string;
|
||||
arcId?: string | null;
|
||||
chapterId?: string | null;
|
||||
}
|
||||
|
||||
export interface PrepClock {
|
||||
id: string;
|
||||
name: string;
|
||||
segments: number;
|
||||
filled: number;
|
||||
frontName?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionPrepReport {
|
||||
playthroughId: string;
|
||||
lastSession?: PrepLastSession | null;
|
||||
questsInProgress: PrepQuest[];
|
||||
questsAvailable: PrepQuest[];
|
||||
questsCompleted: PrepQuest[];
|
||||
hotspots: PrepNode[];
|
||||
gaps: ReadinessGap[];
|
||||
otherGapCount: number;
|
||||
clocks: PrepClock[];
|
||||
}
|
||||
18
web/src/app/services/session-prep.service.ts
Normal file
18
web/src/app/services/session-prep.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SessionPrepReport } from './session-prep.model';
|
||||
|
||||
/**
|
||||
* Service HTTP « Préparer la prochaine séance » (Phase 3 co-MJ). Read-model pur :
|
||||
* position des joueurs + contenu probable + manques ciblés + horloges en mouvement.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionPrepService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getPrep(playthroughId: string): Observable<SessionPrepReport> {
|
||||
return this.http.get<SessionPrepReport>(`/api/playthroughs/${playthroughId}/session-prep`);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface Session {
|
||||
startedAt: string;
|
||||
/** Null/undefined = session en cours. */
|
||||
endedAt: string | null;
|
||||
/** Scène courante épinglée (mode cockpit) ; null = rien d'épinglé. */
|
||||
currentSceneId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
active: boolean;
|
||||
|
||||
@@ -59,4 +59,14 @@ export class SessionService {
|
||||
deleteSession(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Épingle (sceneId) ou dés-épingle (null) la scène courante — mode cockpit. */
|
||||
setCurrentScene(id: string, sceneId: string | null): Observable<Session> {
|
||||
return this.http.put<Session>(`${this.apiUrl}/${id}/current-scene`, { sceneId });
|
||||
}
|
||||
|
||||
/** Récap « précédemment… » : résume le journal de la séance précédente de la Partie. */
|
||||
recap(id: string): Observable<{ previousSessionName: string; recap: string }> {
|
||||
return this.http.post<{ previousSessionName: string; recap: string }>(`${this.apiUrl}/${id}/recap`, {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,70 @@
|
||||
<div class="play-grid">
|
||||
<!-- Colonne gauche : journal -->
|
||||
<div class="play-main">
|
||||
<!-- Scène courante épinglée (mode cockpit) -->
|
||||
@if (pinnedScene) {
|
||||
<section class="pinned-scene">
|
||||
<div class="pinned-head">
|
||||
<lucide-icon [img]="Pin" [size]="14" class="pinned-icon"></lucide-icon>
|
||||
<span class="pinned-label">{{ 'sessionDetail.pinned.label' | translate }}</span>
|
||||
<strong class="pinned-name">{{ pinnedScene.name }}</strong>
|
||||
<span class="pinned-actions">
|
||||
<button type="button" class="btn-icon" (click)="openPinnedScene()" [disabled]="!pinnedArcId"
|
||||
[title]="'sessionDetail.pinned.open' | translate">
|
||||
<lucide-icon [img]="ExternalLink" [size]="13"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="btn-icon" (click)="unpinScene()"
|
||||
[title]="'sessionDetail.pinned.unpin' | translate">
|
||||
<lucide-icon [img]="PinOff" [size]="13"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
@if (pinnedScene.location || pinnedScene.timing) {
|
||||
<p class="pinned-meta">
|
||||
@if (pinnedScene.location) { <span>📍 {{ pinnedScene.location }}</span> }
|
||||
@if (pinnedScene.timing) { <span>🕒 {{ pinnedScene.timing }}</span> }
|
||||
</p>
|
||||
}
|
||||
@if (pinnedScene.playerNarration) {
|
||||
<button type="button" class="pinned-narration-toggle" (click)="narrationOpen = !narrationOpen">
|
||||
{{ (narrationOpen ? 'sessionDetail.pinned.hideNarration' : 'sessionDetail.pinned.showNarration') | translate }}
|
||||
</button>
|
||||
@if (narrationOpen) {
|
||||
<p class="pinned-narration">{{ pinnedScene.playerNarration }}</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Récap « précédemment… » -->
|
||||
<section class="recap-bar">
|
||||
@if (!recapText) {
|
||||
<button type="button" class="btn-secondary recap-btn" (click)="generateRecap()" [disabled]="recapLoading">
|
||||
<lucide-icon [img]="recapLoading ? RefreshCw : ScrollText" [size]="14" [class.spin]="recapLoading"></lucide-icon>
|
||||
{{ (recapLoading ? 'sessionDetail.recap.loading' : 'sessionDetail.recap.button') | translate }}
|
||||
</button>
|
||||
@if (recapError) { <span class="recap-error">{{ recapError }}</span> }
|
||||
}
|
||||
@if (recapText) {
|
||||
<div class="recap-box">
|
||||
<div class="recap-head">
|
||||
<lucide-icon [img]="ScrollText" [size]="14"></lucide-icon>
|
||||
<span>{{ 'sessionDetail.recap.title' | translate:{ name: recapFrom } }}</span>
|
||||
<button type="button" class="btn-icon" (click)="closeRecap()" [title]="'common.cancel' | translate">
|
||||
<lucide-icon [img]="X" [size]="13"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
<p class="recap-text">{{ recapText }}</p>
|
||||
@if (session.active) {
|
||||
<button type="button" class="btn-secondary recap-add" (click)="addRecapToJournal()">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
|
||||
{{ 'sessionDetail.recap.addToJournal' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Ajouter une entrée -->
|
||||
@if (session.active) {
|
||||
<section class="detail-section add-entry-section">
|
||||
@@ -185,7 +249,8 @@
|
||||
[canAddToJournal]="session.active"
|
||||
(rolled)="onDiceRolled($event)"
|
||||
(aiReplyToJournal)="onAiReplyToJournal($event)"
|
||||
(noteToJournal)="onItemNoteToJournal($event)">
|
||||
(noteToJournal)="onItemNoteToJournal($event)"
|
||||
(pinScene)="onPinScene($event)">
|
||||
</app-session-reference-panel>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -344,3 +344,92 @@
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// ─────────────── Mode cockpit : scène courante épinglée ───────────────
|
||||
.pinned-scene {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: #14141f;
|
||||
border: 1px solid #3a3658;
|
||||
border-left: 3px solid #6c63ff;
|
||||
border-radius: 10px;
|
||||
|
||||
.pinned-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
|
||||
.pinned-icon { color: #8b80ff; flex-shrink: 0; }
|
||||
.pinned-label { color: #9ca3af; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.pinned-name { flex: 1; min-width: 0; color: #f3f4f6; font-size: 0.95rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pinned-actions { display: inline-flex; gap: 0.25rem; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.pinned-meta {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
margin: 0.4rem 0 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.pinned-narration-toggle {
|
||||
margin-top: 0.45rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #8b80ff;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
&:hover { color: #b9a8ff; text-decoration: underline; }
|
||||
}
|
||||
|
||||
.pinned-narration {
|
||||
margin: 0.45rem 0 0;
|
||||
color: #d1d5db;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── Récap « précédemment… » ───────────────
|
||||
.recap-bar { margin-bottom: 1rem; }
|
||||
|
||||
.recap-btn { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
|
||||
.recap-error { margin-left: 0.6rem; color: #fca5a5; font-size: 0.82rem; }
|
||||
|
||||
.spin { animation: session-spin 0.9s linear infinite; }
|
||||
@keyframes session-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.recap-box {
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: #17131f;
|
||||
border: 1px solid #4a3a68;
|
||||
border-left: 3px solid #8b5fb4;
|
||||
border-radius: 10px;
|
||||
|
||||
.recap-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: #cbb9ff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
|
||||
span { flex: 1; min-width: 0; }
|
||||
}
|
||||
|
||||
.recap-text {
|
||||
margin: 0.5rem 0;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recap-add { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
LucideAngularModule, LucideIconData,
|
||||
Dices, ArrowLeft, Square, Trash2, Pencil, Check,
|
||||
StickyNote, Sparkles, UserCheck, Plus, X
|
||||
StickyNote, Sparkles, UserCheck, Plus, X,
|
||||
Pin, PinOff, ExternalLink, ScrollText, RefreshCw
|
||||
} from 'lucide-angular';
|
||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { SessionService } from '../../services/session.service';
|
||||
import { Session } from '../../services/session.model';
|
||||
import { PlaythroughService } from '../../services/playthrough.service';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { Scene } from '../../services/campaign.model';
|
||||
import {
|
||||
SessionEntry, SessionEntryInput, EntryType, ENTRY_TYPE_META
|
||||
} from '../../services/session-entry.model';
|
||||
@@ -43,6 +46,11 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Check = Check;
|
||||
readonly Plus = Plus;
|
||||
readonly X = X;
|
||||
readonly Pin = Pin;
|
||||
readonly PinOff = PinOff;
|
||||
readonly ExternalLink = ExternalLink;
|
||||
readonly ScrollText = ScrollText;
|
||||
readonly RefreshCw = RefreshCw;
|
||||
|
||||
/** Mapping enum → composant Lucide pour le rendu des icônes par type. */
|
||||
readonly typeIcons: Record<EntryType, LucideIconData> = {
|
||||
@@ -73,11 +81,25 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
||||
editEntryType: EntryType = 'NOTE';
|
||||
editEntryContent = '';
|
||||
|
||||
// ─────────────── Mode cockpit : scène courante épinglée ───────────────
|
||||
pinnedScene: Scene | null = null;
|
||||
/** Arc parent de la scène épinglée (résolu via son chapitre) — pour le lien d'ouverture. */
|
||||
pinnedArcId: string | null = null;
|
||||
/** Narration joueur dépliée dans le bandeau. */
|
||||
narrationOpen = false;
|
||||
|
||||
// ─────────────── Récap « précédemment… » ───────────────
|
||||
recapLoading = false;
|
||||
recapText: string | null = null;
|
||||
recapFrom: string | null = null;
|
||||
recapError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private sessionService: SessionService,
|
||||
private playthroughService: PlaythroughService,
|
||||
private campaignService: CampaignService,
|
||||
private entryService: SessionEntryService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
@@ -110,6 +132,7 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
||||
if (session) {
|
||||
this.pageTitleService.set(session.name);
|
||||
this.loadEntries(session.id);
|
||||
this.loadPinnedScene(session.currentSceneId ?? null);
|
||||
if (session.playthroughId) {
|
||||
this.playthroughService.getById(session.playthroughId).pipe(
|
||||
catchError(() => of(null))
|
||||
@@ -127,6 +150,88 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────── Mode cockpit : scène courante ───────────────
|
||||
|
||||
/** Charge (ou vide) le bandeau de scène épinglée. Épingle caduque (scène supprimée) → ignorée. */
|
||||
private loadPinnedScene(sceneId: string | null): void {
|
||||
if (!sceneId) { this.pinnedScene = null; this.pinnedArcId = null; return; }
|
||||
this.campaignService.getSceneById(sceneId).pipe(catchError(() => of(null))).subscribe(scene => {
|
||||
this.pinnedScene = scene;
|
||||
this.narrationOpen = false;
|
||||
this.pinnedArcId = null;
|
||||
if (scene?.chapterId) {
|
||||
this.campaignService.getChapterById(scene.chapterId).pipe(catchError(() => of(null)))
|
||||
.subscribe(ch => this.pinnedArcId = ch?.arcId ?? null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Épingle une scène depuis le panneau de référence (onglet Scènes). */
|
||||
onPinScene(sceneId: string): void {
|
||||
if (!this.session) return;
|
||||
this.sessionService.setCurrentScene(this.session.id, sceneId).subscribe({
|
||||
next: updated => { this.session = updated; this.loadPinnedScene(updated.currentSceneId ?? null); },
|
||||
error: () => console.error('Erreur lors de l\'épinglage de la scène')
|
||||
});
|
||||
}
|
||||
|
||||
unpinScene(): void {
|
||||
if (!this.session) return;
|
||||
this.sessionService.setCurrentScene(this.session.id, null).subscribe({
|
||||
next: updated => { this.session = updated; this.pinnedScene = null; this.pinnedArcId = null; },
|
||||
error: () => console.error('Erreur lors du retrait de l\'épingle')
|
||||
});
|
||||
}
|
||||
|
||||
/** Ouvre la scène épinglée dans un nouvel onglet (préserve l'écran de session). */
|
||||
openPinnedScene(): void {
|
||||
if (!this.pinnedScene || !this.campaignId || !this.pinnedArcId) return;
|
||||
const url = ['/campaigns', this.campaignId, 'arcs', this.pinnedArcId,
|
||||
'chapters', this.pinnedScene.chapterId, 'scenes', this.pinnedScene.id].join('/');
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
// ─────────────── Récap « précédemment… » ───────────────
|
||||
|
||||
generateRecap(): void {
|
||||
if (!this.session || this.recapLoading) return;
|
||||
this.recapLoading = true;
|
||||
this.recapError = null;
|
||||
this.sessionService.recap(this.session.id).subscribe({
|
||||
next: r => {
|
||||
this.recapLoading = false;
|
||||
this.recapText = r.recap;
|
||||
this.recapFrom = r.previousSessionName;
|
||||
},
|
||||
error: err => {
|
||||
this.recapLoading = false;
|
||||
// Message backend (pas de séance précédente, journal vide, Brain KO…) si disponible.
|
||||
this.recapError = err?.error?.error
|
||||
?? this.translate.instant('sessionDetail.recap.error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Consigne le récap au journal (entrée NOTE 📜) puis referme l'encart. */
|
||||
addRecapToJournal(): void {
|
||||
if (!this.session || !this.recapText) return;
|
||||
const input: SessionEntryInput = { type: 'NOTE', content: '📜 ' + this.recapText };
|
||||
this.entryService.createEntry(this.session.id, input).subscribe({
|
||||
next: created => {
|
||||
this.entries = [created, ...this.entries];
|
||||
this.recapText = null;
|
||||
this.recapFrom = null;
|
||||
},
|
||||
error: () => console.error('Erreur lors de l\'ajout du récap au journal')
|
||||
});
|
||||
}
|
||||
|
||||
closeRecap(): void {
|
||||
this.recapText = null;
|
||||
this.recapFrom = null;
|
||||
this.recapError = null;
|
||||
}
|
||||
|
||||
// ─────────────── Renommage de la Session ───────────────
|
||||
|
||||
startRename(): void {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<div class="reference-panel">
|
||||
|
||||
<nav class="ref-tabs">
|
||||
<button type="button"
|
||||
class="ref-tab"
|
||||
[class.ref-tab--active]="activeTab === 'play'"
|
||||
(click)="selectTab('play')">
|
||||
<lucide-icon [img]="Timer" [size]="14"></lucide-icon>
|
||||
{{ 'sessionReferencePanel.tabPlay' | translate }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="ref-tab"
|
||||
[class.ref-tab--active]="activeTab === 'ai'"
|
||||
@@ -47,6 +54,24 @@
|
||||
|
||||
<div class="ref-content" [class.ref-content--fill]="activeTab === 'ai'">
|
||||
|
||||
<!-- ====== Partie (état de jeu : horloges + faits) ====== -->
|
||||
@if (activeTab === 'play') {
|
||||
<div class="ref-list">
|
||||
@if (playthroughId) {
|
||||
<div class="ref-play-section">
|
||||
<h4 class="ref-play-title">{{ 'sessionReferencePanel.playClocks' | translate }}</h4>
|
||||
<app-clocks-manager [playthroughId]="playthroughId" [campaignId]="campaignId"></app-clocks-manager>
|
||||
</div>
|
||||
<div class="ref-play-section">
|
||||
<h4 class="ref-play-title">{{ 'sessionReferencePanel.playFacts' | translate }}</h4>
|
||||
<app-playthrough-flags-manager [campaignId]="campaignId" [playthroughId]="playthroughId"></app-playthrough-flags-manager>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="empty-hint">{{ 'sessionReferencePanel.playNoPlaythrough' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ====== IA ====== -->
|
||||
@if (activeTab === 'ai') {
|
||||
<app-session-ai-chat-panel
|
||||
@@ -178,13 +203,21 @@
|
||||
<div class="ref-subgroup">
|
||||
<span class="ref-subgroup-title">{{ chapter.name }}</span>
|
||||
@for (scene of scenesOf(chapter); track scene) {
|
||||
<button
|
||||
type="button"
|
||||
class="ref-item ref-item--nested"
|
||||
(click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])">
|
||||
<span class="ref-item-name">{{ scene.name }}</span>
|
||||
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
|
||||
</button>
|
||||
<div class="ref-item-row">
|
||||
<button
|
||||
type="button"
|
||||
class="ref-item ref-item--nested"
|
||||
(click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])">
|
||||
<span class="ref-item-name">{{ scene.name }}</span>
|
||||
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
|
||||
</button>
|
||||
<!-- Épingler comme scène courante (mode cockpit). -->
|
||||
<button type="button" class="ref-pin"
|
||||
[title]="'sessionReferencePanel.pinScene' | translate"
|
||||
(click)="pinScene.emit(scene.id!)">
|
||||
<lucide-icon [img]="Pin" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -196,3 +196,44 @@
|
||||
text-align: center;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
// ─────────────── Onglet « Partie » (état de jeu : horloges + faits) ───────────────
|
||||
.ref-play-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.ref-play-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
// Ligne scène + épingle : le bouton d'ouverture garde toute la largeur restante.
|
||||
.ref-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
.ref-item { flex: 1; min-width: 0; }
|
||||
}
|
||||
|
||||
.ref-pin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { color: #c7d2fe; border-color: #6c63ff; background: rgba(108, 99, 255, 0.1); }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package, ChevronDown, ChevronRight, Timer, Pin } from 'lucide-angular';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-panel.component';
|
||||
import { SessionRandomTablesPanelComponent } from '../session-random-tables-panel/session-random-tables-panel.component';
|
||||
import { SessionItemCatalogsPanelComponent } from '../session-item-catalogs-panel/session-item-catalogs-panel.component';
|
||||
import { ClocksManagerComponent } from '../../shared/clocks-manager/clocks-manager.component';
|
||||
import { PlaythroughFlagsManagerComponent } from '../../shared/playthrough-flags-manager/playthrough-flags-manager.component';
|
||||
|
||||
type TabId = 'dice' | 'tables' | 'objects' | 'characters' | 'scenes' | 'ai';
|
||||
type TabId = 'play' | 'dice' | 'tables' | 'objects' | 'characters' | 'scenes' | 'ai';
|
||||
|
||||
/**
|
||||
* Panneau latéral du mode jeu : référence rapide en lecture seule.
|
||||
@@ -33,7 +35,7 @@ type TabId = 'dice' | 'tables' | 'objects' | 'characters' | 'scenes' | 'ai';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-session-reference-panel',
|
||||
imports: [LucideAngularModule, TranslatePipe, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent],
|
||||
imports: [LucideAngularModule, TranslatePipe, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent, ClocksManagerComponent, PlaythroughFlagsManagerComponent],
|
||||
templateUrl: './session-reference-panel.component.html',
|
||||
styleUrls: ['./session-reference-panel.component.scss']
|
||||
})
|
||||
@@ -48,6 +50,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
readonly Package = Package;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly Timer = Timer;
|
||||
readonly Pin = Pin;
|
||||
|
||||
@Input() campaignId!: string;
|
||||
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
|
||||
@@ -59,8 +63,11 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
@Output() aiReplyToJournal = new EventEmitter<string>();
|
||||
/** Émis pour consigner un objet de catalogue au journal (entrée NOTE). */
|
||||
@Output() noteToJournal = new EventEmitter<string>();
|
||||
/** Émis quand le MJ épingle une scène comme « scène courante » (mode cockpit). */
|
||||
@Output() pinScene = new EventEmitter<string>();
|
||||
|
||||
activeTab: TabId = 'dice';
|
||||
// « Partie » par défaut : l'état de jeu (horloges + faits) est le réflexe à la table.
|
||||
activeTab: TabId = 'play';
|
||||
|
||||
characters: Character[] = [];
|
||||
npcs: Npc[] = [];
|
||||
|
||||
158
web/src/app/shared/clocks-manager/clocks-manager.component.html
Normal file
158
web/src/app/shared/clocks-manager/clocks-manager.component.html
Normal file
@@ -0,0 +1,158 @@
|
||||
<div class="clocks-manager">
|
||||
|
||||
@if (clocks.length === 0 && fronts.length === 0 && !loading) {
|
||||
<p class="empty">{{ 'clocksManager.empty' | translate }}</p>
|
||||
}
|
||||
|
||||
@for (group of groups; track group.front?.id ?? '__ungrouped__') {
|
||||
<div class="front-group">
|
||||
@if (group.front; as front) {
|
||||
<div class="front-header">
|
||||
<div class="front-title">
|
||||
<span class="front-name">{{ front.name }}</span>
|
||||
@if (front.description) { <span class="front-desc">{{ front.description }}</span> }
|
||||
</div>
|
||||
<span class="front-progress">{{ frontProgressLabel(group.clocks) }}</span>
|
||||
<button type="button" class="btn-del-front" (click)="removeFront(front)"
|
||||
[title]="'clocksManager.deleteFront' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="front-header ungrouped">
|
||||
<span class="front-name">{{ 'clocksManager.ungrouped' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="clock-grid">
|
||||
@for (clock of group.clocks; track clock.id) {
|
||||
<div class="clock-card" [class.complete]="clock.complete" [class.editing]="editingId === clock.id">
|
||||
<svg viewBox="0 0 64 64" class="clock-svg" role="img"
|
||||
[attr.aria-label]="clock.name + ' ' + clock.filled + '/' + clock.segments">
|
||||
@for (seg of segments(clock); track $index) {
|
||||
<path [attr.d]="seg.d" fill="none"
|
||||
[attr.stroke]="seg.filled ? '#8b80ff' : '#2f2f42'"
|
||||
stroke-width="7" stroke-linecap="butt" />
|
||||
}
|
||||
<text x="32" y="36" text-anchor="middle" class="clock-count">{{ clock.filled }}/{{ clock.segments }}</text>
|
||||
</svg>
|
||||
@if (editingId === clock.id) {
|
||||
<div class="clock-edit">
|
||||
<input type="text" [(ngModel)]="editName" [placeholder]="'clocksManager.namePlaceholder' | translate" />
|
||||
<select [(ngModel)]="editSegments" [attr.aria-label]="'clocksManager.segments' | translate">
|
||||
@for (s of segmentOptions; track s) {
|
||||
<option [ngValue]="s">{{ 'clocksManager.segmentsN' | translate:{ n: s } }}</option>
|
||||
}
|
||||
</select>
|
||||
<select [(ngModel)]="editTriggerType" [attr.aria-label]="'clocksManager.triggerType' | translate">
|
||||
@for (t of triggerTypeOptions; track t) {
|
||||
<option [ngValue]="t">{{ 'clocksManager.triggerOption.' + t | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (editTriggerType === 'FLAG_SET') {
|
||||
<input type="text" [(ngModel)]="editTriggerRef" list="clock-flags-list"
|
||||
[placeholder]="'clocksManager.flagPlaceholder' | translate" />
|
||||
}
|
||||
@if (editTriggerType === 'QUEST_COMPLETED') {
|
||||
<select [(ngModel)]="editTriggerRef" [attr.aria-label]="'clocksManager.quest' | translate">
|
||||
<option value="">{{ 'clocksManager.selectQuest' | translate }}</option>
|
||||
@for (q of quests; track q.id) { <option [value]="q.id">{{ q.name }}</option> }
|
||||
</select>
|
||||
}
|
||||
@if (fronts.length > 0) {
|
||||
<select [(ngModel)]="editFront" [attr.aria-label]="'clocksManager.front' | translate">
|
||||
<option value="">{{ 'clocksManager.noFront' | translate }}</option>
|
||||
@for (f of fronts; track f.id) { <option [value]="f.id">{{ f.name }}</option> }
|
||||
</select>
|
||||
}
|
||||
<div class="clock-actions">
|
||||
<button type="button" class="btn-step" (click)="saveEdit(clock)" [disabled]="!editName.trim()"
|
||||
[title]="'common.save' | translate">
|
||||
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="btn-step" (click)="cancelEdit()" [title]="'common.cancel' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="clock-name" [title]="clock.description || clock.name">{{ clock.name }}</div>
|
||||
@if (triggerLabel(clock); as label) {
|
||||
<div class="clock-trigger"><lucide-icon [img]="Zap" [size]="11"></lucide-icon> {{ label }}</div>
|
||||
}
|
||||
<div class="clock-actions">
|
||||
<button type="button" class="btn-step" (click)="regress(clock)" [disabled]="clock.filled === 0"
|
||||
[title]="'clocksManager.regress' | translate">
|
||||
<lucide-icon [img]="Minus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="btn-step" (click)="advance(clock)" [disabled]="clock.filled >= clock.segments"
|
||||
[title]="'clocksManager.advance' | translate">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="btn-step" (click)="startEdit(clock)" [title]="'clocksManager.edit' | translate">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="btn-del" (click)="remove(clock)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Créer un front -->
|
||||
<div class="front-create">
|
||||
<input type="text" [(ngModel)]="newFrontName" [placeholder]="'clocksManager.frontPlaceholder' | translate"
|
||||
(keyup.enter)="createFront()" />
|
||||
<button type="button" class="btn-add" (click)="createFront()" [disabled]="!newFrontName.trim()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ 'clocksManager.addFront' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Créer une horloge -->
|
||||
<div class="clock-create">
|
||||
<input type="text" [(ngModel)]="newName" [placeholder]="'clocksManager.namePlaceholder' | translate"
|
||||
(keyup.enter)="create()" />
|
||||
<select [(ngModel)]="newSegments" [attr.aria-label]="'clocksManager.segments' | translate">
|
||||
@for (s of segmentOptions; track s) {
|
||||
<option [ngValue]="s">{{ 'clocksManager.segmentsN' | translate:{ n: s } }}</option>
|
||||
}
|
||||
</select>
|
||||
<select [(ngModel)]="newTriggerType" [attr.aria-label]="'clocksManager.triggerType' | translate">
|
||||
@for (t of triggerTypeOptions; track t) {
|
||||
<option [ngValue]="t">{{ 'clocksManager.triggerOption.' + t | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (newTriggerType === 'FLAG_SET') {
|
||||
<input type="text" [(ngModel)]="newTriggerRef" list="clock-flags-list"
|
||||
[placeholder]="'clocksManager.flagPlaceholder' | translate" />
|
||||
<datalist id="clock-flags-list">
|
||||
@for (f of referencedFlags; track f) { <option [value]="f"></option> }
|
||||
</datalist>
|
||||
}
|
||||
@if (newTriggerType === 'QUEST_COMPLETED') {
|
||||
<select [(ngModel)]="newTriggerRef" [attr.aria-label]="'clocksManager.quest' | translate">
|
||||
<option value="">{{ 'clocksManager.selectQuest' | translate }}</option>
|
||||
@for (q of quests; track q.id) {
|
||||
<option [value]="q.id">{{ q.name }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@if (fronts.length > 0) {
|
||||
<select [(ngModel)]="newClockFront" [attr.aria-label]="'clocksManager.front' | translate">
|
||||
<option value="">{{ 'clocksManager.noFront' | translate }}</option>
|
||||
@for (f of fronts; track f.id) {
|
||||
<option [value]="f.id">{{ f.name }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
<button type="button" class="btn-add" (click)="create()" [disabled]="!newName.trim()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ 'clocksManager.add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
175
web/src/app/shared/clocks-manager/clocks-manager.component.scss
Normal file
175
web/src/app/shared/clocks-manager/clocks-manager.component.scss
Normal file
@@ -0,0 +1,175 @@
|
||||
.clocks-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.front-group {
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.front-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid #2a2a3d;
|
||||
|
||||
.front-title { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.front-name { font-weight: 600; color: #cbb9ff; }
|
||||
.front-desc { font-size: 0.8rem; color: #9ca3af; }
|
||||
.front-progress { font-size: 0.85rem; color: #9ca3af; white-space: nowrap; }
|
||||
|
||||
&.ungrouped {
|
||||
border-bottom-style: dashed;
|
||||
.front-name { color: #9ca3af; font-weight: 500; font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.btn-del-front {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3658;
|
||||
border-radius: 6px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover { background: #3a1d1d; border-color: #b4554f; color: #ffb4ae; }
|
||||
}
|
||||
}
|
||||
|
||||
.front-create {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
input[type="text"] { flex: 1; min-width: 160px; }
|
||||
}
|
||||
|
||||
.clock-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.clock-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 120px;
|
||||
padding: 0.6rem;
|
||||
background: #14141f;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 10px;
|
||||
|
||||
&.complete {
|
||||
border-color: #8b80ff;
|
||||
box-shadow: inset 0 0 0 1px #8b80ff;
|
||||
}
|
||||
|
||||
&.editing {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.clock-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
|
||||
input, select { width: 100%; box-sizing: border-box; }
|
||||
}
|
||||
|
||||
.clock-svg {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.clock-count {
|
||||
fill: #e5e7eb;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.clock-name {
|
||||
max-width: 100%;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #f3f4f6;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clock-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
max-width: 100%;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: #201b3a;
|
||||
border: 1px solid #3a3068;
|
||||
border-radius: 999px;
|
||||
color: #b9a8ff;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clock-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: #1f1d3a;
|
||||
border: 1px solid #3a3658;
|
||||
border-radius: 6px;
|
||||
color: #cfcbe8;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: #2c2952; color: #fff; }
|
||||
&:disabled { opacity: 0.35; cursor: default; }
|
||||
}
|
||||
|
||||
.btn-del:hover:not(:disabled) {
|
||||
background: #3a1d1d;
|
||||
border-color: #b4554f;
|
||||
color: #ffb4ae;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clock-create {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
input[type="text"] { flex: 1; min-width: 160px; }
|
||||
select { min-width: 120px; }
|
||||
}
|
||||
245
web/src/app/shared/clocks-manager/clocks-manager.component.ts
Normal file
245
web/src/app/shared/clocks-manager/clocks-manager.component.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Plus, Minus, Trash2, Zap, Pencil, Check, X } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ClockService } from '../../services/clock.service';
|
||||
import { Clock, ClockTrigger } from '../../services/clock.model';
|
||||
import { FrontService } from '../../services/front.service';
|
||||
import { Front } from '../../services/front.model';
|
||||
import { QuestService } from '../../services/quest.service';
|
||||
import { Quest } from '../../services/campaign.model';
|
||||
import { CampaignFlagService } from '../../services/campaign-flag.service';
|
||||
|
||||
/** Un groupe affiché : un Front (ou null = horloges libres) et ses horloges. */
|
||||
interface ClockGroup { front: Front | null; clocks: Clock[]; }
|
||||
|
||||
/**
|
||||
* Gestionnaire des Horloges de progression (Clocks) d'une Partie, regroupées par
|
||||
* **Front** (menace) : visuel à segments, +/− (avancer/reculer), déclencheur auto
|
||||
* (co-MJ), création/suppression d'horloges ET de fronts. Auto-chargé via
|
||||
* {@code playthroughId} ; {@code campaignId} (optionnel) alimente les déclencheurs.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-clocks-manager',
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './clocks-manager.component.html',
|
||||
styleUrls: ['./clocks-manager.component.scss']
|
||||
})
|
||||
export class ClocksManagerComponent implements OnInit, OnChanges {
|
||||
|
||||
@Input() playthroughId!: string;
|
||||
@Input() campaignId = '';
|
||||
|
||||
readonly Plus = Plus;
|
||||
readonly Minus = Minus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Zap = Zap;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Check = Check;
|
||||
readonly X = X;
|
||||
readonly segmentOptions = [2, 4, 6, 8, 10, 12];
|
||||
readonly triggerTypeOptions: ClockTrigger[] = ['NONE', 'FLAG_SET', 'QUEST_COMPLETED', 'SESSION_ENDED'];
|
||||
|
||||
clocks: Clock[] = [];
|
||||
fronts: Front[] = [];
|
||||
quests: Quest[] = [];
|
||||
referencedFlags: string[] = [];
|
||||
loading = false;
|
||||
|
||||
// Formulaire de création d'horloge.
|
||||
newName = '';
|
||||
newSegments = 4;
|
||||
newTriggerType: ClockTrigger = 'NONE';
|
||||
newTriggerRef = '';
|
||||
newClockFront = '';
|
||||
|
||||
// Formulaire de création de front.
|
||||
newFrontName = '';
|
||||
|
||||
// Édition in-place d'une horloge.
|
||||
editingId: string | null = null;
|
||||
editName = '';
|
||||
editSegments = 4;
|
||||
editTriggerType: ClockTrigger = 'NONE';
|
||||
editTriggerRef = '';
|
||||
editFront = '';
|
||||
|
||||
constructor(
|
||||
private clockService: ClockService,
|
||||
private frontService: FrontService,
|
||||
private questService: QuestService,
|
||||
private campaignFlagService: CampaignFlagService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void { this.reload(); }
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['playthroughId'] || changes['campaignId']) this.reload();
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
if (!this.playthroughId) { this.clocks = []; this.fronts = []; return; }
|
||||
this.loading = true;
|
||||
forkJoin({
|
||||
clocks: this.clockService.list(this.playthroughId),
|
||||
fronts: this.frontService.list(this.playthroughId).pipe(catchError(() => of([] as Front[]))),
|
||||
quests: this.campaignId
|
||||
? this.questService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Quest[])))
|
||||
: of([] as Quest[]),
|
||||
flags: this.campaignId
|
||||
? this.campaignFlagService.listReferenced(this.campaignId).pipe(catchError(() => of([] as string[])))
|
||||
: of([] as string[])
|
||||
}).subscribe({
|
||||
next: ({ clocks, fronts, quests, flags }) => {
|
||||
this.clocks = clocks;
|
||||
this.fronts = fronts;
|
||||
this.quests = quests;
|
||||
this.referencedFlags = flags;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => { this.clocks = []; this.fronts = []; this.loading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
/** Groupes affichés : un par Front (avec ses horloges) + un groupe « libres » si besoin. */
|
||||
get groups(): ClockGroup[] {
|
||||
const groups: ClockGroup[] = this.fronts.map(f => ({
|
||||
front: f,
|
||||
clocks: this.clocks.filter(c => c.frontId === f.id)
|
||||
}));
|
||||
const ungrouped = this.clocks.filter(c => !c.frontId);
|
||||
if (ungrouped.length > 0) groups.push({ front: null, clocks: ungrouped });
|
||||
return groups;
|
||||
}
|
||||
|
||||
frontProgressLabel(clocks: Clock[]): string {
|
||||
const filled = clocks.reduce((s, c) => s + c.filled, 0);
|
||||
const total = clocks.reduce((s, c) => s + c.segments, 0);
|
||||
return `${filled}/${total}`;
|
||||
}
|
||||
|
||||
// ── Fronts ──────────────────────────────────────────────────────────────
|
||||
createFront(): void {
|
||||
const name = this.newFrontName.trim();
|
||||
if (!name) return;
|
||||
this.frontService.create(this.playthroughId, { name }).subscribe({
|
||||
next: f => { this.fronts = [...this.fronts, f]; this.newFrontName = ''; }
|
||||
});
|
||||
}
|
||||
|
||||
removeFront(front: Front): void {
|
||||
this.frontService.delete(this.playthroughId, front.id).subscribe({
|
||||
next: () => {
|
||||
this.fronts = this.fronts.filter(f => f.id !== front.id);
|
||||
// Le backend orpheline les horloges (frontId -> null) : on reflète localement.
|
||||
this.clocks = this.clocks.map(c => c.frontId === front.id ? { ...c, frontId: undefined } : c);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Horloges ────────────────────────────────────────────────────────────
|
||||
create(): void {
|
||||
const name = this.newName.trim();
|
||||
if (!name) return;
|
||||
const needsRef = this.newTriggerType === 'FLAG_SET' || this.newTriggerType === 'QUEST_COMPLETED';
|
||||
const ref = needsRef ? (this.newTriggerRef.trim() || undefined) : undefined;
|
||||
this.clockService.create(this.playthroughId, {
|
||||
name, segments: this.newSegments, triggerType: this.newTriggerType,
|
||||
triggerRef: ref, frontId: this.newClockFront || undefined
|
||||
}).subscribe({
|
||||
next: c => {
|
||||
this.clocks = [...this.clocks, c];
|
||||
this.newName = '';
|
||||
this.newSegments = 4;
|
||||
this.newTriggerType = 'NONE';
|
||||
this.newTriggerRef = '';
|
||||
this.newClockFront = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
advance(clock: Clock): void {
|
||||
this.clockService.advance(this.playthroughId, clock.id).subscribe({ next: u => this.replace(u) });
|
||||
}
|
||||
|
||||
regress(clock: Clock): void {
|
||||
this.clockService.regress(this.playthroughId, clock.id).subscribe({ next: u => this.replace(u) });
|
||||
}
|
||||
|
||||
remove(clock: Clock): void {
|
||||
this.clockService.delete(this.playthroughId, clock.id).subscribe({
|
||||
next: () => this.clocks = this.clocks.filter(c => c.id !== clock.id)
|
||||
});
|
||||
}
|
||||
|
||||
startEdit(clock: Clock): void {
|
||||
this.editingId = clock.id;
|
||||
this.editName = clock.name;
|
||||
this.editSegments = clock.segments;
|
||||
this.editTriggerType = clock.triggerType ?? 'NONE';
|
||||
this.editTriggerRef = clock.triggerRef ?? '';
|
||||
this.editFront = clock.frontId ?? '';
|
||||
}
|
||||
|
||||
cancelEdit(): void { this.editingId = null; }
|
||||
|
||||
saveEdit(clock: Clock): void {
|
||||
const name = this.editName.trim();
|
||||
if (!name) return;
|
||||
const needsRef = this.editTriggerType === 'FLAG_SET' || this.editTriggerType === 'QUEST_COMPLETED';
|
||||
const ref = needsRef ? (this.editTriggerRef.trim() || undefined) : undefined;
|
||||
this.clockService.update(this.playthroughId, clock.id, {
|
||||
name, segments: this.editSegments, triggerType: this.editTriggerType,
|
||||
triggerRef: ref, frontId: this.editFront || undefined
|
||||
}).subscribe({
|
||||
next: u => { this.replace(u); this.editingId = null; }
|
||||
});
|
||||
}
|
||||
|
||||
private replace(updated: Clock): void {
|
||||
this.clocks = this.clocks.map(c => c.id === updated.id ? updated : c);
|
||||
}
|
||||
|
||||
/** Libellé lisible du déclencheur auto (badge), ou null si aucun. */
|
||||
triggerLabel(clock: Clock): string | null {
|
||||
switch (clock.triggerType) {
|
||||
case 'FLAG_SET':
|
||||
return this.translate.instant('clocksManager.triggerFlag', { name: clock.triggerRef });
|
||||
case 'QUEST_COMPLETED':
|
||||
return this.translate.instant('clocksManager.triggerQuest', { name: this.questName(clock.triggerRef) });
|
||||
case 'SESSION_ENDED':
|
||||
return this.translate.instant('clocksManager.triggerSession');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private questName(id?: string): string {
|
||||
return this.quests.find(q => q.id === id)?.name ?? this.translate.instant('clocksManager.deletedQuest');
|
||||
}
|
||||
|
||||
/**
|
||||
* Arcs SVG des segments (anneau) d'une horloge : un arc par segment, rempli ou non.
|
||||
* Évite le wedge plein (point central) → pas de cas dégénéré pour 1 segment.
|
||||
*/
|
||||
segments(clock: Clock): { d: string; filled: boolean }[] {
|
||||
const n = Math.max(1, clock.segments);
|
||||
const cx = 32, cy = 32, r = 26;
|
||||
const gap = n > 1 ? 0.12 : 0.0001; // léger espace entre segments (radians)
|
||||
const out: { d: string; filled: boolean }[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a0 = (i / n) * 2 * Math.PI - Math.PI / 2 + gap / 2;
|
||||
const a1 = ((i + 1) / n) * 2 * Math.PI - Math.PI / 2 - gap / 2;
|
||||
const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
|
||||
const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
|
||||
const large = (a1 - a0) > Math.PI ? 1 : 0;
|
||||
out.push({
|
||||
d: `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`,
|
||||
filled: i < clock.filled
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,23 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Références cassées (fiche supprimée) : exposées pour pouvoir les retirer -->
|
||||
@if (brokenGroups.length > 0) {
|
||||
<div class="linked-chips">
|
||||
@for (b of brokenGroups; track b.id) {
|
||||
<span class="chip chip-broken" [title]="'enemyLinkPicker.brokenTitle' | translate">
|
||||
<span class="chip-label">
|
||||
<lucide-icon [img]="AlertTriangle" [size]="12"></lucide-icon>
|
||||
{{ 'enemyLinkPicker.broken' | translate }}{{ b.count > 1 ? ' ×' + b.count : '' }}
|
||||
</span>
|
||||
<button type="button" class="chip-remove" (click)="remove(b.id)" [title]="'enemyLinkPicker.removeLink' | translate">
|
||||
<lucide-icon [img]="X" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Input + dropdown de suggestions -->
|
||||
<div class="search-wrapper">
|
||||
<input
|
||||
|
||||
@@ -75,6 +75,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Chip d'une référence cassée (fiche du bestiaire supprimée) — teinte ambre = « à corriger ».
|
||||
.chip-broken {
|
||||
background: #3a2e14;
|
||||
color: #fcd34d;
|
||||
|
||||
.chip-label { cursor: default; }
|
||||
.chip-label:hover { color: #fde68a; }
|
||||
.chip-remove {
|
||||
border-left: 1px solid #4a3a1a;
|
||||
&:hover { background: #4a3a1a; color: #fde68a; }
|
||||
}
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X, Skull } from 'lucide-angular';
|
||||
import { LucideAngularModule, X, Skull, AlertTriangle } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { Enemy } from '../../services/enemy.model';
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Enemy } from '../../services/enemy.model';
|
||||
export class EnemyLinkPickerComponent {
|
||||
readonly X = X;
|
||||
readonly Skull = Skull;
|
||||
readonly AlertTriangle = AlertTriangle;
|
||||
|
||||
/** IDs des ennemis actuellement liés (contrôlés par le parent). */
|
||||
@Input() value: string[] = [];
|
||||
@@ -60,6 +61,25 @@ export class EnemyLinkPickerComponent {
|
||||
.filter((g): g is { enemy: Enemy; count: number } => !!g.enemy);
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs liés dont la fiche est INTROUVABLE dans le bestiaire de la campagne (fiche
|
||||
* supprimée — typiquement un ennemi supprimé puis recréé sous un nouvel id). Le picker
|
||||
* les cachait silencieusement ; on les EXPOSE (chip « introuvable ») pour que
|
||||
* l'utilisateur puisse les retirer — sinon la référence morte reste invisible mais
|
||||
* persistée, et le guidage (readiness) la signale sans que rien ne soit actionnable.
|
||||
*/
|
||||
get brokenGroups(): { id: string; count: number }[] {
|
||||
const counts = new Map<string, number>();
|
||||
const order: string[] = [];
|
||||
for (const id of this.value) {
|
||||
if (!id) continue;
|
||||
if (this.availableEnemies.some(e => e.id === id)) continue; // résolu → pas cassé
|
||||
if (!counts.has(id)) order.push(id);
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
return order.map(id => ({ id, count: counts.get(id)! }));
|
||||
}
|
||||
|
||||
/** Ennemis proposables dans le dropdown — filtrés par query, exclut les déjà liés. */
|
||||
get suggestions(): Enemy[] {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<div class="entity-assist">
|
||||
<button type="button" class="assist-toggle" (click)="toggle()">
|
||||
<lucide-icon [img]="open ? ChevronDown : ChevronRight" [size]="15"></lucide-icon>
|
||||
<lucide-icon [img]="Sparkles" [size]="15" class="spark"></lucide-icon>
|
||||
<span class="assist-title">{{ 'entityAssist.button' | translate }}</span>
|
||||
</button>
|
||||
|
||||
@if (open) {
|
||||
<div class="assist-body">
|
||||
<p class="assist-hint">{{ 'entityAssist.hint' | translate }}</p>
|
||||
|
||||
<div class="assist-prompt">
|
||||
<textarea [(ngModel)]="instruction" rows="2"
|
||||
[placeholder]="'entityAssist.instructionPlaceholder' | translate"></textarea>
|
||||
<button type="button" class="btn-generate" (click)="generate()" [disabled]="loading">
|
||||
<lucide-icon [img]="loading ? RefreshCw : Sparkles" [size]="14" [class.spin]="loading"></lucide-icon>
|
||||
{{ (loading ? 'entityAssist.generating' : (generated ? 'entityAssist.regenerate' : 'entityAssist.generate')) | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<p class="assist-error">{{ error }}</p>
|
||||
}
|
||||
|
||||
@if (generated && fields.length === 0 && !loading) {
|
||||
<p class="assist-empty">{{ 'entityAssist.empty' | translate }}</p>
|
||||
}
|
||||
|
||||
@if (fields.length > 0) {
|
||||
<ul class="field-list">
|
||||
@for (f of fields; track f.key) {
|
||||
<li class="field-card" [class.rejected]="!f.accepted">
|
||||
<label class="field-check">
|
||||
<input type="checkbox" [(ngModel)]="f.accepted" />
|
||||
<span class="field-name">{{ fieldLabel(f.key) }}</span>
|
||||
</label>
|
||||
@if (f.currentValue) {
|
||||
<div class="field-current">
|
||||
<span class="tag">{{ 'entityAssist.current' | translate }}</span>
|
||||
<span class="val">{{ f.currentValue }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="field-proposed">
|
||||
<span class="tag">{{ 'entityAssist.proposed' | translate }}</span>
|
||||
<span class="val">{{ f.proposedValue }}</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="assist-actions">
|
||||
<button type="button" class="btn-apply" (click)="apply()" [disabled]="acceptedCount === 0">
|
||||
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||
{{ 'entityAssist.applySelection' | translate:{ n: acceptedCount } }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,147 @@
|
||||
.entity-assist {
|
||||
border: 1px solid #2a2440;
|
||||
border-radius: 10px;
|
||||
background: #12101d;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assist-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #d6ccff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover { background: #171327; }
|
||||
|
||||
.spark { color: #b9a8ff; }
|
||||
.assist-title { font-weight: 600; font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.assist-body {
|
||||
padding: 0.2rem 0.85rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.assist-hint {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.assist-prompt {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
resize: vertical;
|
||||
min-height: 2.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-generate,
|
||||
.btn-apply {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #3a3068;
|
||||
background: #201b3a;
|
||||
color: #d6ccff;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: #2c2358; color: #fff; }
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
}
|
||||
|
||||
.btn-apply {
|
||||
border-color: #2f6b46;
|
||||
background: #163326;
|
||||
color: #a7f3d0;
|
||||
&:hover:not(:disabled) { background: #1c4531; color: #fff; }
|
||||
}
|
||||
|
||||
.spin { animation: assist-spin 0.9s linear infinite; }
|
||||
@keyframes assist-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.assist-error { margin: 0; color: #fca5a5; font-size: 0.82rem; }
|
||||
.assist-empty { margin: 0; color: #9ca3af; font-style: italic; font-size: 0.82rem; }
|
||||
|
||||
.field-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.field-card {
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: #16131f;
|
||||
border: 1px solid #272233;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
|
||||
&.rejected { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.field-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
cursor: pointer;
|
||||
|
||||
input { accent-color: #8b80ff; }
|
||||
.field-name { font-weight: 600; color: #e5e7eb; font-size: 0.83rem; }
|
||||
}
|
||||
|
||||
.field-current,
|
||||
.field-proposed {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
|
||||
.tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
height: fit-content;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.val { white-space: pre-wrap; word-break: break-word; }
|
||||
}
|
||||
|
||||
.field-current {
|
||||
.tag { background: #2a2233; color: #9ca3af; }
|
||||
.val { color: #8b8595; text-decoration: line-through; text-decoration-color: #4b4458; }
|
||||
}
|
||||
|
||||
.field-proposed {
|
||||
.tag { background: #201b3a; color: #b9a8ff; }
|
||||
.val { color: #ede9ff; }
|
||||
}
|
||||
|
||||
.assist-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { LucideAngularModule, Sparkles, RefreshCw, Check, X, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||
import { EntityAssistService } from '../../services/entity-assist.service';
|
||||
import { FieldProposal } from '../../services/entity-assist.model';
|
||||
|
||||
interface ReviewField extends FieldProposal {
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panneau « Étoffer avec l'IA » (Pilier A — co-création), générique par type d'entité
|
||||
* narrative ({@code arc|chapter|scene}). Demande au co-MJ des propositions de valeurs pour
|
||||
* les champs de l'entité, les affiche en diff (actuel → proposé) avec accept/reject champ
|
||||
* par champ, et ÉMET les champs retenus.
|
||||
*
|
||||
* <p>Ne persiste rien : le parent (arc/chapter/scene-edit) applique les champs retenus au
|
||||
* FORMULAIRE ; l'utilisateur enregistre ensuite normalement (human-in-the-loop).</p>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-entity-assist-panel',
|
||||
standalone: true,
|
||||
imports: [FormsModule, TranslatePipe, LucideAngularModule],
|
||||
templateUrl: './entity-assist-panel.component.html',
|
||||
styleUrls: ['./entity-assist-panel.component.scss']
|
||||
})
|
||||
export class EntityAssistPanelComponent implements OnChanges {
|
||||
|
||||
/** Type d'entité : 'arc' | 'chapter' | 'scene'. */
|
||||
@Input() entityType = 'scene';
|
||||
@Input() entityId = '';
|
||||
@Input() campaignId = '';
|
||||
/** Émet les champs ACCEPTÉS ; le parent les applique où il veut (formulaire, /apply…). */
|
||||
@Output() applied = new EventEmitter<FieldProposal[]>();
|
||||
|
||||
open = false;
|
||||
instruction = '';
|
||||
loading = false;
|
||||
generated = false;
|
||||
error = '';
|
||||
fields: ReviewField[] = [];
|
||||
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly RefreshCw = RefreshCw;
|
||||
readonly Check = Check;
|
||||
readonly X = X;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
|
||||
constructor(private assist: EntityAssistService, private translate: TranslateService) {}
|
||||
|
||||
/**
|
||||
* L'écran d'édition RÉUTILISE ce composant quand on navigue entre entités sœurs
|
||||
* (même route pattern → pas de recréation). On repart donc d'un état vierge dès que
|
||||
* l'entité ciblée change, sinon on afficherait les propositions de l'entité précédente.
|
||||
*/
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['entityId'] && !changes['entityId'].firstChange) {
|
||||
this.open = false;
|
||||
this.instruction = '';
|
||||
this.loading = false;
|
||||
this.generated = false;
|
||||
this.error = '';
|
||||
this.fields = [];
|
||||
}
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.open = !this.open;
|
||||
}
|
||||
|
||||
generate(): void {
|
||||
if (!this.entityId || this.loading) return;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.generated = false;
|
||||
this.fields = [];
|
||||
this.assist.generateFields(this.entityType, this.entityId, this.campaignId, this.instruction.trim() || undefined).subscribe({
|
||||
next: proposal => {
|
||||
this.fields = (proposal.fields ?? []).map(f => ({ ...f, accepted: true }));
|
||||
this.generated = true;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.error = this.translate.instant('entityAssist.error');
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get acceptedCount(): number {
|
||||
return this.fields.filter(f => f.accepted).length;
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
const accepted: FieldProposal[] = this.fields
|
||||
.filter(f => f.accepted)
|
||||
.map(f => ({ key: f.key, currentValue: f.currentValue, proposedValue: f.proposedValue }));
|
||||
if (accepted.length > 0) {
|
||||
this.applied.emit(accepted);
|
||||
}
|
||||
this.fields = [];
|
||||
this.generated = false;
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
/** Libellé lisible d'une clé de champ (fallback sur la clé brute si non traduite). */
|
||||
fieldLabel(key: string): string {
|
||||
const label = this.translate.instant('entityAssist.field.' + key);
|
||||
return label === 'entityAssist.field.' + key ? key : label;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
<span class="section-icon">{{ icon }}</span>
|
||||
}
|
||||
{{ title }}
|
||||
@if (filled) {
|
||||
<span class="section-dot" aria-hidden="true"></span>
|
||||
}
|
||||
</span>
|
||||
<lucide-icon [img]="isOpen ? ChevronUp : ChevronDown" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
|
||||
@@ -42,6 +42,19 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
// Pastille « cette section contient du contenu » (voir Input `filled`).
|
||||
.section-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #6c63ff;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.private .section-dot {
|
||||
background: #fca5a5;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1rem 1.1rem 1.2rem 1.1rem;
|
||||
border-top: 1px solid #374151;
|
||||
|
||||
@@ -25,6 +25,12 @@ export class ExpandableSectionComponent {
|
||||
@Input() icon = ''; // Emoji ou caractère unicode (ex: '📍', '📖')
|
||||
@Input() initiallyOpen = false;
|
||||
@Input() variant: 'default' | 'private' = 'default'; // 'private' = notes MJ (couleur différente)
|
||||
/**
|
||||
* Pastille « contient du contenu » dans l'en-tête (replié comme déplié).
|
||||
* Permet de voir d'un coup d'œil quelles sections optionnelles sont remplies
|
||||
* sans avoir à les ouvrir une à une. Le parent calcule l'état.
|
||||
*/
|
||||
@Input() filled = false;
|
||||
|
||||
isOpen = false;
|
||||
|
||||
|
||||
51
web/src/app/shared/node-picker/node-picker.component.html
Normal file
51
web/src/app/shared/node-picker/node-picker.component.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<div class="node-picker">
|
||||
|
||||
<div class="node-list">
|
||||
@for (n of nodes; track $index) {
|
||||
<div class="node-chip">
|
||||
<lucide-icon class="node-icon" [img]="n.nodeType === 'SCENE' ? MapPin : BookOpen" [size]="15"></lucide-icon>
|
||||
<span class="node-name">{{ nodeName(n) || ('nodePicker.deletedNode' | translate) }}</span>
|
||||
<span class="node-type">{{ (n.nodeType === 'SCENE' ? 'nodePicker.scene' : 'nodePicker.chapter') | translate }}</span>
|
||||
<button type="button" (click)="moveUp($index)" [disabled]="$index === 0" [title]="'nodePicker.moveUp' | translate">
|
||||
<lucide-icon [img]="ChevronUp" [size]="15"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" (click)="moveDown($index)" [disabled]="$index === nodes.length - 1" [title]="'nodePicker.moveDown' | translate">
|
||||
<lucide-icon [img]="ChevronDown" [size]="15"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" (click)="remove($index)" [title]="'nodePicker.remove' | translate">
|
||||
<lucide-icon [img]="X" [size]="15"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (nodes.length === 0) {
|
||||
<p class="empty-hint">{{ 'nodePicker.empty' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="add-row">
|
||||
<select [(ngModel)]="selectedChapterId" [attr.aria-label]="'nodePicker.selectChapter' | translate">
|
||||
<option value="">{{ 'nodePicker.selectChapter' | translate }}</option>
|
||||
@for (c of availableChapters; track c.id) {
|
||||
<option [value]="c.id">{{ c.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<button type="button" class="btn-secondary" (click)="addChapter()" [disabled]="!selectedChapterId">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ 'nodePicker.addChapter' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="add-row">
|
||||
<select [(ngModel)]="selectedSceneId" [attr.aria-label]="'nodePicker.selectScene' | translate">
|
||||
<option value="">{{ 'nodePicker.selectScene' | translate }}</option>
|
||||
@for (s of availableScenes; track s.id) {
|
||||
<option [value]="s.id">{{ sceneOptionLabel(s) }}</option>
|
||||
}
|
||||
</select>
|
||||
<button type="button" class="btn-secondary" (click)="addScene()" [disabled]="!selectedSceneId">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
{{ 'nodePicker.addScene' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
68
web/src/app/shared/node-picker/node-picker.component.scss
Normal file
68
web/src/app/shared/node-picker/node-picker.component.scss
Normal file
@@ -0,0 +1,68 @@
|
||||
.node-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.node-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.node-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 8px;
|
||||
|
||||
.node-icon { color: #9ca3af; flex: none; }
|
||||
|
||||
.node-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.node-type {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { color: #fff; background: #2a2a3d; }
|
||||
&:disabled { opacity: 0.3; cursor: default; }
|
||||
}
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
select { flex: 1; min-width: 0; }
|
||||
}
|
||||
101
web/src/app/shared/node-picker/node-picker.component.ts
Normal file
101
web/src/app/shared/node-picker/node-picker.component.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Plus, X, ChevronUp, ChevronDown, BookOpen, MapPin } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { Chapter, Scene, QuestNodeRef, NodeType } from '../../services/campaign.model';
|
||||
|
||||
/**
|
||||
* Sélecteur de nœuds narratifs (Chapitres / Scènes) traversés par une quête.
|
||||
* Émet une liste ORDONNÉE de QuestNodeRef ; l'ordre = position dans la liste
|
||||
* (réindexé à chaque changement). Réutilise les chapitres / scènes déjà chargés
|
||||
* par {@code loadCampaignTreeData} — aucun appel réseau ici.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-node-picker',
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './node-picker.component.html',
|
||||
styleUrls: ['./node-picker.component.scss']
|
||||
})
|
||||
export class NodePickerComponent {
|
||||
readonly Plus = Plus;
|
||||
readonly X = X;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly BookOpen = BookOpen;
|
||||
readonly MapPin = MapPin;
|
||||
|
||||
@Input() nodes: QuestNodeRef[] = [];
|
||||
@Input() chapters: Chapter[] = [];
|
||||
@Input() scenes: Scene[] = [];
|
||||
@Output() nodesChange = new EventEmitter<QuestNodeRef[]>();
|
||||
|
||||
selectedChapterId = '';
|
||||
selectedSceneId = '';
|
||||
|
||||
/** Chapitres pas encore rattachés (un même nœud ne se lie qu'une fois). */
|
||||
get availableChapters(): Chapter[] {
|
||||
return this.chapters.filter(c => !!c.id && !this.isLinked('CHAPTER', c.id));
|
||||
}
|
||||
|
||||
/** Scènes pas encore rattachées. */
|
||||
get availableScenes(): Scene[] {
|
||||
return this.scenes.filter(s => !!s.id && !this.isLinked('SCENE', s.id));
|
||||
}
|
||||
|
||||
private isLinked(type: NodeType, id: string): boolean {
|
||||
return this.nodes.some(n => n.nodeType === type && n.nodeId === id);
|
||||
}
|
||||
|
||||
addChapter(): void {
|
||||
if (!this.selectedChapterId) return;
|
||||
this.emit([...this.nodes, { nodeType: 'CHAPTER', nodeId: this.selectedChapterId, order: this.nodes.length }]);
|
||||
this.selectedChapterId = '';
|
||||
}
|
||||
|
||||
addScene(): void {
|
||||
if (!this.selectedSceneId) return;
|
||||
this.emit([...this.nodes, { nodeType: 'SCENE', nodeId: this.selectedSceneId, order: this.nodes.length }]);
|
||||
this.selectedSceneId = '';
|
||||
}
|
||||
|
||||
remove(index: number): void {
|
||||
this.emit(this.nodes.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
moveUp(index: number): void {
|
||||
if (index <= 0) return;
|
||||
const next = [...this.nodes];
|
||||
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
||||
this.emit(next);
|
||||
}
|
||||
|
||||
moveDown(index: number): void {
|
||||
if (index >= this.nodes.length - 1) return;
|
||||
const next = [...this.nodes];
|
||||
[next[index + 1], next[index]] = [next[index], next[index + 1]];
|
||||
this.emit(next);
|
||||
}
|
||||
|
||||
/** Réindexe {@code order} sur la position courante avant de propager. */
|
||||
private emit(nodes: QuestNodeRef[]): void {
|
||||
this.nodesChange.emit(nodes.map((n, i) => ({ ...n, order: i })));
|
||||
}
|
||||
|
||||
// ── Libellés ──────────────────────────────────────────────────────────────
|
||||
/** Libellé d'un nœud lié ('' si l'entité a été supprimée → géré dans le template). */
|
||||
nodeName(n: QuestNodeRef): string {
|
||||
if (n.nodeType === 'CHAPTER') {
|
||||
return this.chapters.find(c => c.id === n.nodeId)?.name ?? '';
|
||||
}
|
||||
const scene = this.scenes.find(s => s.id === n.nodeId);
|
||||
if (!scene) return '';
|
||||
const chapter = this.chapters.find(c => c.id === scene.chapterId);
|
||||
return chapter ? `${chapter.name} › ${scene.name}` : scene.name;
|
||||
}
|
||||
|
||||
/** Option du menu déroulant des scènes : préfixée du chapitre pour lever l'ambiguïté. */
|
||||
sceneOptionLabel(s: Scene): string {
|
||||
const chapter = this.chapters.find(c => c.id === s.chapterId);
|
||||
return chapter ? `${chapter.name} › ${s.name}` : s.name;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,13 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { Chapter, Prerequisite } from '../../services/campaign.model';
|
||||
import { Prerequisite } from '../../services/campaign.model';
|
||||
|
||||
/** Cible candidate pour un prérequis QUEST_COMPLETED (Chapter HUB hérité ou Quest). */
|
||||
export interface PrerequisiteTarget {
|
||||
id?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Éditeur des prérequis (conditions de déblocage) d'une quête.
|
||||
@@ -22,8 +28,8 @@ export class PrerequisiteEditorComponent {
|
||||
/** Liste courante. */
|
||||
@Input() prerequisites: Prerequisite[] = [];
|
||||
|
||||
/** Quêtes candidates pour QUEST_COMPLETED (typiquement les chapitres frères du Hub). */
|
||||
@Input() availableQuests: Chapter[] = [];
|
||||
/** Quêtes candidates pour QUEST_COMPLETED (Chapitres HUB hérités ou Quests). */
|
||||
@Input() availableQuests: PrerequisiteTarget[] = [];
|
||||
|
||||
/** Flags déjà connus de la campagne (autocomplete pour FLAG_SET). */
|
||||
@Input() availableFlags: string[] = [];
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
@if (assessment) {
|
||||
<section class="readiness-panel" [class.ready]="isReady">
|
||||
<button type="button" class="readiness-header" (click)="toggle()">
|
||||
<lucide-icon [img]="collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
|
||||
@if (isReady) {
|
||||
<lucide-icon [img]="CheckCircle2" [size]="16" class="ready-icon"></lucide-icon>
|
||||
} @else {
|
||||
<lucide-icon [img]="AlertTriangle" [size]="16" class="warn-icon"></lucide-icon>
|
||||
}
|
||||
<span class="readiness-title">{{ 'readiness.title' | translate }}</span>
|
||||
<span class="status-badge" [class]="'status-' + assessment.overallStatus">
|
||||
{{ ('readiness.badge.' + assessment.overallStatus) | translate }}
|
||||
</span>
|
||||
@if (!collapsed) {
|
||||
<span class="counts">
|
||||
@if (blockingCount > 0) {
|
||||
<span class="count-chip blocking">{{ 'readiness.blockingN' | translate:{ n: blockingCount } }}</span>
|
||||
}
|
||||
@if (recommendedCount > 0) {
|
||||
<span class="count-chip recommended">{{ 'readiness.recommendedN' | translate:{ n: recommendedCount } }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (!collapsed) {
|
||||
@if (isReady) {
|
||||
<p class="ready-msg">{{ 'readiness.allSet' | translate }}</p>
|
||||
} @else {
|
||||
<ul class="gap-list">
|
||||
@for (gap of assessment.gaps; track gap.ruleId + gap.entityId) {
|
||||
<li class="gap-card" [class]="'sev-' + gap.severity">
|
||||
<span class="sev-dot"></span>
|
||||
<div class="gap-body">
|
||||
<span class="gap-message">{{ gap.message }}</span>
|
||||
@if (gap.entityName) {
|
||||
<span class="gap-entity">{{ gap.entityName }}</span>
|
||||
}
|
||||
</div>
|
||||
<a class="gap-cta" [routerLink]="gapLink(gap)" [queryParams]="gapQuery(gap)">
|
||||
{{ 'readiness.fix' | translate }}
|
||||
<lucide-icon [img]="ArrowRight" [size]="13"></lucide-icon>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
.readiness-panel {
|
||||
margin: 0 0 1.25rem;
|
||||
background: #0d1117;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
&.ready { border-color: #14532d; }
|
||||
}
|
||||
|
||||
.readiness-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e5e7eb;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover { background: #111827; }
|
||||
|
||||
.ready-icon { color: #4ade80; }
|
||||
.warn-icon { color: #fb923c; }
|
||||
}
|
||||
|
||||
.readiness-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.status-DRAFT { background: #3a1d1d; color: #fca5a5; }
|
||||
&.status-PLAYABLE { background: #3a2e1a; color: #fcd34d; }
|
||||
&.status-POLISHED { background: #14311f; color: #86efac; }
|
||||
}
|
||||
|
||||
.counts {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.count-chip {
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.blocking { background: #3a1d1d; color: #fca5a5; }
|
||||
&.recommended { background: #3a2e1a; color: #fcd34d; }
|
||||
}
|
||||
|
||||
.ready-msg {
|
||||
margin: 0;
|
||||
padding: 0 0.9rem 0.9rem;
|
||||
color: #86efac;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.gap-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.6rem 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.gap-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
|
||||
.sev-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
&.sev-BLOCKING .sev-dot { background: #ef4444; }
|
||||
&.sev-RECOMMENDED .sev-dot { background: #fb923c; }
|
||||
&.sev-OPTIONAL .sev-dot { background: #6b7280; }
|
||||
}
|
||||
|
||||
.gap-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gap-message {
|
||||
color: #e5e7eb;
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.gap-entity {
|
||||
color: #9ca3af;
|
||||
font-size: 0.74rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gap-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: #1f1d3a;
|
||||
border: 1px solid #3a3658;
|
||||
border-radius: 6px;
|
||||
color: #c7d2fe;
|
||||
font-size: 0.76rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover { background: #2c2952; color: #fff; }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LucideAngularModule, ChevronDown, ChevronRight, CheckCircle2, AlertTriangle, ArrowRight } from 'lucide-angular';
|
||||
import { ReadinessService } from '../../services/readiness.service';
|
||||
import { CampaignReadinessAssessment, ReadinessGap } from '../../services/readiness.model';
|
||||
import { gapAction } from '../../campaigns/gap-action.helper';
|
||||
|
||||
/**
|
||||
* Panneau « Prochaines étapes » (Pilier B — guidage co-MJ). Affiche le statut de
|
||||
* préparation agrégé d'une campagne et la liste des manques à combler, chacun
|
||||
* cliquable vers l'éditeur concerné. Purement indicatif (ne bloque rien).
|
||||
*
|
||||
* <p>Peut recevoir un {@link CampaignReadinessAssessment} déjà chargé via
|
||||
* {@code [assessment]} (évite un second appel quand le parent l'a déjà) ; sinon
|
||||
* il le charge lui-même à partir de {@code [campaignId]}.</p>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-readiness-panel',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslatePipe, LucideAngularModule],
|
||||
templateUrl: './readiness-panel.component.html',
|
||||
styleUrls: ['./readiness-panel.component.scss']
|
||||
})
|
||||
export class ReadinessPanelComponent implements OnInit {
|
||||
|
||||
@Input() campaignId = '';
|
||||
@Input() assessment: CampaignReadinessAssessment | null = null;
|
||||
|
||||
loading = false;
|
||||
collapsed = false;
|
||||
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly CheckCircle2 = CheckCircle2;
|
||||
readonly AlertTriangle = AlertTriangle;
|
||||
readonly ArrowRight = ArrowRight;
|
||||
|
||||
private static readonly STORAGE_KEY = 'loremind.readiness.collapsed';
|
||||
|
||||
constructor(private readinessService: ReadinessService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.collapsed = localStorage.getItem(ReadinessPanelComponent.STORAGE_KEY) === '1';
|
||||
if (!this.assessment && this.campaignId) {
|
||||
this.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
private fetch(): void {
|
||||
this.loading = true;
|
||||
this.readinessService.getReadiness(this.campaignId).subscribe({
|
||||
next: a => { this.assessment = a; this.loading = false; },
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.collapsed = !this.collapsed;
|
||||
localStorage.setItem(ReadinessPanelComponent.STORAGE_KEY, this.collapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
get blockingCount(): number {
|
||||
return this.assessment?.counts?.['BLOCKING'] ?? 0;
|
||||
}
|
||||
|
||||
get recommendedCount(): number {
|
||||
return this.assessment?.counts?.['RECOMMENDED'] ?? 0;
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
return !!this.assessment && this.assessment.gaps.length === 0;
|
||||
}
|
||||
|
||||
/** Lien profond « réparateur » : route + query params (outil auto-ouvert sur la cible). */
|
||||
gapLink(gap: ReadinessGap): string[] {
|
||||
return gapAction(gap, this.campaignId).link;
|
||||
}
|
||||
|
||||
gapQuery(gap: ReadinessGap): Record<string, string> | null {
|
||||
return gapAction(gap, this.campaignId).queryParams ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<div class="scene-draft">
|
||||
<button type="button" class="draft-toggle" (click)="toggle()">
|
||||
<lucide-icon [img]="open ? ChevronDown : ChevronRight" [size]="15"></lucide-icon>
|
||||
<lucide-icon [img]="Sparkles" [size]="15" class="spark"></lucide-icon>
|
||||
<span class="draft-title">{{ 'sceneDraft.button' | translate }}</span>
|
||||
</button>
|
||||
|
||||
@if (open) {
|
||||
<div class="draft-body">
|
||||
<p class="draft-hint">{{ 'sceneDraft.hint' | translate }}</p>
|
||||
|
||||
<div class="draft-prompt">
|
||||
<textarea [(ngModel)]="instruction" rows="2"
|
||||
[placeholder]="'sceneDraft.instructionPlaceholder' | translate"></textarea>
|
||||
<div class="draft-controls">
|
||||
<label class="count-label">
|
||||
{{ 'sceneDraft.countLabel' | translate }}
|
||||
<select [(ngModel)]="count">
|
||||
@for (c of countOptions; track c) { <option [ngValue]="c">{{ c }}</option> }
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="btn-generate" (click)="generate()" [disabled]="loading">
|
||||
<lucide-icon [img]="loading ? RefreshCw : Sparkles" [size]="14" [class.spin]="loading"></lucide-icon>
|
||||
{{ (loading ? 'sceneDraft.generating' : (generated ? 'sceneDraft.regenerate' : 'sceneDraft.generate')) | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<p class="draft-error">{{ error }}</p>
|
||||
}
|
||||
|
||||
@if (generated && drafts.length === 0 && !loading) {
|
||||
<p class="draft-empty">{{ 'sceneDraft.empty' | translate }}</p>
|
||||
}
|
||||
|
||||
@if (drafts.length > 0) {
|
||||
<ul class="draft-list">
|
||||
@for (d of drafts; track $index) {
|
||||
<li class="draft-card" [class.rejected]="!d.accepted">
|
||||
<label class="draft-check">
|
||||
<input type="checkbox" [(ngModel)]="d.accepted" />
|
||||
<span class="draft-name">{{ d.name }}</span>
|
||||
</label>
|
||||
@if (d.description) { <p class="draft-desc">{{ d.description }}</p> }
|
||||
@if (d.playerNarration) { <p class="draft-narration">{{ d.playerNarration }}</p> }
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="draft-actions">
|
||||
<button type="button" class="btn-apply" (click)="apply()" [disabled]="acceptedCount === 0 || applying">
|
||||
<lucide-icon [img]="applying ? RefreshCw : Check" [size]="14" [class.spin]="applying"></lucide-icon>
|
||||
{{ (applying ? 'sceneDraft.creating' : 'sceneDraft.createSelection') | translate:{ n: acceptedCount } }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,145 @@
|
||||
.scene-draft {
|
||||
border: 1px solid #24303a;
|
||||
border-radius: 10px;
|
||||
background: #101820;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.draft-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b9e0ff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover { background: #14202b; }
|
||||
|
||||
.spark { color: #7fc8ff; }
|
||||
.draft-title { font-weight: 600; font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.draft-body {
|
||||
padding: 0.2rem 0.85rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.draft-hint {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.draft-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
|
||||
textarea { width: 100%; box-sizing: border-box; resize: vertical; min-height: 2.4rem; }
|
||||
}
|
||||
|
||||
.draft-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
|
||||
.count-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.82rem;
|
||||
select { padding: 0.25rem 0.4rem; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-generate,
|
||||
.btn-apply {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #2c5372;
|
||||
background: #16283a;
|
||||
color: #b9e0ff;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: #1d3651; color: #fff; }
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
}
|
||||
|
||||
.btn-apply {
|
||||
border-color: #2f6b46;
|
||||
background: #163326;
|
||||
color: #a7f3d0;
|
||||
margin-left: auto;
|
||||
&:hover:not(:disabled) { background: #1c4531; color: #fff; }
|
||||
}
|
||||
|
||||
.spin { animation: draft-spin 0.9s linear infinite; }
|
||||
@keyframes draft-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.draft-error { margin: 0; color: #fca5a5; font-size: 0.82rem; }
|
||||
.draft-empty { margin: 0; color: #9ca3af; font-style: italic; font-size: 0.82rem; }
|
||||
|
||||
.draft-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.draft-card {
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: #13202b;
|
||||
border: 1px solid #223341;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
&.rejected { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.draft-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
cursor: pointer;
|
||||
|
||||
input { accent-color: #7fc8ff; }
|
||||
.draft-name { font-weight: 600; color: #e5e7eb; font-size: 0.85rem; }
|
||||
}
|
||||
|
||||
.draft-desc {
|
||||
margin: 0;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.draft-narration {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
font-style: italic;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.draft-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { LucideAngularModule, Sparkles, RefreshCw, Check, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||
import { SceneDraftService } from '../../services/scene-draft.service';
|
||||
import { SceneDraft } from '../../services/scene-draft.model';
|
||||
|
||||
interface ReviewDraft extends SceneDraft {
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panneau « Générer des scènes » (Pilier A — capacité « create »). Le co-MJ ébauche
|
||||
* plusieurs scènes pour un chapitre ; l'utilisateur révise/coche, puis les scènes retenues
|
||||
* sont CRÉÉES dans le chapitre (contrairement à l'étoffage de champs, l'application persiste).
|
||||
*
|
||||
* <p>Émet {@code created} avec le nombre de scènes créées pour que le parent puisse
|
||||
* naviguer (ex. vers le graphe du chapitre).</p>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-scene-draft-panel',
|
||||
standalone: true,
|
||||
imports: [FormsModule, TranslatePipe, LucideAngularModule],
|
||||
templateUrl: './scene-draft-panel.component.html',
|
||||
styleUrls: ['./scene-draft-panel.component.scss']
|
||||
})
|
||||
export class SceneDraftPanelComponent implements OnChanges {
|
||||
|
||||
@Input() chapterId = '';
|
||||
@Input() campaignId = '';
|
||||
/** Déplie le panneau à l'arrivée (bouton « Corriger » du guidage sur un chapitre vide). */
|
||||
@Input() startOpen = false;
|
||||
/** Nombre de scènes créées, pour que le parent réagisse (navigation/refresh). */
|
||||
@Output() created = new EventEmitter<number>();
|
||||
|
||||
open = false;
|
||||
instruction = '';
|
||||
count = 4;
|
||||
loading = false;
|
||||
applying = false;
|
||||
generated = false;
|
||||
error = '';
|
||||
drafts: ReviewDraft[] = [];
|
||||
|
||||
readonly countOptions = [3, 4, 5, 6];
|
||||
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly RefreshCw = RefreshCw;
|
||||
readonly Check = Check;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
|
||||
constructor(private draftService: SceneDraftService, private translate: TranslateService) {}
|
||||
|
||||
/** Repart d'un état vierge si le chapitre ciblé change (composant réutilisé). */
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['startOpen'] && this.startOpen) {
|
||||
this.open = true;
|
||||
}
|
||||
if (changes['chapterId'] && !changes['chapterId'].firstChange) {
|
||||
this.open = false;
|
||||
this.instruction = '';
|
||||
this.loading = false;
|
||||
this.applying = false;
|
||||
this.generated = false;
|
||||
this.error = '';
|
||||
this.drafts = [];
|
||||
}
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.open = !this.open;
|
||||
}
|
||||
|
||||
generate(): void {
|
||||
if (!this.chapterId || this.loading) return;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.generated = false;
|
||||
this.drafts = [];
|
||||
this.draftService.generate(this.chapterId, this.campaignId, this.instruction.trim(), this.count).subscribe({
|
||||
next: proposal => {
|
||||
this.drafts = (proposal.scenes ?? []).map(s => ({ ...s, accepted: true }));
|
||||
this.generated = true;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.error = this.translate.instant('sceneDraft.error');
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get acceptedCount(): number {
|
||||
return this.drafts.filter(d => d.accepted).length;
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
const accepted: SceneDraft[] = this.drafts
|
||||
.filter(d => d.accepted)
|
||||
.map(d => ({ name: d.name, description: d.description, playerNarration: d.playerNarration }));
|
||||
if (accepted.length === 0 || this.applying) return;
|
||||
this.applying = true;
|
||||
this.error = '';
|
||||
this.draftService.apply(this.chapterId, { chapterId: this.chapterId, scenes: accepted }).subscribe({
|
||||
next: createdScenes => {
|
||||
this.applying = false;
|
||||
this.drafts = [];
|
||||
this.generated = false;
|
||||
this.open = false;
|
||||
this.created.emit(createdScenes.length);
|
||||
},
|
||||
error: () => {
|
||||
this.applying = false;
|
||||
this.error = this.translate.instant('sceneDraft.applyError');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
*/
|
||||
runCreateAction(event: Event, action: TreeCreateAction): void {
|
||||
event.stopPropagation();
|
||||
this.router.navigate([action.route]);
|
||||
this.router.navigate([action.route], action.queryParams ? { queryParams: action.queryParams } : {});
|
||||
}
|
||||
|
||||
/** True si le noeud a au moins un vrai enfant (utile pour le chevron). */
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
@if (sidebar.iconFor(item); as icon) {
|
||||
<lucide-icon [img]="icon" [size]="14" class="item-icon"></lucide-icon>
|
||||
}
|
||||
<!-- Pastille AVANT le libellé : les actions « + » (hover, à droite) ne la masquent jamais. -->
|
||||
@if (item.statusDot) {
|
||||
<span class="status-dot"
|
||||
[class.blocking]="item.statusDot === 'blocking'"
|
||||
[class.recommended]="item.statusDot === 'recommended'"
|
||||
[title]="item.statusDotTitle || (('readiness.dot.' + item.statusDot) | translate)"></span>
|
||||
}
|
||||
{{ item.label }}
|
||||
@if (!item.isAction && item.meta) {
|
||||
<span class="tree-item-meta">{{ item.meta }}</span>
|
||||
|
||||
@@ -47,6 +47,18 @@
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Pastille de readiness (guidage) : reste collée au libellé ; si un compteur
|
||||
// `meta` est présent, il file à droite (margin-left:auto) et la pastille reste ici.
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.blocking { background: #ef4444; }
|
||||
&.recommended { background: #fb923c; }
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
@if (report) {
|
||||
<div class="prep-panel">
|
||||
|
||||
<!-- Dernière séance -->
|
||||
@if (report.lastSession; as last) {
|
||||
<p class="prep-last">
|
||||
<lucide-icon [img]="CalendarCheck" [size]="14"></lucide-icon>
|
||||
{{ (last.active ? 'sessionPrep.lastSessionActive' : 'sessionPrep.lastSessionEnded') | translate:{ name: last.name } }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (allQuiet) {
|
||||
<p class="prep-quiet">
|
||||
<lucide-icon [img]="CheckCircle2" [size]="15"></lucide-icon>
|
||||
{{ 'sessionPrep.allQuiet' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- Où en sont les joueurs -->
|
||||
@if (report.questsInProgress.length > 0 || report.questsAvailable.length > 0) {
|
||||
<div class="prep-section">
|
||||
<h3><lucide-icon [img]="Compass" [size]="14"></lucide-icon> {{ 'sessionPrep.positionTitle' | translate }}</h3>
|
||||
@if (report.questsInProgress.length > 0) {
|
||||
<div class="prep-chip-row">
|
||||
<span class="prep-chip-label">{{ 'sessionPrep.inProgress' | translate }}</span>
|
||||
@for (q of report.questsInProgress; track q.id) {
|
||||
<span class="prep-chip-group">
|
||||
<a class="prep-chip in-progress" [routerLink]="['/campaigns', campaignId, 'quests', q.id]">{{ q.name }}</a>
|
||||
<button type="button" class="chip-action" (click)="completeQuest(q)"
|
||||
[title]="'sessionPrep.markCompleted' | translate">
|
||||
<lucide-icon [img]="Check" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (report.questsAvailable.length > 0) {
|
||||
<div class="prep-chip-row">
|
||||
<span class="prep-chip-label">{{ 'sessionPrep.available' | translate }}</span>
|
||||
@for (q of report.questsAvailable; track q.id) {
|
||||
<span class="prep-chip-group">
|
||||
<a class="prep-chip available" [routerLink]="['/campaigns', campaignId, 'quests', q.id]">{{ q.name }}</a>
|
||||
<button type="button" class="chip-action" (click)="startQuest(q)"
|
||||
[title]="'sessionPrep.markStarted' | translate">
|
||||
<lucide-icon [img]="Play" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="chip-action" (click)="completeQuest(q)"
|
||||
[title]="'sessionPrep.markCompleted' | translate">
|
||||
<lucide-icon [img]="Check" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Quêtes terminées : rappel discret + rouvrir en cas d'erreur -->
|
||||
@if (report.questsCompleted.length > 0) {
|
||||
<div class="prep-chip-row prep-completed">
|
||||
<span class="prep-chip-label">{{ 'sessionPrep.completed' | translate }}</span>
|
||||
@for (q of report.questsCompleted; track q.id) {
|
||||
<span class="prep-chip-group">
|
||||
<a class="prep-chip done" [routerLink]="['/campaigns', campaignId, 'quests', q.id]">{{ q.name }}</a>
|
||||
<button type="button" class="chip-action" (click)="reopenQuest(q)"
|
||||
[title]="'sessionPrep.reopen' | translate">
|
||||
<lucide-icon [img]="RotateCcw" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Contenu probable de la prochaine séance -->
|
||||
@if (report.hotspots.length > 0) {
|
||||
<div class="prep-section">
|
||||
<h3><lucide-icon [img]="MapPin" [size]="14"></lucide-icon> {{ 'sessionPrep.hotspotsTitle' | translate }}</h3>
|
||||
<div class="prep-chip-row">
|
||||
@for (node of report.hotspots; track node.nodeType + node.id) {
|
||||
<a class="prep-chip node" [routerLink]="nodeLink(node)">
|
||||
{{ node.name }}
|
||||
<span class="node-kind">{{ ('sessionPrep.nodeKind.' + node.nodeType) | translate }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- À combler avant de jouer -->
|
||||
@if (report.gaps.length > 0) {
|
||||
<div class="prep-section">
|
||||
<h3 class="warn"><lucide-icon [img]="AlertTriangle" [size]="14"></lucide-icon> {{ 'sessionPrep.gapsTitle' | translate }}</h3>
|
||||
<ul class="prep-gap-list">
|
||||
@for (gap of report.gaps; track gap.ruleId + gap.entityId) {
|
||||
<li class="prep-gap" [class.blocking]="gap.severity === 'BLOCKING'">
|
||||
<span class="gap-msg">{{ gap.message }}</span>
|
||||
<a class="gap-cta" [routerLink]="gapLink(gap)" [queryParams]="gapQuery(gap)">
|
||||
{{ 'sessionPrep.fix' | translate }}
|
||||
<lucide-icon [img]="ArrowRight" [size]="12"></lucide-icon>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (report.otherGapCount > 0) {
|
||||
<p class="prep-other">
|
||||
<a [routerLink]="['/campaigns', campaignId]">{{ 'sessionPrep.otherGaps' | translate:{ n: report.otherGapCount } }}</a>
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- Menaces en mouvement -->
|
||||
@if (report.clocks.length > 0) {
|
||||
<div class="prep-section">
|
||||
<h3><lucide-icon [img]="Timer" [size]="14"></lucide-icon> {{ 'sessionPrep.clocksTitle' | translate }}</h3>
|
||||
<div class="prep-chip-row">
|
||||
@for (clock of report.clocks; track clock.id) {
|
||||
<span class="prep-clock" [class.nearly]="isNearlyFull(clock)" [class.full]="isFull(clock)">
|
||||
{{ clock.name }} — {{ clock.filled }}/{{ clock.segments }}
|
||||
@if (clock.frontName) { <span class="clock-front">({{ clock.frontName }})</span> }
|
||||
@if (isFull(clock)) { <strong>{{ 'sessionPrep.clockFull' | translate }}</strong> }
|
||||
@else if (isNearlyFull(clock)) { <strong>{{ 'sessionPrep.clockNearly' | translate }}</strong> }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
.prep-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.prep-last {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.prep-quiet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
color: #86efac;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.prep-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #9ca3af;
|
||||
|
||||
&.warn { color: #fb923c; }
|
||||
}
|
||||
}
|
||||
|
||||
.prep-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.prep-chip-label {
|
||||
color: #6b7280;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
// Groupe chip + actions : le lien ouvre la quête, les petits boutons pilotent
|
||||
// la progression (▶ commencer, ✓ terminer, ↺ rouvrir).
|
||||
.prep-chip-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.chip-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 50%;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { color: #fff; border-color: #6c63ff; background: #24243a; }
|
||||
}
|
||||
}
|
||||
|
||||
.prep-completed .prep-chip-label { color: #4b5563; }
|
||||
|
||||
.prep-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition: filter 0.15s;
|
||||
|
||||
&:hover { filter: brightness(1.25); }
|
||||
|
||||
&.in-progress { background: #1e2f45; border-color: #3c6ea5; color: #93c5fd; }
|
||||
&.available { background: #14311f; border-color: #2f6b46; color: #86efac; }
|
||||
&.node { background: #1f1d3a; border-color: #3a3658; color: #cfcbe8; }
|
||||
&.done { background: #16161f; border-color: #2a2a3d; color: #6b7280; text-decoration: line-through; }
|
||||
|
||||
.node-kind {
|
||||
font-size: 0.68rem;
|
||||
color: #8b8595;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.prep-gap-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.prep-gap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: #14141f;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-left: 3px solid #fb923c;
|
||||
border-radius: 8px;
|
||||
|
||||
&.blocking { border-left-color: #ef4444; }
|
||||
|
||||
.gap-msg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gap-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.25rem 0.55rem;
|
||||
background: #1f1d3a;
|
||||
border: 1px solid #3a3658;
|
||||
border-radius: 6px;
|
||||
color: #c7d2fe;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { background: #2c2952; color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.prep-other {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
|
||||
a { color: #6b7280; text-decoration: none; }
|
||||
a:hover { color: #9ca3af; text-decoration: underline; }
|
||||
}
|
||||
|
||||
.prep-clock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: #201b3a;
|
||||
border: 1px solid #3a3068;
|
||||
color: #b9a8ff;
|
||||
font-size: 0.82rem;
|
||||
|
||||
.clock-front { color: #8b8595; font-size: 0.75rem; }
|
||||
|
||||
&.nearly { border-color: #b48a4f; color: #fcd34d; background: #3a301a; }
|
||||
&.full { border-color: #b4554f; color: #fca5a5; background: #3a1d1d; }
|
||||
|
||||
strong { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LucideAngularModule, CalendarCheck, Compass, MapPin, AlertTriangle, Timer, CheckCircle2, ArrowRight, Play, Check, RotateCcw } from 'lucide-angular';
|
||||
import { SessionPrepService } from '../../services/session-prep.service';
|
||||
import { QuestService } from '../../services/quest.service';
|
||||
import { SessionPrepReport, PrepClock, PrepNode, PrepQuest } from '../../services/session-prep.model';
|
||||
import { ReadinessGap } from '../../services/readiness.model';
|
||||
import { gapAction } from '../../campaigns/gap-action.helper';
|
||||
|
||||
/**
|
||||
* Panneau « Préparer la prochaine séance » (Phase 3 co-MJ) sur la page d'une Partie.
|
||||
* Croise la position des joueurs (quêtes actives, dernière séance), le contenu probable,
|
||||
* les manques du guidage CIBLÉS sur ce contenu, et les horloges en mouvement.
|
||||
* Read-only : chaque élément deep-linke vers l'éditeur / la fiche concernée.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-session-prep-panel',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslatePipe, LucideAngularModule],
|
||||
templateUrl: './session-prep-panel.component.html',
|
||||
styleUrls: ['./session-prep-panel.component.scss']
|
||||
})
|
||||
export class SessionPrepPanelComponent implements OnChanges {
|
||||
|
||||
@Input() playthroughId = '';
|
||||
@Input() campaignId = '';
|
||||
|
||||
report: SessionPrepReport | null = null;
|
||||
loading = false;
|
||||
|
||||
readonly CalendarCheck = CalendarCheck;
|
||||
readonly Compass = Compass;
|
||||
readonly MapPin = MapPin;
|
||||
readonly AlertTriangle = AlertTriangle;
|
||||
readonly Timer = Timer;
|
||||
readonly CheckCircle2 = CheckCircle2;
|
||||
readonly ArrowRight = ArrowRight;
|
||||
readonly Play = Play;
|
||||
readonly Check = Check;
|
||||
readonly RotateCcw = RotateCcw;
|
||||
|
||||
constructor(private prepService: SessionPrepService,
|
||||
private questService: QuestService) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['playthroughId'] && this.playthroughId) {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
this.loading = true;
|
||||
this.report = null;
|
||||
this.prepService.getPrep(this.playthroughId).subscribe({
|
||||
next: r => { this.report = r; this.loading = false; },
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
/** Rien à signaler : ni quête active, ni manque ciblé, ni horloge en mouvement. */
|
||||
get allQuiet(): boolean {
|
||||
const r = this.report;
|
||||
return !!r && r.questsInProgress.length === 0 && r.questsAvailable.length === 0
|
||||
&& r.gaps.length === 0 && r.clocks.length === 0;
|
||||
}
|
||||
|
||||
/** Lien profond vers un nœud probable (chapitre ou scène). */
|
||||
nodeLink(node: PrepNode): string[] {
|
||||
const base = ['/campaigns', this.campaignId, 'arcs', node.arcId ?? ''];
|
||||
return node.nodeType === 'SCENE'
|
||||
? [...base, 'chapters', node.chapterId ?? '', 'scenes', node.id]
|
||||
: [...base, 'chapters', node.id];
|
||||
}
|
||||
|
||||
/** Lien profond « réparateur » — mapping partagé avec le panneau readiness du hub. */
|
||||
gapLink(gap: ReadinessGap): string[] {
|
||||
return gapAction(gap, this.campaignId).link;
|
||||
}
|
||||
|
||||
gapQuery(gap: ReadinessGap): Record<string, string> | null {
|
||||
return gapAction(gap, this.campaignId).queryParams ?? null;
|
||||
}
|
||||
|
||||
// ─────────────── Progression des quêtes (pilotée depuis les chips) ───────────────
|
||||
// C'est ICI que le MJ tient à jour « où en sont les joueurs » : Disponible → ▶ En
|
||||
// cours → ✓ Terminée (elle disparaît des pistes), ↺ pour rouvrir en cas d'erreur.
|
||||
|
||||
private setProgression(quest: PrepQuest, status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED'): void {
|
||||
this.questService.setProgression(this.playthroughId, quest.id, status).subscribe({
|
||||
next: () => this.load(), // recharge le rapport : listes + manques ciblés à jour
|
||||
error: () => console.error('Erreur lors de la mise à jour de la progression')
|
||||
});
|
||||
}
|
||||
|
||||
startQuest(quest: PrepQuest): void { this.setProgression(quest, 'IN_PROGRESS'); }
|
||||
completeQuest(quest: PrepQuest): void { this.setProgression(quest, 'COMPLETED'); }
|
||||
reopenQuest(quest: PrepQuest): void { this.setProgression(quest, 'NOT_STARTED'); }
|
||||
|
||||
isNearlyFull(clock: PrepClock): boolean {
|
||||
return clock.segments - clock.filled === 1;
|
||||
}
|
||||
|
||||
isFull(clock: PrepClock): boolean {
|
||||
return clock.filled >= clock.segments;
|
||||
}
|
||||
}
|
||||
@@ -378,7 +378,9 @@
|
||||
"removeLink": "Remove link",
|
||||
"increment": "One more",
|
||||
"decrement": "One less",
|
||||
"noMatch": "No matching enemy"
|
||||
"noMatch": "No matching enemy",
|
||||
"broken": "Missing reference",
|
||||
"brokenTitle": "This scene references a deleted bestiary entry (enemy deleted then recreated?). Click × to remove the dead reference."
|
||||
},
|
||||
"personaView": {
|
||||
"empty": "This sheet is still empty."
|
||||
@@ -508,7 +510,11 @@
|
||||
"chatSuggestion3": "Suggest a two-act resolution",
|
||||
"deleteTitle": "Delete arc",
|
||||
"deleteMessage": "Delete the arc \"{{name}}\"?",
|
||||
"irreversible": "This action is irreversible."
|
||||
"irreversible": "This action is irreversible.",
|
||||
"optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.",
|
||||
"themesStakesSectionTitle": "Themes and stakes",
|
||||
"gmNotesSectionTitle": "GM notes",
|
||||
"rewardsResolutionSectionTitle": "Rewards and resolution"
|
||||
},
|
||||
"arcView": {
|
||||
"subtitleHub": "Hub arc (non-linear quests)",
|
||||
@@ -537,7 +543,10 @@
|
||||
"deleteCascade": "This action will also delete: {{parts}}.",
|
||||
"irreversible": "This action is irreversible.",
|
||||
"deleteConfirmTitle": "Delete arc",
|
||||
"deleteConfirmMessage": "Delete the arc \"{{name}}\"?"
|
||||
"deleteConfirmMessage": "Delete the arc \"{{name}}\"?",
|
||||
"questsTitle": "Quests in this arc",
|
||||
"questsEmpty": "No quests yet. Add one to populate this hub.",
|
||||
"createQuest": "New quest"
|
||||
},
|
||||
"campaigns": {
|
||||
"heroTitle": "Your Campaigns",
|
||||
@@ -625,7 +634,13 @@
|
||||
"playthroughsPlural": "{{n}} playthroughs (with their PCs, sessions, facts)",
|
||||
"alsoDeletes": "Will also be deleted: {{items}}.",
|
||||
"irreversible": "This action is irreversible."
|
||||
}
|
||||
},
|
||||
"quickScene": "Scene",
|
||||
"quickSceneFirst": "Add a scene",
|
||||
"organizeInArcs": "or organize into arcs",
|
||||
"defaultArcName": "Main arc",
|
||||
"defaultChapterName": "Chapter 1",
|
||||
"flatModeHint": "Simple mode: your scenes are in the left panel. Add one, or organize the campaign into arcs."
|
||||
},
|
||||
"campaignImport": {
|
||||
"pageTitle": "Import a campaign",
|
||||
@@ -726,7 +741,11 @@
|
||||
"chatSuggestion3": "Suggest a striking opening scene",
|
||||
"deleteTitle": "Delete chapter",
|
||||
"deleteMessage": "Delete the chapter \"{{name}}\"?",
|
||||
"irreversible": "This action is irreversible."
|
||||
"irreversible": "This action is irreversible.",
|
||||
"optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.",
|
||||
"gmNotesSectionTitle": "GM notes",
|
||||
"objectivesStakesSectionTitle": "Objectives and stakes",
|
||||
"quest": "Quest"
|
||||
},
|
||||
"chapterGraph": {
|
||||
"chapter": "Chapter",
|
||||
@@ -734,8 +753,31 @@
|
||||
"subtitle": "Flowchart of scenes and their narrative branches",
|
||||
"back": "Back to chapter",
|
||||
"empty": "This chapter has no scenes. Create some to see the map appear.",
|
||||
"hint": "💡 Click a scene to open it, or drag it to rearrange the map. Scenes not connected to the entry point (scene of order 1) appear at the bottom.",
|
||||
"allCampaigns": "All campaigns"
|
||||
"hint": "💡 Click a scene to open it, drag it to move it. Drag the dot under a node onto another to create a link; click a link to edit or delete it. Wheel = zoom, drag the background = pan the view.",
|
||||
"allCampaigns": "All campaigns",
|
||||
"addScene": "Add a scene",
|
||||
"newSceneName": "New scene",
|
||||
"titleFlat": "Scene map",
|
||||
"backFlat": "Back to campaign",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"fit": "Fit to content",
|
||||
"linkEditor": {
|
||||
"label": "Choice label",
|
||||
"labelPlaceholder": "e.g. \"Follow the trail\"",
|
||||
"condition": "Condition (GM notes)",
|
||||
"conditionPlaceholder": "e.g. \"if the PCs have the key\"",
|
||||
"type": "Link type",
|
||||
"delete": "Delete link",
|
||||
"close": "Close",
|
||||
"kind": {
|
||||
"EXIT": "Exit",
|
||||
"CLUE": "Clue",
|
||||
"LEAD": "Lead"
|
||||
}
|
||||
},
|
||||
"rename": "Rename scene",
|
||||
"renamePlaceholder": "Scene name"
|
||||
},
|
||||
"chapterView": {
|
||||
"chapter": "Chapter",
|
||||
@@ -941,8 +983,8 @@
|
||||
"aiAssistantTitle": "Open the AI Assistant to discuss this scene",
|
||||
"illustrationsLabel": "Illustrations",
|
||||
"illustrationsHint": "NPC portraits, visual mood, evocative scenes...",
|
||||
"battlemapLabel": "Battlemap (Foundry export)",
|
||||
"battlemapHint": "Battle map to export to Foundry: media (image/video) + Universal VTT .json/.dd2vtt file (Dungeon Alchemist, Dungeondraft...). Not displayed in the app.",
|
||||
"battlemapLabel": "Battlemaps (Foundry export)",
|
||||
"battlemapHint": "Battle maps to export to Foundry: media (image/video) + Universal VTT .json/.dd2vtt file (Dungeon Alchemist, Dungeondraft...). Add several maps for variants of the same scene (day/night, floors...). Not displayed in the app.",
|
||||
"battlemapMedia": "Media (image / video)",
|
||||
"battlemapData": "Data (.json / .dd2vtt)",
|
||||
"battlemapChoose": "Choose a file",
|
||||
@@ -951,7 +993,7 @@
|
||||
"battlemapRemove": "Remove",
|
||||
"nameLabel": "Scene title *",
|
||||
"namePlaceholder": "E.g.: Arrival at the village",
|
||||
"descriptionLabel": "Short description *",
|
||||
"descriptionLabel": "Short description",
|
||||
"descriptionPlaceholder": "One or two sentences summarizing what happens...",
|
||||
"iconLabel": "Icon",
|
||||
"contextSectionTitle": "Context and mood",
|
||||
@@ -1007,7 +1049,15 @@
|
||||
"battlemapSourceDD": "DungeonDraft (.dd2vtt)",
|
||||
"battlemapDd2vttSlot": ".dd2vtt file",
|
||||
"battlemapDd2vttHint": "The .dd2vtt contains the map AND the walls/lights; the embedded image is extracted automatically for the Foundry export.",
|
||||
"battlemapDd2vttNoImage": "No image found in this .dd2vtt — the exported map will have no background."
|
||||
"battlemapDd2vttNoImage": "No image found in this .dd2vtt — the exported map will have no background.",
|
||||
"optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.",
|
||||
"nodeTypeLabel": "Node type",
|
||||
"nodeTypeHint": "Level 2: typing the scene (location, encounter, NPC…) colors the node in the graph view. Optional.",
|
||||
"branchKindLabel": "Link type",
|
||||
"branchTargetBroken": "Destination not found (deleted scene): pick a new destination or remove this branch.",
|
||||
"battlemapAdd": "Add a map",
|
||||
"battlemapLabelPlaceholder": "Label (e.g.: Day, Night, Floor 1…)",
|
||||
"battlemapRemoveMap": "Remove this map"
|
||||
},
|
||||
"sceneView": {
|
||||
"subtitle": "Scene",
|
||||
@@ -1411,6 +1461,20 @@
|
||||
"deleteEntryConfirm": {
|
||||
"title": "Delete this entry?",
|
||||
"message": "This log entry will be permanently deleted."
|
||||
},
|
||||
"pinned": {
|
||||
"label": "Current scene",
|
||||
"open": "Open the scene (new tab)",
|
||||
"unpin": "Unpin",
|
||||
"showNarration": "Show player narration",
|
||||
"hideNarration": "Hide narration"
|
||||
},
|
||||
"recap": {
|
||||
"button": "“Previously…” recap",
|
||||
"loading": "Writing the recap…",
|
||||
"title": "Previously ({{name}})",
|
||||
"addToJournal": "Add to journal",
|
||||
"error": "Recap failed. Is the Brain (AI) running?"
|
||||
}
|
||||
},
|
||||
"sessionReferencePanel": {
|
||||
@@ -1423,7 +1487,12 @@
|
||||
"playerCharacters": "Player characters",
|
||||
"nonPlayerCharacters": "Non-player characters",
|
||||
"emptyCharacters": "No characters in this campaign.",
|
||||
"emptyScenes": "No narrative arc yet. Build your campaign scenario to find it here."
|
||||
"emptyScenes": "No narrative arc yet. Build your campaign scenario to find it here.",
|
||||
"tabPlay": "Play",
|
||||
"playClocks": "Clocks",
|
||||
"playFacts": "Facts",
|
||||
"playNoPlaythrough": "This session is not attached to any playthrough.",
|
||||
"pinScene": "Pin as current scene"
|
||||
},
|
||||
"sessionDicePanel": {
|
||||
"count": "Count",
|
||||
@@ -1521,7 +1590,9 @@
|
||||
"deleteCascade": "This action will also delete: {{parts}}.",
|
||||
"irreversible": "This action is irreversible.",
|
||||
"deleteTitle": "Delete playthrough",
|
||||
"deleteMessage": "Delete \"{{name}}\"?"
|
||||
"deleteMessage": "Delete \"{{name}}\"?",
|
||||
"clocksTitle": "Progress clocks",
|
||||
"prepTitle": "Prepare the next session"
|
||||
},
|
||||
"playthroughFlagsPage": {
|
||||
"title": "Facts — {{name}}",
|
||||
@@ -1546,7 +1617,9 @@
|
||||
"allCampaigns": "All campaigns",
|
||||
"sectionCharacters": "Characters",
|
||||
"sectionNarration": "Narration",
|
||||
"sectionTools": "Tools"
|
||||
"sectionTools": "Tools",
|
||||
"scenesGroup": "Scenes",
|
||||
"quests": "Quests"
|
||||
},
|
||||
"services": {
|
||||
"importFailed": "Import failed.",
|
||||
@@ -1563,5 +1636,244 @@
|
||||
"zoomOut": "Zoom out",
|
||||
"dragHint": "Drag to reframe",
|
||||
"remove": "Remove this image"
|
||||
},
|
||||
"questList": {
|
||||
"title": "Quests",
|
||||
"create": "New quest",
|
||||
"hint": "Quests are orthogonal to the structure: they carry objectives and unlock conditions, and can span several scenes.",
|
||||
"empty": "No quests yet.",
|
||||
"conditional": "Unlock conditions",
|
||||
"deleteTitle": "Delete quest",
|
||||
"deleteMessage": "Delete the quest \"{{name}}\"?",
|
||||
"irreversible": "This action is irreversible."
|
||||
},
|
||||
"questEdit": {
|
||||
"newTitle": "New quest",
|
||||
"fallbackTitle": "Quest",
|
||||
"subtitle": "Quest",
|
||||
"optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.",
|
||||
"nameLabel": "Quest title *",
|
||||
"namePlaceholder": "E.g.: Save the missing merchant",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Describe the objective and context of the quest...",
|
||||
"iconLabel": "Icon",
|
||||
"unlockSectionTitle": "Unlock conditions",
|
||||
"unlockHint": "All conditions must be met (AND) for the quest to unlock in a Playthrough. Empty = available from the start. Progression is managed in the Playthrough screen.",
|
||||
"gmNotesSectionTitle": "GM notes",
|
||||
"gmNotesPlaceholder": "Private notes about the quest, twists, secrets...",
|
||||
"objectivesSectionTitle": "Objectives and stakes",
|
||||
"playerObjectivesLabel": "Player objectives",
|
||||
"playerObjectivesPlaceholder": "What must the players accomplish?",
|
||||
"narrativeStakesLabel": "Narrative stakes",
|
||||
"narrativeStakesPlaceholder": "What are the dramatic stakes?",
|
||||
"deleteTitle": "Delete quest",
|
||||
"deleteMessage": "Delete the quest \"{{name}}\"?",
|
||||
"irreversible": "This action is irreversible.",
|
||||
"illustrationsLabel": "Illustrations",
|
||||
"illustrationsHint": "Portraits, moods and notable locations of the quest.",
|
||||
"nodesSectionTitle": "Narrative nodes traversed",
|
||||
"nodesHint": "OPTIONAL — only use this if the quest should TRAVERSE existing content (“the quest passes through these places”). These are links: removing a node deletes nothing. Without nodes, the quest automatically gets its own scene space (created on the fly, with its map).",
|
||||
"relatedPagesLabel": "Linked Lore pages",
|
||||
"relatedPagesHint": "Link this quest to NPCs, places or Lore elements that appear in it.",
|
||||
"noLoreHint": "💡 This campaign is not linked to any universe. Link it to a Lore from the campaign screen to attach Lore pages to this quest."
|
||||
},
|
||||
"nodePicker": {
|
||||
"chapter": "Chapter",
|
||||
"scene": "Scene",
|
||||
"selectChapter": "Add a chapter...",
|
||||
"selectScene": "Add a scene...",
|
||||
"addChapter": "Add",
|
||||
"addScene": "Add",
|
||||
"empty": "No node attached yet. This quest isn't linked to any chapter or scene.",
|
||||
"deletedNode": "(deleted node)",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"remove": "Remove this node"
|
||||
},
|
||||
"questView": {
|
||||
"subtitle": "Quest",
|
||||
"synopsisTitle": "Description",
|
||||
"playerObjectivesTitle": "Player objectives",
|
||||
"narrativeStakesTitle": "Narrative stakes",
|
||||
"prerequisitesTitle": "Unlock conditions",
|
||||
"nodesTitle": "Narrative nodes traversed",
|
||||
"gmNotesTitle": "GM notes",
|
||||
"relatedPagesTitle": "Linked Lore pages",
|
||||
"notProvided": "Not provided",
|
||||
"deleteTitle": "Delete quest",
|
||||
"deleteMessage": "Delete the quest \"{{name}}\"?",
|
||||
"irreversible": "This action is irreversible.",
|
||||
"deletedPage": "(deleted page)",
|
||||
"deletedQuest": "(deleted quest)",
|
||||
"deletedNode": "(deleted node)",
|
||||
"prereqQuestCompleted": "Quest \"{{name}}\" completed",
|
||||
"prereqSessionReached": "Session {{n}} reached",
|
||||
"prereqFlagSet": "Fact: {{flag}}",
|
||||
"mapButton": "Scene map"
|
||||
},
|
||||
"sceneType": {
|
||||
"GENERIC": "Generic scene",
|
||||
"LOCATION": "Location",
|
||||
"ENCOUNTER": "Encounter",
|
||||
"NPC": "NPC situation",
|
||||
"EVENT": "Event",
|
||||
"REVELATION": "Revelation"
|
||||
},
|
||||
"linkType": {
|
||||
"EXIT": "Exit",
|
||||
"CLUE": "Clue",
|
||||
"LEAD": "Lead"
|
||||
},
|
||||
"clocksManager": {
|
||||
"empty": "No clocks yet. Create one to track a threat or a countdown that advances as the game unfolds.",
|
||||
"namePlaceholder": "Clock name (e.g. \"The cult completes its ritual\")",
|
||||
"segments": "Segments",
|
||||
"segmentsN": "{{n}} segments",
|
||||
"add": "Add",
|
||||
"advance": "Advance one segment",
|
||||
"regress": "Roll back one segment",
|
||||
"triggerType": "Auto trigger",
|
||||
"triggerOption": {
|
||||
"NONE": "Manual",
|
||||
"FLAG_SET": "When a Fact becomes true",
|
||||
"QUEST_COMPLETED": "When a quest completes",
|
||||
"SESSION_ENDED": "At session end"
|
||||
},
|
||||
"flagPlaceholder": "Fact name",
|
||||
"quest": "Quest",
|
||||
"selectQuest": "— Choose a quest —",
|
||||
"triggerFlag": "Fact: {{name}}",
|
||||
"triggerQuest": "Quest: {{name}}",
|
||||
"triggerSession": "Session end",
|
||||
"deletedQuest": "(deleted quest)",
|
||||
"ungrouped": "Loose clocks",
|
||||
"deleteFront": "Delete this front (its clocks become loose)",
|
||||
"frontPlaceholder": "New front (threat: \"The rise of the Cult\"…)",
|
||||
"addFront": "Add a front",
|
||||
"front": "Front",
|
||||
"noFront": "— No front —",
|
||||
"edit": "Edit"
|
||||
},
|
||||
"readiness": {
|
||||
"title": "Next steps",
|
||||
"badge": {
|
||||
"DRAFT": "Draft",
|
||||
"PLAYABLE": "Playable",
|
||||
"POLISHED": "Polished"
|
||||
},
|
||||
"blockingN": "{{n}} blocking",
|
||||
"recommendedN": "{{n}} recommended",
|
||||
"allSet": "All set: this campaign is ready to play.",
|
||||
"fix": "Fix",
|
||||
"dot": {
|
||||
"blocking": "To fix: a blocking gap, here or in an item below. See “Next steps” on the campaign.",
|
||||
"recommended": "Suggested: a recommended gap, here or below. See “Next steps” on the campaign.",
|
||||
"more": "+ {{n}} more gap(s) — see “Next steps”"
|
||||
}
|
||||
},
|
||||
"sceneAssist": {
|
||||
"button": "Flesh out with AI",
|
||||
"hint": "The co-GM suggests values for the scene's fields. Nothing is saved: pick what fills the form, then save as usual.",
|
||||
"instructionPlaceholder": "Optional instruction (e.g. \"make the mood more ominous\", \"add a clue about the cult\")…",
|
||||
"generate": "Generate suggestions",
|
||||
"generating": "Generating…",
|
||||
"regenerate": "Regenerate",
|
||||
"applySelection": "Apply selection ({{n}})",
|
||||
"empty": "The AI had nothing new to suggest — the scene may already be complete, or rephrase your instruction.",
|
||||
"error": "Generation failed. Is the Brain (AI) running?",
|
||||
"current": "Current",
|
||||
"proposed": "Proposed",
|
||||
"field": {
|
||||
"description": "Description",
|
||||
"location": "Location",
|
||||
"timing": "Timing",
|
||||
"atmosphere": "Atmosphere",
|
||||
"playerNarration": "Player narration",
|
||||
"choicesConsequences": "Choices & consequences",
|
||||
"combatDifficulty": "Combat difficulty",
|
||||
"enemies": "Enemies",
|
||||
"gmSecretNotes": "GM secret notes"
|
||||
}
|
||||
},
|
||||
"entityAssist": {
|
||||
"button": "Flesh out with AI",
|
||||
"hint": "The co-GM suggests values for the fields. Nothing is saved: pick what fills the form, then save as usual.",
|
||||
"instructionPlaceholder": "Optional instruction (e.g. \"make the mood more ominous\", \"add a clue about the cult\")…",
|
||||
"generate": "Generate suggestions",
|
||||
"generating": "Generating…",
|
||||
"regenerate": "Regenerate",
|
||||
"applySelection": "Apply selection ({{n}})",
|
||||
"empty": "The AI had nothing new to suggest — the entity may already be complete, or rephrase your instruction.",
|
||||
"error": "Generation failed. Is the Brain (AI) running?",
|
||||
"current": "Current",
|
||||
"proposed": "Proposed",
|
||||
"field": {
|
||||
"description": "Description",
|
||||
"location": "Location",
|
||||
"timing": "Timing",
|
||||
"atmosphere": "Atmosphere",
|
||||
"playerNarration": "Player narration",
|
||||
"choicesConsequences": "Choices & consequences",
|
||||
"combatDifficulty": "Combat difficulty",
|
||||
"enemies": "Enemies",
|
||||
"gmSecretNotes": "GM secret notes",
|
||||
"gmNotes": "GM notes",
|
||||
"playerObjectives": "Player objectives",
|
||||
"narrativeStakes": "Narrative stakes",
|
||||
"themes": "Themes",
|
||||
"stakes": "Stakes",
|
||||
"rewards": "Rewards",
|
||||
"resolution": "Resolution"
|
||||
}
|
||||
},
|
||||
"sceneDraft": {
|
||||
"button": "Generate scenes",
|
||||
"hint": "The co-GM suggests several scenes for this chapter. Tick the ones to keep; they are created in the chapter (you can refine them afterwards).",
|
||||
"instructionPlaceholder": "Optional instruction (e.g. \"an infiltration, a fight, then a reveal\")…",
|
||||
"countLabel": "How many",
|
||||
"generate": "Suggest scenes",
|
||||
"generating": "Generating…",
|
||||
"regenerate": "Regenerate",
|
||||
"createSelection": "Create selection ({{n}})",
|
||||
"creating": "Creating…",
|
||||
"empty": "The AI suggested no scenes — try rephrasing your instruction.",
|
||||
"error": "Generation failed. Is the Brain (AI) running?",
|
||||
"applyError": "Failed to create the scenes."
|
||||
},
|
||||
"sessionPrep": {
|
||||
"lastSessionActive": "Session in progress: {{name}}",
|
||||
"lastSessionEnded": "Last session: {{name}} (ended)",
|
||||
"allQuiet": "All clear: no active quest and no gap in upcoming content. Start the next session whenever you like.",
|
||||
"positionTitle": "Where the players are",
|
||||
"inProgress": "In progress:",
|
||||
"available": "Available:",
|
||||
"hotspotsTitle": "Likely content for the next session",
|
||||
"nodeKind": {
|
||||
"CHAPTER": "chapter",
|
||||
"SCENE": "scene"
|
||||
},
|
||||
"gapsTitle": "To fill in before playing",
|
||||
"fix": "Fix",
|
||||
"otherGaps": "+ {{n}} other gap(s) elsewhere in the campaign",
|
||||
"clocksTitle": "Threats in motion",
|
||||
"clockNearly": "nearly full!",
|
||||
"clockFull": "full!",
|
||||
"completed": "Completed:",
|
||||
"markStarted": "Mark as in progress (the players started it)",
|
||||
"markCompleted": "Mark as completed (removed from likely leads)",
|
||||
"reopen": "Reopen (back to not started)"
|
||||
},
|
||||
"foundryDialog": {
|
||||
"title": "Export to Foundry",
|
||||
"intro": "Choose what the Foundry module should import. For example, to run your combats, export only the maps and the enemies prepared in your scenes.",
|
||||
"presetAll": "Everything",
|
||||
"presetMapsOnly": "Maps & enemies only",
|
||||
"optMaps": "Maps & enemies",
|
||||
"optMapsHint": "One Foundry Scene per battlemap (day/night variants included), with the tokens of the enemies linked to each scene.",
|
||||
"optJournals": "Narrative journals",
|
||||
"optJournalsHint": "Arcs, chapters, quests and scenes as journals (texts + illustrations), plus NPC and bestiary sheets.",
|
||||
"optTables": "Random tables",
|
||||
"optTablesHint": "Your encounter and event tables as Foundry RollTables.",
|
||||
"confirm": "Export"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +378,9 @@
|
||||
"removeLink": "Retirer le lien",
|
||||
"increment": "Un de plus",
|
||||
"decrement": "Un de moins",
|
||||
"noMatch": "Aucun ennemi ne correspond"
|
||||
"noMatch": "Aucun ennemi ne correspond",
|
||||
"broken": "Référence introuvable",
|
||||
"brokenTitle": "Cette scène référence une fiche du bestiaire supprimée (ennemi supprimé puis recréé ?). Cliquez sur × pour retirer la référence morte."
|
||||
},
|
||||
"personaView": {
|
||||
"empty": "Cette fiche est encore vide."
|
||||
@@ -508,7 +510,11 @@
|
||||
"chatSuggestion3": "Suggère un dénouement en deux actes",
|
||||
"deleteTitle": "Supprimer l'arc",
|
||||
"deleteMessage": "Supprimer l'arc \"{{name}}\" ?",
|
||||
"irreversible": "Cette action est irréversible."
|
||||
"irreversible": "Cette action est irréversible.",
|
||||
"optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.",
|
||||
"themesStakesSectionTitle": "Thèmes et enjeux",
|
||||
"gmNotesSectionTitle": "Notes du MJ",
|
||||
"rewardsResolutionSectionTitle": "Récompenses et dénouement"
|
||||
},
|
||||
"arcView": {
|
||||
"subtitleHub": "Arc en hub (quêtes non linéaires)",
|
||||
@@ -537,7 +543,10 @@
|
||||
"deleteCascade": "Cette action supprimera aussi : {{parts}}.",
|
||||
"irreversible": "Cette action est irréversible.",
|
||||
"deleteConfirmTitle": "Supprimer l'arc",
|
||||
"deleteConfirmMessage": "Supprimer l'arc \"{{name}}\" ?"
|
||||
"deleteConfirmMessage": "Supprimer l'arc \"{{name}}\" ?",
|
||||
"questsTitle": "Quêtes de cet arc",
|
||||
"questsEmpty": "Aucune quête pour l'instant. Ajoutez-en une pour peupler ce hub.",
|
||||
"createQuest": "Nouvelle quête"
|
||||
},
|
||||
"campaigns": {
|
||||
"heroTitle": "Vos Campagnes",
|
||||
@@ -625,7 +634,13 @@
|
||||
"playthroughsPlural": "{{n}} parties (avec ses PJ, sessions, faits)",
|
||||
"alsoDeletes": "Sera aussi supprimé : {{items}}.",
|
||||
"irreversible": "Cette action est irréversible."
|
||||
}
|
||||
},
|
||||
"quickScene": "Scène",
|
||||
"quickSceneFirst": "Ajouter une scène",
|
||||
"organizeInArcs": "ou organiser en arcs",
|
||||
"defaultArcName": "Arc principal",
|
||||
"defaultChapterName": "Chapitre 1",
|
||||
"flatModeHint": "Mode simple : vos scènes sont dans le volet de gauche. Ajoutez-en une, ou organisez la campagne en arcs."
|
||||
},
|
||||
"campaignImport": {
|
||||
"pageTitle": "Importer une campagne",
|
||||
@@ -726,7 +741,11 @@
|
||||
"chatSuggestion3": "Suggère une scène d'ouverture marquante",
|
||||
"deleteTitle": "Supprimer le chapitre",
|
||||
"deleteMessage": "Supprimer le chapitre \"{{name}}\" ?",
|
||||
"irreversible": "Cette action est irréversible."
|
||||
"irreversible": "Cette action est irréversible.",
|
||||
"optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.",
|
||||
"gmNotesSectionTitle": "Notes du MJ",
|
||||
"objectivesStakesSectionTitle": "Objectifs et enjeux",
|
||||
"quest": "Quête"
|
||||
},
|
||||
"chapterGraph": {
|
||||
"chapter": "Chapitre",
|
||||
@@ -734,8 +753,31 @@
|
||||
"subtitle": "Organigramme des scènes et de leurs branches narratives",
|
||||
"back": "Retour au chapitre",
|
||||
"empty": "Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.",
|
||||
"hint": "💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.",
|
||||
"allCampaigns": "Toutes les campagnes"
|
||||
"hint": "💡 Cliquez une scène pour l'ouvrir, glissez-la pour la déplacer. Glissez la pastille sous un nœud vers un autre pour créer un lien ; cliquez un lien pour l'éditer ou le supprimer. Molette = zoom, glisser le fond = déplacer la vue.",
|
||||
"allCampaigns": "Toutes les campagnes",
|
||||
"addScene": "Ajouter une scène",
|
||||
"newSceneName": "Nouvelle scène",
|
||||
"titleFlat": "Carte des scènes",
|
||||
"backFlat": "Retour à la campagne",
|
||||
"zoomIn": "Zoom avant",
|
||||
"zoomOut": "Zoom arrière",
|
||||
"fit": "Recadrer sur l'ensemble",
|
||||
"linkEditor": {
|
||||
"label": "Libellé du choix",
|
||||
"labelPlaceholder": "ex : « Suivre la piste »",
|
||||
"condition": "Condition (notes MJ)",
|
||||
"conditionPlaceholder": "ex : « si les PJ ont la clé »",
|
||||
"type": "Type de lien",
|
||||
"delete": "Supprimer le lien",
|
||||
"close": "Fermer",
|
||||
"kind": {
|
||||
"EXIT": "Sortie",
|
||||
"CLUE": "Indice",
|
||||
"LEAD": "Piste"
|
||||
}
|
||||
},
|
||||
"rename": "Renommer la scène",
|
||||
"renamePlaceholder": "Nom de la scène"
|
||||
},
|
||||
"chapterView": {
|
||||
"chapter": "Chapitre",
|
||||
@@ -941,8 +983,8 @@
|
||||
"aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cette scène",
|
||||
"illustrationsLabel": "Illustrations",
|
||||
"illustrationsHint": "Portraits des PNJ, ambiance visuelle, scenes evocatrices...",
|
||||
"battlemapLabel": "Battlemap (export Foundry)",
|
||||
"battlemapHint": "Carte de combat a exporter vers Foundry : media (image/video) + fichier .json/.dd2vtt Universal VTT (Dungeon Alchemist, Dungeondraft...). Non affichee dans l'appli.",
|
||||
"battlemapLabel": "Battlemaps (export Foundry)",
|
||||
"battlemapHint": "Cartes de combat a exporter vers Foundry : media (image/video) + fichier .json/.dd2vtt Universal VTT (Dungeon Alchemist, Dungeondraft...). Ajoutez plusieurs cartes pour les variantes d'une meme scene (jour/nuit, etages...). Non affichees dans l'appli.",
|
||||
"battlemapMedia": "Media (image / video)",
|
||||
"battlemapData": "Donnees (.json / .dd2vtt)",
|
||||
"battlemapChoose": "Choisir un fichier",
|
||||
@@ -951,7 +993,7 @@
|
||||
"battlemapRemove": "Retirer",
|
||||
"nameLabel": "Titre de la scène *",
|
||||
"namePlaceholder": "Ex: Arrivée au village",
|
||||
"descriptionLabel": "Description courte *",
|
||||
"descriptionLabel": "Description courte",
|
||||
"descriptionPlaceholder": "Résumé en une ou deux phrases de ce qui se passe...",
|
||||
"iconLabel": "Icône",
|
||||
"contextSectionTitle": "Contexte et ambiance",
|
||||
@@ -1007,7 +1049,15 @@
|
||||
"battlemapSourceDD": "DungeonDraft (.dd2vtt)",
|
||||
"battlemapDd2vttSlot": "Fichier .dd2vtt",
|
||||
"battlemapDd2vttHint": "Le .dd2vtt contient la carte ET les murs/lumières ; l'image embarquée est extraite automatiquement pour l'export Foundry.",
|
||||
"battlemapDd2vttNoImage": "Aucune image trouvée dans ce .dd2vtt — la carte exportée n'aura pas de fond."
|
||||
"battlemapDd2vttNoImage": "Aucune image trouvée dans ce .dd2vtt — la carte exportée n'aura pas de fond.",
|
||||
"optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.",
|
||||
"nodeTypeLabel": "Type de nœud",
|
||||
"nodeTypeHint": "Niveau 2 : typer la scène (lieu, rencontre, PNJ…) colore le nœud dans la vue graphe. Optionnel.",
|
||||
"branchKindLabel": "Type de lien",
|
||||
"branchTargetBroken": "Destination introuvable (scène supprimée) : choisissez une nouvelle destination ou retirez cette branche.",
|
||||
"battlemapAdd": "Ajouter une carte",
|
||||
"battlemapLabelPlaceholder": "Libellé (ex : Jour, Nuit, Étage 1…)",
|
||||
"battlemapRemoveMap": "Retirer cette carte"
|
||||
},
|
||||
"sceneView": {
|
||||
"subtitle": "Scène",
|
||||
@@ -1411,6 +1461,20 @@
|
||||
"deleteEntryConfirm": {
|
||||
"title": "Supprimer cette entrée ?",
|
||||
"message": "Cette entrée du journal sera définitivement supprimée."
|
||||
},
|
||||
"pinned": {
|
||||
"label": "Scène courante",
|
||||
"open": "Ouvrir la scène (nouvel onglet)",
|
||||
"unpin": "Retirer l'épingle",
|
||||
"showNarration": "Afficher la narration joueurs",
|
||||
"hideNarration": "Masquer la narration"
|
||||
},
|
||||
"recap": {
|
||||
"button": "Récap « précédemment… »",
|
||||
"loading": "Rédaction du récap…",
|
||||
"title": "Précédemment ({{name}})",
|
||||
"addToJournal": "Consigner au journal",
|
||||
"error": "Échec du récap. Le Brain (IA) est-il démarré ?"
|
||||
}
|
||||
},
|
||||
"sessionReferencePanel": {
|
||||
@@ -1423,7 +1487,12 @@
|
||||
"playerCharacters": "Personnages joueurs",
|
||||
"nonPlayerCharacters": "Personnages non-joueurs",
|
||||
"emptyCharacters": "Aucun personnage dans cette campagne.",
|
||||
"emptyScenes": "Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici."
|
||||
"emptyScenes": "Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici.",
|
||||
"tabPlay": "Partie",
|
||||
"playClocks": "Horloges",
|
||||
"playFacts": "Faits",
|
||||
"playNoPlaythrough": "Cette session n'est rattachée à aucune Partie.",
|
||||
"pinScene": "Épingler comme scène courante"
|
||||
},
|
||||
"sessionDicePanel": {
|
||||
"count": "Nombre",
|
||||
@@ -1521,7 +1590,9 @@
|
||||
"deleteCascade": "Cette action supprimera aussi : {{parts}}.",
|
||||
"irreversible": "Cette action est irréversible.",
|
||||
"deleteTitle": "Supprimer la Partie",
|
||||
"deleteMessage": "Supprimer \"{{name}}\" ?"
|
||||
"deleteMessage": "Supprimer \"{{name}}\" ?",
|
||||
"clocksTitle": "Horloges de progression",
|
||||
"prepTitle": "Préparer la prochaine séance"
|
||||
},
|
||||
"playthroughFlagsPage": {
|
||||
"title": "Faits — {{name}}",
|
||||
@@ -1546,7 +1617,9 @@
|
||||
"allCampaigns": "Toutes les campagnes",
|
||||
"sectionCharacters": "Personnages",
|
||||
"sectionNarration": "Narration",
|
||||
"sectionTools": "Outils"
|
||||
"sectionTools": "Outils",
|
||||
"scenesGroup": "Scènes",
|
||||
"quests": "Quêtes"
|
||||
},
|
||||
"services": {
|
||||
"importFailed": "Échec de l'import.",
|
||||
@@ -1563,5 +1636,244 @@
|
||||
"zoomOut": "Dézoomer",
|
||||
"dragHint": "Glisser pour recadrer",
|
||||
"remove": "Retirer cette image"
|
||||
},
|
||||
"questList": {
|
||||
"title": "Quêtes",
|
||||
"create": "Nouvelle quête",
|
||||
"hint": "Les quêtes sont transversales à la structure : elles portent des objectifs et des conditions de déblocage, et peuvent traverser plusieurs scènes.",
|
||||
"empty": "Aucune quête pour l'instant.",
|
||||
"conditional": "Conditions de déblocage",
|
||||
"deleteTitle": "Supprimer la quête",
|
||||
"deleteMessage": "Supprimer la quête « {{name}} » ?",
|
||||
"irreversible": "Cette action est irréversible."
|
||||
},
|
||||
"questEdit": {
|
||||
"newTitle": "Nouvelle quête",
|
||||
"fallbackTitle": "Quête",
|
||||
"subtitle": "Quête",
|
||||
"optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.",
|
||||
"nameLabel": "Titre de la quête *",
|
||||
"namePlaceholder": "Ex: Sauver le marchand disparu",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Décrivez l'objectif et le contexte de la quête...",
|
||||
"iconLabel": "Icône",
|
||||
"unlockSectionTitle": "Conditions de déblocage",
|
||||
"unlockHint": "Toutes les conditions doivent être remplies (ET) pour que la quête se débloque dans une Partie. Vide = disponible dès le départ. La progression se gère dans l'écran de la Partie.",
|
||||
"gmNotesSectionTitle": "Notes du MJ",
|
||||
"gmNotesPlaceholder": "Notes privées sur la quête, les rebondissements, les secrets...",
|
||||
"objectivesSectionTitle": "Objectifs et enjeux",
|
||||
"playerObjectivesLabel": "Objectifs des joueurs",
|
||||
"playerObjectivesPlaceholder": "Que doivent accomplir les joueurs ?",
|
||||
"narrativeStakesLabel": "Enjeux narratifs",
|
||||
"narrativeStakesPlaceholder": "Quels sont les enjeux dramatiques ?",
|
||||
"deleteTitle": "Supprimer la quête",
|
||||
"deleteMessage": "Supprimer la quête « {{name}} » ?",
|
||||
"irreversible": "Cette action est irréversible.",
|
||||
"illustrationsLabel": "Illustrations",
|
||||
"illustrationsHint": "Portraits, ambiances et lieux marquants de la quête.",
|
||||
"nodesSectionTitle": "Nœuds narratifs traversés",
|
||||
"nodesHint": "FACULTATIF — ne servez-vous en que si cette quête doit TRAVERSER du contenu existant (« la quête passe par ces endroits »). Ce sont des liens : retirer un nœud ne supprime rien. Sans nœud, la quête reçoit automatiquement son propre espace de scènes (créées à la volée, avec sa carte).",
|
||||
"relatedPagesLabel": "Pages Lore liées",
|
||||
"relatedPagesHint": "Liez cette quête à des PNJ, des lieux ou des éléments du Lore qui y apparaissent.",
|
||||
"noLoreHint": "💡 Cette campagne n'est pas liée à un univers. Liez-la à un Lore depuis l'écran de campagne pour pouvoir rattacher des pages Lore à cette quête."
|
||||
},
|
||||
"nodePicker": {
|
||||
"chapter": "Chapitre",
|
||||
"scene": "Scène",
|
||||
"selectChapter": "Ajouter un chapitre...",
|
||||
"selectScene": "Ajouter une scène...",
|
||||
"addChapter": "Ajouter",
|
||||
"addScene": "Ajouter",
|
||||
"empty": "Aucun nœud rattaché. Cette quête n'est encore liée à aucun chapitre ni scène.",
|
||||
"deletedNode": "(nœud supprimé)",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre",
|
||||
"remove": "Retirer ce nœud"
|
||||
},
|
||||
"questView": {
|
||||
"subtitle": "Quête",
|
||||
"synopsisTitle": "Description",
|
||||
"playerObjectivesTitle": "Objectifs des joueurs",
|
||||
"narrativeStakesTitle": "Enjeux narratifs",
|
||||
"prerequisitesTitle": "Conditions de déblocage",
|
||||
"nodesTitle": "Nœuds narratifs traversés",
|
||||
"gmNotesTitle": "Notes du MJ",
|
||||
"relatedPagesTitle": "Pages Lore liées",
|
||||
"notProvided": "Non renseigné",
|
||||
"deleteTitle": "Supprimer la quête",
|
||||
"deleteMessage": "Supprimer la quête « {{name}} » ?",
|
||||
"irreversible": "Cette action est irréversible.",
|
||||
"deletedPage": "(page supprimée)",
|
||||
"deletedQuest": "(quête supprimée)",
|
||||
"deletedNode": "(nœud supprimé)",
|
||||
"prereqQuestCompleted": "Quête « {{name}} » terminée",
|
||||
"prereqSessionReached": "Séance {{n}} atteinte",
|
||||
"prereqFlagSet": "Fait : {{flag}}",
|
||||
"mapButton": "Carte des scènes"
|
||||
},
|
||||
"sceneType": {
|
||||
"GENERIC": "Scène générique",
|
||||
"LOCATION": "Lieu",
|
||||
"ENCOUNTER": "Rencontre",
|
||||
"NPC": "Situation PNJ",
|
||||
"EVENT": "Événement",
|
||||
"REVELATION": "Révélation"
|
||||
},
|
||||
"linkType": {
|
||||
"EXIT": "Sortie",
|
||||
"CLUE": "Indice",
|
||||
"LEAD": "Piste"
|
||||
},
|
||||
"clocksManager": {
|
||||
"empty": "Aucune horloge. Créez-en une pour suivre une menace ou un compte à rebours qui avance avec la partie.",
|
||||
"namePlaceholder": "Nom de l'horloge (ex : « Le culte achève son rituel »)",
|
||||
"segments": "Segments",
|
||||
"segmentsN": "{{n}} segments",
|
||||
"add": "Ajouter",
|
||||
"advance": "Avancer d'un segment",
|
||||
"regress": "Reculer d'un segment",
|
||||
"triggerType": "Déclencheur auto",
|
||||
"triggerOption": {
|
||||
"NONE": "Manuel",
|
||||
"FLAG_SET": "Quand un Fait devient vrai",
|
||||
"QUEST_COMPLETED": "Quand une quête se termine",
|
||||
"SESSION_ENDED": "En fin de séance"
|
||||
},
|
||||
"flagPlaceholder": "Nom du fait",
|
||||
"quest": "Quête",
|
||||
"selectQuest": "— Choisir une quête —",
|
||||
"triggerFlag": "Fait : {{name}}",
|
||||
"triggerQuest": "Quête : {{name}}",
|
||||
"triggerSession": "Fin de séance",
|
||||
"deletedQuest": "(quête supprimée)",
|
||||
"ungrouped": "Horloges libres",
|
||||
"deleteFront": "Supprimer cette menace (les horloges deviennent libres)",
|
||||
"frontPlaceholder": "Nouvelle menace (ex : « La montée du Culte »…)",
|
||||
"addFront": "Ajouter une menace",
|
||||
"front": "Menace",
|
||||
"noFront": "— Sans menace —",
|
||||
"edit": "Modifier"
|
||||
},
|
||||
"readiness": {
|
||||
"title": "Prochaines étapes",
|
||||
"badge": {
|
||||
"DRAFT": "Brouillon",
|
||||
"PLAYABLE": "Jouable",
|
||||
"POLISHED": "Peaufiné"
|
||||
},
|
||||
"blockingN": "{{n}} bloquant(s)",
|
||||
"recommendedN": "{{n}} recommandé(s)",
|
||||
"allSet": "Tout est prêt : cette campagne peut être jouée sans accroc.",
|
||||
"fix": "Corriger",
|
||||
"dot": {
|
||||
"blocking": "À corriger : un manque bloquant, ici ou dans un élément en dessous. Voir « Prochaines étapes » sur la campagne.",
|
||||
"recommended": "Conseillé : un manque recommandé, ici ou en dessous. Voir « Prochaines étapes » sur la campagne.",
|
||||
"more": "+ {{n}} autre(s) manque(s) — voir « Prochaines étapes »"
|
||||
}
|
||||
},
|
||||
"sceneAssist": {
|
||||
"button": "Étoffer avec l'IA",
|
||||
"hint": "Le co-MJ propose des valeurs pour les champs de la scène. Rien n'est enregistré : vous choisissez ce qui remplit le formulaire, puis vous sauvegardez.",
|
||||
"instructionPlaceholder": "Consigne facultative (ex : « ambiance plus inquiétante », « ajoute un indice sur le culte »)…",
|
||||
"generate": "Générer des suggestions",
|
||||
"generating": "Génération…",
|
||||
"regenerate": "Régénérer",
|
||||
"applySelection": "Appliquer la sélection ({{n}})",
|
||||
"empty": "L'IA n'a rien proposé de neuf — la scène est peut-être déjà complète, ou reformulez la consigne.",
|
||||
"error": "Échec de la génération. Le Brain (IA) est-il démarré ?",
|
||||
"current": "Actuel",
|
||||
"proposed": "Proposé",
|
||||
"field": {
|
||||
"description": "Description",
|
||||
"location": "Lieu",
|
||||
"timing": "Moment",
|
||||
"atmosphere": "Ambiance",
|
||||
"playerNarration": "Narration joueurs",
|
||||
"choicesConsequences": "Choix & conséquences",
|
||||
"combatDifficulty": "Difficulté de combat",
|
||||
"enemies": "Ennemis",
|
||||
"gmSecretNotes": "Notes secrètes du MJ"
|
||||
}
|
||||
},
|
||||
"entityAssist": {
|
||||
"button": "Étoffer avec l'IA",
|
||||
"hint": "Le co-MJ propose des valeurs pour les champs. Rien n'est enregistré : vous choisissez ce qui remplit le formulaire, puis vous sauvegardez.",
|
||||
"instructionPlaceholder": "Consigne facultative (ex : « ambiance plus inquiétante », « ajoute un indice sur le culte »)…",
|
||||
"generate": "Générer des suggestions",
|
||||
"generating": "Génération…",
|
||||
"regenerate": "Régénérer",
|
||||
"applySelection": "Appliquer la sélection ({{n}})",
|
||||
"empty": "L'IA n'a rien proposé de neuf — l'entité est peut-être déjà complète, ou reformulez la consigne.",
|
||||
"error": "Échec de la génération. Le Brain (IA) est-il démarré ?",
|
||||
"current": "Actuel",
|
||||
"proposed": "Proposé",
|
||||
"field": {
|
||||
"description": "Description",
|
||||
"location": "Lieu",
|
||||
"timing": "Moment",
|
||||
"atmosphere": "Ambiance",
|
||||
"playerNarration": "Narration joueurs",
|
||||
"choicesConsequences": "Choix & conséquences",
|
||||
"combatDifficulty": "Difficulté de combat",
|
||||
"enemies": "Ennemis",
|
||||
"gmSecretNotes": "Notes secrètes du MJ",
|
||||
"gmNotes": "Notes du MJ",
|
||||
"playerObjectives": "Objectifs des joueurs",
|
||||
"narrativeStakes": "Enjeux narratifs",
|
||||
"themes": "Thèmes",
|
||||
"stakes": "Enjeux",
|
||||
"rewards": "Récompenses",
|
||||
"resolution": "Dénouement"
|
||||
}
|
||||
},
|
||||
"sceneDraft": {
|
||||
"button": "Générer des scènes",
|
||||
"hint": "Le co-MJ propose plusieurs scènes pour ce chapitre. Cochez celles à garder ; elles sont créées dans le chapitre (vous pourrez les affiner ensuite).",
|
||||
"instructionPlaceholder": "Consigne facultative (ex : « une infiltration, un combat, puis une révélation »)…",
|
||||
"countLabel": "Nombre",
|
||||
"generate": "Proposer des scènes",
|
||||
"generating": "Génération…",
|
||||
"regenerate": "Régénérer",
|
||||
"createSelection": "Créer la sélection ({{n}})",
|
||||
"creating": "Création…",
|
||||
"empty": "L'IA n'a proposé aucune scène — reformulez la consigne.",
|
||||
"error": "Échec de la génération. Le Brain (IA) est-il démarré ?",
|
||||
"applyError": "Échec de la création des scènes."
|
||||
},
|
||||
"sessionPrep": {
|
||||
"lastSessionActive": "Séance en cours : {{name}}",
|
||||
"lastSessionEnded": "Dernière séance : {{name}} (terminée)",
|
||||
"allQuiet": "Rien à signaler : pas de quête active ni de manque sur le contenu à venir. Lancez la prochaine séance quand vous voulez.",
|
||||
"positionTitle": "Où en sont les joueurs",
|
||||
"inProgress": "En cours :",
|
||||
"available": "Disponibles :",
|
||||
"hotspotsTitle": "Contenu probable de la prochaine séance",
|
||||
"nodeKind": {
|
||||
"CHAPTER": "chapitre",
|
||||
"SCENE": "scène"
|
||||
},
|
||||
"gapsTitle": "À combler avant de jouer",
|
||||
"fix": "Corriger",
|
||||
"otherGaps": "+ {{n}} autre(s) manque(s) ailleurs dans la campagne",
|
||||
"clocksTitle": "Menaces en mouvement",
|
||||
"clockNearly": "presque pleine !",
|
||||
"clockFull": "pleine !",
|
||||
"completed": "Terminées :",
|
||||
"markStarted": "Marquer « en cours » (les joueurs l'ont entamée)",
|
||||
"markCompleted": "Marquer « terminée » (elle sort des pistes probables)",
|
||||
"reopen": "Rouvrir (revenir à « non commencée »)"
|
||||
},
|
||||
"foundryDialog": {
|
||||
"title": "Exporter pour Foundry",
|
||||
"intro": "Choisissez ce que le module Foundry doit importer. Par exemple, pour jouer vos combats, exportez seulement les cartes et les ennemis préparés dans vos scènes.",
|
||||
"presetAll": "Tout",
|
||||
"presetMapsOnly": "Cartes & ennemis seulement",
|
||||
"optMaps": "Cartes & ennemis",
|
||||
"optMapsHint": "Une Scène Foundry par battlemap (variantes Jour/Nuit incluses), avec les tokens des ennemis liés à chaque scène.",
|
||||
"optJournals": "Journaux narratifs",
|
||||
"optJournalsHint": "Arcs, chapitres, quêtes et scènes en journaux (textes + illustrations), plus les fiches PNJ et bestiaire.",
|
||||
"optTables": "Tables aléatoires",
|
||||
"optTablesHint": "Vos tables de rencontres et d'événements en RollTables Foundry.",
|
||||
"confirm": "Exporter"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"subtitle": "Story arc",
|
||||
"aiAssistant": "AI Assistant",
|
||||
"aiAssistantTitle": "Open the AI Assistant to discuss this arc",
|
||||
"optionalHint": "Only the title is required. Expand and fill in only the sections you need — everything else is optional.",
|
||||
"themesStakesSectionTitle": "Themes and stakes",
|
||||
"gmNotesSectionTitle": "GM notes",
|
||||
"rewardsResolutionSectionTitle": "Rewards and resolution",
|
||||
"illustrationsLabel": "Illustrations",
|
||||
"illustrationsHint": "Moods, portraits, evocative visuals for the arc. JPEG, PNG, WebP or GIF, 10 MB max.",
|
||||
"mapsLabel": "Maps & plans",
|
||||
@@ -56,6 +60,9 @@
|
||||
"arcView": {
|
||||
"subtitleHub": "Hub arc (non-linear quests)",
|
||||
"subtitleLinear": "Story arc",
|
||||
"questsTitle": "Quests in this arc",
|
||||
"questsEmpty": "No quests yet. Add one to populate this hub.",
|
||||
"createQuest": "New quest",
|
||||
"deleteTitle": "Delete the arc and all its content",
|
||||
"mapsTitle": "Maps & plans",
|
||||
"hubQuestsTitle": "Hub quests (scenario)",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"subtitle": "Arc narratif",
|
||||
"aiAssistant": "Assistant IA",
|
||||
"aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cet arc",
|
||||
"optionalHint": "Seul le titre est requis. Déplie et remplis uniquement les sections utiles — tout le reste est optionnel.",
|
||||
"themesStakesSectionTitle": "Thèmes et enjeux",
|
||||
"gmNotesSectionTitle": "Notes du MJ",
|
||||
"rewardsResolutionSectionTitle": "Récompenses et dénouement",
|
||||
"illustrationsLabel": "Illustrations",
|
||||
"illustrationsHint": "Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max.",
|
||||
"mapsLabel": "Cartes & plans",
|
||||
@@ -56,6 +60,9 @@
|
||||
"arcView": {
|
||||
"subtitleHub": "Arc en hub (quêtes non linéaires)",
|
||||
"subtitleLinear": "Arc narratif",
|
||||
"questsTitle": "Quêtes de cet arc",
|
||||
"questsEmpty": "Aucune quête pour l'instant. Ajoutez-en une pour peupler ce hub.",
|
||||
"createQuest": "Nouvelle quête",
|
||||
"deleteTitle": "Supprimer l'arc et tout son contenu",
|
||||
"mapsTitle": "Cartes & plans",
|
||||
"hubQuestsTitle": "Quêtes du hub (scénario)",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"unclassified": "Unfiled",
|
||||
"newNpc": "New NPC",
|
||||
"newScene": "New scene",
|
||||
"scenesGroup": "Scenes",
|
||||
"quests": "Quests",
|
||||
"newQuest": "New quest",
|
||||
"newChapter": "New chapter",
|
||||
"randomTables": "Random tables",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"unclassified": "Sans dossier",
|
||||
"newNpc": "Nouveau PNJ",
|
||||
"newScene": "Nouvelle scène",
|
||||
"scenesGroup": "Scènes",
|
||||
"quests": "Quêtes",
|
||||
"newQuest": "Nouvelle quête",
|
||||
"newChapter": "Nouveau chapitre",
|
||||
"randomTables": "Tables aléatoires",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user