Migration Angular 20 -> 21 : fin de la migration securite (0 vulnerabilite npm)

- ng update @angular/core@21 @angular/cli@21 (corrige GHSA-jrmj-c5cx-3cw6, XSS SVG)
- Migration automatique des templates vers le control flow natif (@if/@for, 138 fichiers)
- Correction des 5 `track` invalides generes par la migration (trackBy a 1 argument
  -> track $index) + suppression des fonctions trackBy mortes
- TypeScript 5.9, zone.js 0.15 ; npm audit : 0 vulnerabilite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:55:36 +02:00
parent 6acad41672
commit 23878f1c63
142 changed files with 7055 additions and 6292 deletions

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
@@ -20,7 +20,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
*/
@Component({
selector: 'app-arc-create',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './arc-create.component.html',
styleUrls: ['./arc-create.component.scss']
})

View File

@@ -7,9 +7,9 @@
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cet arc">
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cet arc">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
@@ -58,7 +58,7 @@
formControlName="name"
placeholder="Ex: L'Ombre du Nord"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
/>
</div>
<div class="field">
@@ -145,25 +145,29 @@
</div>
<!-- ===== Pages Lore associées (phase B2 cross-context) ===== -->
<div class="field" *ngIf="loreId">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.
</small>
</div>
@if (loreId) {
<div class="field">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.
</small>
</div>
}
<div class="field lore-hint" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).
</small>
</div>
@if (!loreId) {
<div class="field lore-hint">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).
</small>
</div>
}
</form>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -33,7 +33,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-arc-edit',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './arc-edit.component.html',
styleUrls: ['./arc-edit.component.scss']
})

View File

@@ -1,123 +1,150 @@
<div class="view-page" *ngIf="arc">
<header class="view-header">
<div>
<h1>
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ arc.name }}
</h1>
<p class="view-subtitle">
{{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }}
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(arc.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></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. -->
<section class="view-section" *ngIf="arc.type === 'HUB'">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
<p class="view-section-empty" *ngIf="hubQuests.length === 0">
Aucune quête pour ce hub. Créez-en une pour démarrer.
</p>
<div class="hub-quest-grid" *ngIf="hubQuests.length > 0">
<button type="button"
class="hub-quest-card"
*ngFor="let q of hubQuests"
(click)="openQuest(q)">
<div class="hub-quest-card-head">
<span class="hub-quest-card-icon" *ngIf="q.icon">
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon>
</span>
<span class="hub-quest-card-name">{{ q.name }}</span>
</div>
<p class="hub-quest-card-desc" *ngIf="q.description?.trim()">{{ q.description }}</p>
<div class="hub-quest-card-locked-hint" *ngIf="(q.prerequisites?.length ?? 0) > 0">
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span>
<ul class="hub-quest-card-prereq-list">
<li *ngFor="let p of q.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
</div>
</button>
</div>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
<p class="view-section-body" *ngIf="arc.description?.trim(); else emptyDesc">{{ arc.description }}</p>
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<div class="view-row">
@if (arc) {
<div class="view-page">
<header class="view-header">
<div>
<h1>
@if (arc.icon) {
<lucide-icon [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
}
{{ arc.name }}
</h1>
<p class="view-subtitle">
{{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }}
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- Illustrations (rendu editorial magazine) -->
@if ((arc.illustrationImageIds?.length ?? 0) > 0) {
<section class="view-section">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
}
<!-- Cartes & plans -->
@if ((arc.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></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> Quêtes du hub (scénario)</h2>
@if (hubQuests.length === 0) {
<p class="view-section-empty">
Aucune quête pour ce hub. Créez-en une pour démarrer.
</p>
}
@if (hubQuests.length > 0) {
<div class="hub-quest-grid">
@for (q of hubQuests; track q) {
<button type="button"
class="hub-quest-card"
(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>{{ q.prerequisites!.length }} condition(s) de déblocage</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> Thèmes principaux</h2>
<p class="view-section-body" *ngIf="arc.themes?.trim(); else emptyThemes">{{ arc.themes }}</p>
<ng-template #emptyThemes><p class="view-section-empty">Non renseigné</p></ng-template>
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
@if (arc.description?.trim()) {
<p class="view-section-body">{{ arc.description }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<div class="view-row">
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Thèmes principaux</h2>
@if (arc.themes?.trim()) {
<p class="view-section-body">{{ arc.themes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> Enjeux globaux</h2>
@if (arc.stakes?.trim()) {
<p class="view-section-body">{{ arc.stakes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
</div>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> Enjeux globaux</h2>
<p class="view-section-body" *ngIf="arc.stakes?.trim(); else emptyStakes">{{ arc.stakes }}</p>
<ng-template #emptyStakes><p class="view-section-empty">Non renseigné</p></ng-template>
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2>
@if (arc.rewards?.trim()) {
<p class="view-section-body">{{ arc.rewards }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2>
@if (arc.resolution?.trim()) {
<p class="view-section-body">{{ arc.resolution }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<!-- Notes MJ (bloc privé rouge discret) -->
@if (arc.gmNotes?.trim()) {
<section class="view-section view-section--private">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes et planification du MJ
</h2>
<p class="view-section-body">{{ arc.gmNotes }}</p>
</section>
}
<!-- Pages Lore liées (chips cliquables) -->
@if (loreId && (arc.relatedPageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
@for (relId of arc.relatedPageIds; track relId) {
<a class="view-chip"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
}
</div>
</section>
}
</div>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2>
<p class="view-section-body" *ngIf="arc.rewards?.trim(); else emptyRewards">{{ arc.rewards }}</p>
<ng-template #emptyRewards><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2>
<p class="view-section-body" *ngIf="arc.resolution?.trim(); else emptyResolution">{{ arc.resolution }}</p>
<ng-template #emptyResolution><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<!-- Notes MJ (bloc privé rouge discret) -->
<section class="view-section view-section--private" *ngIf="arc.gmNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes et planification du MJ
</h2>
<p class="view-section-body">{{ arc.gmNotes }}</p>
</section>
<!-- Pages Lore liées (chips cliquables) -->
<section class="view-section" *ngIf="loreId && (arc.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of arc.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
@@ -25,7 +25,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-arc-view',
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './arc-view.component.html',
styleUrls: ['./arc-view.component.scss']
})

View File

@@ -18,102 +18,114 @@
formControlName="name"
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label for="campaign-description">Description / Pitch</label>
<textarea
id="campaign-description"
formControlName="description"
placeholder="Résumez l'intrigue principale de votre campagne..."
rows="5"
></textarea>
</div>
<div class="field">
<label for="campaign-player-count">Nombre de joueurs</label>
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
</div>
<div class="field">
<label for="campaign-lore">Univers associé</label>
<select id="campaign-lore" formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
<p class="hint">
Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.
</p>
</div>
<div class="field">
<label for="campaign-game-system">Système de JDR</label>
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
<!-- Mode creation inline : remplace temporairement le select. -->
<div *ngIf="creatingGameSystem" class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
</div>
<div class="field">
<label for="campaign-description">Description / Pitch</label>
<textarea
id="campaign-description"
formControlName="description"
placeholder="Résumez l'intrigue principale de votre campagne..."
rows="5"
></textarea>
</div>
<div class="field">
<label for="campaign-player-count">Nombre de joueurs</label>
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
</div>
<div class="field">
<label for="campaign-lore">Univers associé</label>
<select id="campaign-lore" formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option>
@for (lore of availableLores; track lore) {
<option [value]="lore.id">{{ lore.name }}</option>
}
</select>
<p class="hint">
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
et le reste depuis la section "Systèmes" plus tard.
Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.
</p>
</div>
<p *ngIf="!creatingGameSystem" class="hint">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
dans ses suggestions pour respecter les mécaniques du JDR.
</p>
<p *ngIf="!creatingGameSystem" class="hint hint-warning">
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
Le changer plus tard rendra les champs des fiches existantes invisibles
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
Choisissez bien dès le départ si possible.
</p>
<div class="field">
<label for="campaign-game-system">Système de JDR</label>
@if (!creatingGameSystem) {
<select id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option>
@for (gs of availableGameSystems; track gs) {
<option [value]="gs.id">{{ gs.name }}</option>
}
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
}
<!-- Mode creation inline : remplace temporairement le select. -->
@if (creatingGameSystem) {
<div class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
<p class="hint">
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
et le reste depuis la section "Systèmes" plus tard.
</p>
</div>
}
@if (!creatingGameSystem) {
<p class="hint">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
dans ses suggestions pour respecter les mécaniques du JDR.
</p>
}
@if (!creatingGameSystem) {
<p class="hint hint-warning">
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
Le changer plus tard rendra les champs des fiches existantes invisibles
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
Choisissez bien dès le départ si possible.
</p>
}
</div>
<div class="info-box">
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
<ul>
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
</ul>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
Créer la campagne
</button>
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
</div>
</form>
</div>
<div class="info-box">
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
<ul>
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
</ul>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
Créer la campagne
</button>
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular';
@@ -22,7 +22,7 @@ export interface CampaignCreatePayload {
@Component({
selector: 'app-campaign-create',
imports: [CommonModule, ReactiveFormsModule, FormsModule, LucideAngularModule],
imports: [ReactiveFormsModule, FormsModule, LucideAngularModule],
templateUrl: './campaign-create.component.html',
styleUrls: ['./campaign-create.component.scss']
})

View File

@@ -1,206 +1,233 @@
<div class="campaign-detail" *ngIf="campaign">
<!-- ============ Header : mode lecture ============ -->
<div class="detail-header" *ngIf="!editing">
<div class="header-texts">
<h1>{{ campaign.name }}</h1>
<p class="description">{{ campaign.description }}</p>
<div class="meta">
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
<a *ngIf="linkedLore"
class="badge badge-lore"
[routerLink]="['/lore', linkedLore.id]"
title="Ouvrir l'univers associé">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
{{ linkedLore.name }}
</a>
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
<span *ngIf="campaign.loreId && !linkedLore" class="badge badge-lore-missing" title="L'univers associé est introuvable">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
Univers introuvable
</span>
@if (campaign) {
<div class="campaign-detail">
<!-- ============ Header : mode lecture ============ -->
@if (!editing) {
<div class="detail-header">
<div class="header-texts">
<h1>{{ campaign.name }}</h1>
<p class="description">{{ campaign.description }}</p>
<div class="meta">
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
@if (linkedLore) {
<a
class="badge badge-lore"
[routerLink]="['/lore', linkedLore.id]"
title="Ouvrir l'univers associé">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
{{ linkedLore.name }}
</a>
}
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
@if (campaign.loreId && !linkedLore) {
<span class="badge badge-lore-missing" title="L'univers associé est introuvable">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
Univers introuvable
</span>
}
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="startEdit()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteCampaign()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="startEdit()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteCampaign()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
<!-- ============ Header : mode édition inline ============ -->
<div class="detail-header edit-mode" *ngIf="editing">
<div class="field">
<label>Nom</label>
<input type="text" [(ngModel)]="editName" name="editName" required />
</div>
<div class="field">
<label>Description</label>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div>
<div class="field">
<label>Univers associé</label>
<select [(ngModel)]="editLoreId" name="editLoreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
</div>
<div class="field">
<label>Système de JDR</label>
<select *ngIf="!creatingGameSystem"
}
<!-- ============ Header : mode édition inline ============ -->
@if (editing) {
<div class="detail-header edit-mode">
<div class="field">
<label>Nom</label>
<input type="text" [(ngModel)]="editName" name="editName" required />
</div>
<div class="field">
<label>Description</label>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div>
<div class="field">
<label>Univers associé</label>
<select [(ngModel)]="editLoreId" name="editLoreId">
<option value="">— Aucun univers (campagne libre) —</option>
@for (lore of availableLores; track lore) {
<option [value]="lore.id">{{ lore.name }}</option>
}
</select>
</div>
<div class="field">
<label>Système de JDR</label>
@if (!creatingGameSystem) {
<select
[(ngModel)]="editGameSystemId"
name="editGameSystemId"
(ngModelChange)="onEditGameSystemChange($event)">
<option value="">— Aucun (générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
<div *ngIf="creatingGameSystem" class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
name="newGameSystemName"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancelEdit()">
Annuler
</button>
</div>
</div>
<section class="detail-section personas-section" *ngIf="!editing">
<div class="section-header">
<h2>Personnages</h2>
</div>
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
(donnée de scénario, partagée par toutes les Parties). -->
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
<div class="persona-subsection">
<div class="subsection-header">
<h3>
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
Personnages non-joueurs
<span class="count-badge" *ngIf="npcs.length > 0">{{ npcs.length }}</span>
</h3>
<button class="btn-add" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PNJ
</button>
</div>
<div class="characters-grid" *ngIf="npcs.length > 0">
<div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
<option value="">— Aucun (générique) —</option>
@for (gs of availableGameSystems; track gs) {
<option [value]="gs.id">{{ gs.name }}</option>
}
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
}
@if (creatingGameSystem) {
<div class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
name="newGameSystemName"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
</div>
}
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancelEdit()">
Annuler
</button>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="npcs.length === 0">
<p>Aucun PNJ pour le moment.</p>
<button class="btn-add-first" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PNJ
</button>
</div>
}
@if (!editing) {
<section class="detail-section personas-section">
<div class="section-header">
<h2>Personnages</h2>
</div>
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
(donnée de scénario, partagée par toutes les Parties). -->
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
<div class="persona-subsection">
<div class="subsection-header">
<h3>
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
Personnages non-joueurs
@if (npcs.length > 0) {
<span class="count-badge">{{ npcs.length }}</span>
}
</h3>
<button class="btn-add" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PNJ
</button>
</div>
@if (npcs.length > 0) {
<div class="characters-grid">
@for (npc of npcs; track npc) {
<div class="character-card" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div>
}
</div>
}
@if (npcs.length === 0) {
<div class="empty-state empty-state--compact">
<p>Aucun PNJ pour le moment.</p>
<button class="btn-add-first" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PNJ
</button>
</div>
}
</div>
</section>
}
@if (!editing) {
<section class="detail-section arcs-section">
<div class="section-header">
<h2>Arcs narratifs</h2>
<div class="section-header-actions">
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
</div>
</div>
@if (arcs.length > 0) {
<div class="arcs-grid">
@for (arc of arcs; track arc) {
<div class="arc-card" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
</div>
}
</div>
}
@if (arcs.length === 0) {
<div class="empty-state">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p>
<button class="btn-add-first" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier arc
</button>
</div>
}
</section>
}
<!-- ============ Parties (Playthroughs) ============ -->
@if (!editing) {
<section class="detail-section playthroughs-section">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Mes parties
</h2>
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle partie
</button>
</div>
@if (playthroughs.length > 0) {
<div class="playthroughs-grid">
@for (p of playthroughs; track p) {
<div class="playthrough-card"
(click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span>
@if (p.description) {
<span class="playthrough-meta">{{ p.description }}</span>
}
</div>
</div>
}
</div>
}
@if (playthroughs.length === 0) {
<div class="empty-state empty-state--compact">
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
</div>
}
</section>
}
<!-- Sessions retirées : elles vivent désormais dans une Partie.
Les faits narratifs sont définis dans les conditions de chaque quête
(chapter-edit > « Conditions de déblocage »). -->
</div>
</section>
<section class="detail-section arcs-section" *ngIf="!editing">
<div class="section-header">
<h2>Arcs narratifs</h2>
<div class="section-header-actions">
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
</div>
</div>
<div class="arcs-grid" *ngIf="arcs.length > 0">
<div class="arc-card" *ngFor="let arc of arcs" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
</div>
</div>
<div class="empty-state" *ngIf="arcs.length === 0">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p>
<button class="btn-add-first" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier arc
</button>
</div>
</section>
<!-- ============ Parties (Playthroughs) ============ -->
<section class="detail-section playthroughs-section" *ngIf="!editing">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Mes parties
</h2>
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle partie
</button>
</div>
<div class="playthroughs-grid" *ngIf="playthroughs.length > 0">
<div class="playthrough-card"
*ngFor="let p of playthroughs"
(click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span>
<span class="playthrough-meta" *ngIf="p.description">{{ p.description }}</span>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="playthroughs.length === 0">
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
</div>
</section>
<!-- Sessions retirées : elles vivent désormais dans une Partie.
Les faits narratifs sont définis dans les conditions de chaque quête
(chapter-edit > « Conditions de déblocage »). -->
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles } from 'lucide-angular';
@@ -27,7 +27,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
@Component({
selector: 'app-campaign-detail',
imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink],
imports: [FormsModule, LucideAngularModule, RouterLink],
templateUrl: './campaign-detail.component.html',
styleUrls: ['./campaign-detail.component.scss']
})

View File

@@ -13,164 +13,199 @@
</div>
<!-- Étape 1 : upload (masqué une fois en revue) -->
<section class="upload-area" *ngIf="!reviewing">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
</button>
<!-- Progression live -->
<div class="import-progress" *ngIf="importing">
<p class="import-phase">{{ importPhase }}</p>
<div class="progress-bar" *ngIf="importProgress">
<div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
@if (!reviewing) {
<section class="upload-area">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
</button>
<!-- Progression live -->
@if (importing) {
<div class="import-progress">
<p class="import-phase">{{ importPhase }}</p>
@if (importProgress) {
<div class="progress-bar">
<div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div>
</div>
}
@if (importCounts) {
<p class="import-counts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s)
</p>
}
</div>
</div>
<p class="import-counts" *ngIf="importCounts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s)
</p>
</div>
<p class="import-error" *ngIf="importError">{{ importError }}</p>
</section>
}
@if (importError) {
<p class="import-error">{{ importError }}</p>
}
</section>
}
<!-- Étape 2 : revue de l'arbre éditable -->
<section class="review-area" *ngIf="reviewing">
<div class="review-header">
<p class="review-summary">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
<strong>{{ chapterCount }}</strong> chapitre(s),
<strong>{{ sceneCount }}</strong> scène(s).
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
</p>
</div>
<div class="tree">
<!-- Arc -->
<div class="arc-card" *ngFor="let arc of tree; let ai = index">
<div class="node-head arc-head" [class.existing-node]="arc.existing">
<button type="button" class="btn-collapse" (click)="toggleArc(arc)">
<lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
</button>
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" />
<span class="badge-existing" *ngIf="arc.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!arc.existing" (click)="removeArc(ai)" title="Supprimer l'arc">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<div class="node-body" *ngIf="!arc.collapsed">
<div class="arc-type-toggle" *ngIf="!arc.existing">
<span class="toggle-label">Type :</span>
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
(click)="setArcType(arc, 'LINEAR')">Linéaire</button>
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
</div>
<textarea class="node-desc" *ngIf="!arc.existing" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
<!-- Chapitres -->
<div class="chapter-card" *ngFor="let chapter of arc.chapters; let ci = index">
<div class="node-head chapter-head" [class.existing-node]="chapter.existing">
<button type="button" class="btn-collapse" (click)="toggleChapter(chapter)">
<lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
</button>
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[readonly]="chapter.existing" placeholder="Nom du chapitre" />
<span class="badge-existing" *ngIf="chapter.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!chapter.existing" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
@if (reviewing) {
<section class="review-area">
<div class="review-header">
<p class="review-summary">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
<strong>{{ chapterCount }}</strong> chapitre(s),
<strong>{{ sceneCount }}</strong> scène(s).
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
</p>
</div>
<div class="tree">
<!-- Arc -->
@for (arc of tree; track arc; let ai = $index) {
<div class="arc-card">
<div class="node-head arc-head" [class.existing-node]="arc.existing">
<button type="button" class="btn-collapse" (click)="toggleArc(arc)">
<lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
</button>
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" />
@if (arc.existing) {
<span class="badge-existing">déjà présent</span>
}
@if (!arc.existing) {
<button type="button" class="btn-remove" (click)="removeArc(ai)" title="Supprimer l'arc">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
}
</div>
<div class="node-body" *ngIf="!chapter.collapsed">
<textarea class="node-desc" *ngIf="!chapter.existing" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
<!-- Scènes -->
<div class="scene-card" *ngFor="let scene of chapter.scenes; let si = index"
[class.existing-node]="scene.existing">
<div class="scene-row">
<lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon>
<div class="scene-fields">
<input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" />
<input type="text" class="node-desc-inline" *ngIf="!scene.existing" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
@if (!arc.collapsed) {
<div class="node-body">
@if (!arc.existing) {
<div class="arc-type-toggle">
<span class="toggle-label">Type :</span>
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
(click)="setArcType(arc, 'LINEAR')">Linéaire</button>
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
</div>
<span class="badge-existing" *ngIf="scene.existing">déjà présent</span>
<button type="button" class="btn-details" *ngIf="!scene.existing" (click)="toggleDetails(scene)"
title="Narration joueurs, notes MJ, pièces">
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
Détails<span *ngIf="scene.rooms.length"> · {{ scene.rooms.length }} pièce(s)</span>
</button>
<button type="button" class="btn-remove" *ngIf="!scene.existing" (click)="removeScene(chapter, si)" title="Supprimer la scène">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<div class="scene-details" *ngIf="scene.detailsOpen && !scene.existing">
<label class="field-label">À lire aux joueurs</label>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
<label class="field-label">Notes MJ</label>
<textarea class="node-desc" [(ngModel)]="scene.gmNotes"
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
<!-- Pièces (donjon) : vide = scène narrative classique -->
<span class="field-label">Pièces (lieu explorable)</span>
<div class="rooms">
<div class="room-row" *ngFor="let room of scene.rooms; let ri = index">
<input type="text" class="room-name" [(ngModel)]="room.name"
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
<input type="text" class="room-field" [(ngModel)]="room.description"
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" />
<input type="text" class="room-field" [(ngModel)]="room.enemies"
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" />
<input type="text" class="room-field" [(ngModel)]="room.loot"
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" />
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
}
@if (!arc.existing) {
<textarea class="node-desc" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
}
<!-- Chapitres -->
@for (chapter of arc.chapters; track chapter; let ci = $index) {
<div class="chapter-card">
<div class="node-head chapter-head" [class.existing-node]="chapter.existing">
<button type="button" class="btn-collapse" (click)="toggleChapter(chapter)">
<lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
</button>
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[readonly]="chapter.existing" placeholder="Nom du chapitre" />
@if (chapter.existing) {
<span class="badge-existing">déjà présent</span>
}
@if (!chapter.existing) {
<button type="button" class="btn-remove" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
}
</div>
@if (!chapter.collapsed) {
<div class="node-body">
@if (!chapter.existing) {
<textarea class="node-desc" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
}
<!-- Scènes -->
@for (scene of chapter.scenes; track scene; let si = $index) {
<div class="scene-card"
[class.existing-node]="scene.existing">
<div class="scene-row">
<lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon>
<div class="scene-fields">
<input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" />
@if (!scene.existing) {
<input type="text" class="node-desc-inline" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
}
</div>
@if (scene.existing) {
<span class="badge-existing">déjà présent</span>
}
@if (!scene.existing) {
<button type="button" class="btn-details" (click)="toggleDetails(scene)"
title="Narration joueurs, notes MJ, pièces">
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
Détails@if (scene.rooms.length) {
<span> · {{ scene.rooms.length }} pièce(s)</span>
}
</button>
}
@if (!scene.existing) {
<button type="button" class="btn-remove" (click)="removeScene(chapter, si)" title="Supprimer la scène">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
}
</div>
@if (scene.detailsOpen && !scene.existing) {
<div class="scene-details">
<label class="field-label">À lire aux joueurs</label>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
<label class="field-label">Notes MJ</label>
<textarea class="node-desc" [(ngModel)]="scene.gmNotes"
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
<!-- Pièces (donjon) : vide = scène narrative classique -->
<span class="field-label">Pièces (lieu explorable)</span>
<div class="rooms">
@for (room of scene.rooms; track room; let ri = $index) {
<div class="room-row">
<input type="text" class="room-name" [(ngModel)]="room.name"
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
<input type="text" class="room-field" [(ngModel)]="room.description"
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" />
<input type="text" class="room-field" [(ngModel)]="room.enemies"
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" />
<input type="text" class="room-field" [(ngModel)]="room.loot"
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" />
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
}
<button type="button" class="btn-add-inline" (click)="addRoom(scene)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
</button>
</div>
</div>
}
</div>
}
<button type="button" class="btn-add-inline" (click)="addScene(chapter)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
</button>
</div>
<button type="button" class="btn-add-inline" (click)="addRoom(scene)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
</button>
</div>
}
</div>
</div>
<button type="button" class="btn-add-inline" (click)="addScene(chapter)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
}
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
</button>
</div>
</div>
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
</button>
}
</div>
</div>
}
<button type="button" class="btn-add" (click)="addArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
</button>
</div>
<p class="apply-error" *ngIf="applyError">{{ applyError }}</p>
@if (applyError) {
<p class="apply-error">{{ applyError }}</p>
}
<div class="review-actions">
<button type="button" class="btn-primary" [disabled]="applying || !hasNewContent" (click)="apply()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
@@ -179,5 +214,6 @@
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>
</section>
}
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
@@ -47,7 +47,7 @@ interface ArcNode {
*/
@Component({
selector: 'app-campaign-import',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './campaign-import.component.html',
styleUrls: ['./campaign-import.component.scss']
})

View File

@@ -8,18 +8,20 @@
<div class="campaigns-grid">
<div class="campaign-card" *ngFor="let campaign of campaigns" (click)="navigateToDetail(campaign.id!)">
<div class="card-header">
<span class="status-badge en-cours">En cours</span>
<span class="card-date">{{ campaign.playerCount }} joueurs</span>
@for (campaign of campaigns; track campaign) {
<div class="campaign-card" (click)="navigateToDetail(campaign.id!)">
<div class="card-header">
<span class="status-badge en-cours">En cours</span>
<span class="card-date">{{ campaign.playerCount }} joueurs</span>
</div>
<h2>{{ campaign.name }}</h2>
<p class="card-description">{{ campaign.description }}</p>
<div class="card-stats">
<span>⚔️ {{ campaign.arcCount || 0 }} arcs</span>
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
</div>
</div>
<h2>{{ campaign.name }}</h2>
<p class="card-description">{{ campaign.description }}</p>
<div class="card-stats">
<span>⚔️ {{ campaign.arcCount || 0 }} arcs</span>
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
</div>
</div>
}
<div class="campaign-card card-new" (click)="openCreateModal()">
<div class="new-icon">
@@ -35,8 +37,9 @@
</div>
<app-campaign-create
*ngIf="showCreateModal"
(close)="onModalClose()"
(created)="onCampaignCreated($event)">
</app-campaign-create>
@if (showCreateModal) {
<app-campaign-create
(close)="onModalClose()"
(created)="onCampaignCreated($event)">
</app-campaign-create>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
import { CampaignService } from '../services/campaign.service';
@@ -9,7 +9,7 @@ import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campa
@Component({
selector: 'app-campaigns',
imports: [CommonModule, LucideAngularModule, CampaignCreateComponent],
imports: [LucideAngularModule, CampaignCreateComponent],
templateUrl: './campaigns.component.html',
styleUrls: ['./campaigns.component.scss']
})

View File

@@ -2,7 +2,9 @@
<div class="page-header">
<h1>{{ isHub ? 'Créer une nouvelle quête' : 'Créer un nouveau chapitre' }}</h1>
<p class="arc-ref" *ngIf="arcName">{{ isHub ? 'Hub' : 'Arc' }} : {{ arcName }}</p>
@if (arcName) {
<p class="arc-ref">{{ isHub ? 'Hub' : 'Arc' }} : {{ arcName }}</p>
}
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
@@ -15,7 +17,7 @@
formControlName="name"
[placeholder]="isHub ? 'Ex: Sauver le marchand disparu' : 'Ex: Chapitre 1: Les Disparitions'"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
/>
</div>
<div class="field">

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
@@ -19,7 +19,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
*/
@Component({
selector: 'app-chapter-create',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss']
})

View File

@@ -7,9 +7,9 @@
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre">
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
@@ -27,7 +27,7 @@
<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. -->
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
<div class="hub-section">
<h2 class="hub-section-title">
{{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }}
@@ -78,7 +78,7 @@
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
/>
</div>
<div class="field">
@@ -129,25 +129,29 @@
</div>
<!-- ===== Pages Lore associées (B2 cross-context) ===== -->
<div class="field" *ngIf="loreId">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
</small>
</div>
@if (loreId) {
<div class="field">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
</small>
</div>
}
<div class="field" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier ce chapitre à des pages du Lore.
</small>
</div>
@if (!loreId) {
<div class="field">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier ce chapitre à des pages du Lore.
</small>
</div>
}
</form>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -34,15 +34,14 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
@Component({
selector: 'app-chapter-edit',
imports: [
CommonModule,
ReactiveFormsModule,
LucideAngularModule,
LoreLinkPickerComponent,
AiChatDrawerComponent,
ImageGalleryComponent,
IconPickerComponent,
PrerequisiteEditorComponent
],
ReactiveFormsModule,
LucideAngularModule,
LoreLinkPickerComponent,
AiChatDrawerComponent,
ImageGalleryComponent,
IconPickerComponent,
PrerequisiteEditorComponent
],
templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss']
})

View File

@@ -11,64 +11,70 @@
</button>
</div>
<div class="graph-empty" *ngIf="scenes.length === 0">
<p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p>
</div>
@if (scenes.length === 0) {
<div class="graph-empty">
<p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p>
</div>
}
<div class="graph-container" *ngIf="scenes.length > 0">
<svg #svgEl
[attr.width]="svgWidth" [attr.height]="svgHeight"
class="graph-svg"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)">
<defs>
<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" />
</marker>
</defs>
<g class="edges">
<g class="edge" *ngFor="let edge of edges">
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
@if (scenes.length > 0) {
<div class="graph-container">
<svg #svgEl
[attr.width]="svgWidth" [attr.height]="svgHeight"
class="graph-svg"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)">
<defs>
<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" />
</marker>
</defs>
<g class="edges">
@for (edge of edges; track edge) {
<g class="edge">
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
stroke="#b8c0cc" stroke-width="2"
marker-end="url(#arrowhead)" />
<text *ngIf="edge.label"
[attr.x]="edge.labelX"
[attr.y]="edge.labelY"
text-anchor="middle"
class="edge-label"
[class.dragging]="draggingLabelKey === edge.key"
(pointerdown)="onLabelPointerDown($event, edge)">
{{ edge.label }}
</text>
@if (edge.label) {
<text
[attr.x]="edge.labelX"
[attr.y]="edge.labelY"
text-anchor="middle"
class="edge-label"
[class.dragging]="draggingLabelKey === edge.key"
(pointerdown)="onLabelPointerDown($event, edge)">
{{ edge.label }}
</text>
}
</g>
}
</g>
</g>
<g class="nodes">
<g class="node"
[class.dragging]="draggingId === node.id"
*ngFor="let node of nodes"
(pointerdown)="onPointerDown($event, node)">
<title>{{ node.name }}</title>
<rect [attr.x]="node.x" [attr.y]="node.y"
<g class="nodes">
@for (node of nodes; track node) {
<g class="node"
[class.dragging]="draggingId === node.id"
(pointerdown)="onPointerDown($event, node)">
<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" />
<text [attr.x]="node.x + NODE_WIDTH / 2"
<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>
{{ node.displayName }}
</text>
</g>
}
</g>
</g>
</svg>
<small class="graph-hint">
💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
</small>
</div>
</svg>
<small class="graph-hint">
💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
</small>
</div>
}
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
@@ -21,7 +21,7 @@ interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: nu
*/
@Component({
selector: 'app-chapter-graph',
imports: [CommonModule, RouterModule, LucideAngularModule],
imports: [RouterModule, LucideAngularModule],
templateUrl: './chapter-graph.component.html',
styleUrls: ['./chapter-graph.component.scss']
})

View File

@@ -1,112 +1,127 @@
<div class="view-page" *ngIf="chapter">
<header class="view-header">
<div>
<h1>
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
<span class="cond-badge" *ngIf="(chapter.prerequisites?.length ?? 0) > 0"
@if (chapter) {
<div class="view-page">
<header class="view-header">
<div>
<h1>
@if (chapter.icon) {
<lucide-icon [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
}
{{ chapter.name }}
</h1>
<p class="view-subtitle">
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
@if ((chapter.prerequisites?.length ?? 0) > 0) {
<span class="cond-badge"
title="Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.">
<lucide-icon [img]="Lock" [size]="12"></lucide-icon>
Conditionnel
</span>
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
title="Voir l'organigramme des scènes et de leurs branches">
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
Carte du chapitre
</button>
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteChapter()" title="Supprimer le chapitre et ses scènes">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</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. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
<ul class="view-section-body">
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
<small class="view-section-empty">
Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
« Faits » de la Partie.
</small>
</ng-container>
<ng-template #noPrereqs>
<p class="view-section-empty">
Aucune condition — cette quête est disponible dès le début dans chaque Partie.
</p>
<p class="view-section-empty">
Cliquez sur <strong>Modifier</strong> pour ajouter des conditions
(« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »).
</p>
</ng-template>
</section>
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(chapter.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Synopsis</h2>
<p class="view-section-body" *ngIf="chapter.description?.trim(); else emptyDesc">{{ chapter.description }}</p>
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<div class="view-row">
<lucide-icon [img]="Lock" [size]="12"></lucide-icon>
Conditionnel
</span>
}
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
title="Voir l'organigramme des scènes et de leurs branches">
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
Carte du chapitre
</button>
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteChapter()" title="Supprimer le chapitre et ses scènes">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</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> Conditions de déblocage</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">
Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
« Faits » de la Partie.
</small>
} @else {
<p class="view-section-empty">
Aucune condition — cette quête est disponible dès le début dans chaque Partie.
</p>
<p class="view-section-empty">
Cliquez sur <strong>Modifier</strong> pour ajouter des conditions
(« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »).
</p>
}
</section>
}
<!-- Illustrations (rendu editorial magazine) -->
@if ((chapter.illustrationImageIds?.length ?? 0) > 0) {
<section class="view-section">
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
}
<!-- Cartes & plans -->
@if ((chapter.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
}
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> Objectifs des joueurs</h2>
<p class="view-section-body" *ngIf="chapter.playerObjectives?.trim(); else emptyObj">{{ chapter.playerObjectives }}</p>
<ng-template #emptyObj><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Enjeux narratifs</h2>
<p class="view-section-body" *ngIf="chapter.narrativeStakes?.trim(); else emptyNs">{{ chapter.narrativeStakes }}</p>
<ng-template #emptyNs><p class="view-section-empty">Non renseigné</p></ng-template>
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Synopsis</h2>
@if (chapter.description?.trim()) {
<p class="view-section-body">{{ chapter.description }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<div class="view-row">
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> Objectifs des joueurs</h2>
@if (chapter.playerObjectives?.trim()) {
<p class="view-section-body">{{ chapter.playerObjectives }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Enjeux narratifs</h2>
@if (chapter.narrativeStakes?.trim()) {
<p class="view-section-body">{{ chapter.narrativeStakes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
</div>
@if (chapter.gmNotes?.trim()) {
<section class="view-section view-section--private">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes du Maître de Jeu
</h2>
<p class="view-section-body">{{ chapter.gmNotes }}</p>
</section>
}
@if (loreId && (chapter.relatedPageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
@for (relId of chapter.relatedPageIds; track relId) {
<a class="view-chip"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
}
</div>
</section>
}
</div>
<section class="view-section view-section--private" *ngIf="chapter.gmNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes du Maître de Jeu
</h2>
<p class="view-section-body">{{ chapter.gmNotes }}</p>
</section>
<section class="view-section" *ngIf="loreId && (chapter.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of chapter.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
@@ -24,7 +24,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-chapter-view',
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './chapter-view.component.html',
styleUrls: ['./chapter-view.component.scss']
})

View File

@@ -10,16 +10,17 @@
<lucide-icon [img]="User" [size]="22"></lucide-icon>
{{ characterId ? 'Éditer la fiche' : 'Nouveau personnage' }}
</h1>
<button
*ngIf="characterId"
type="button"
class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PJ">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
@if (characterId) {
<button
type="button"
class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PJ">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
}
</div>
</div>
@@ -33,70 +34,72 @@
[(ngModel)]="name"
name="name"
placeholder="Ex: Thorin le Grand-Hache, Lyra l'Errante..."
/>
</div>
<div class="field-row image-row">
<div class="field portrait-field">
<label>Portrait</label>
<app-single-image-picker
[imageId]="portraitImageId"
aspectRatio="1 / 1"
hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
/>
</div>
<div class="field header-field">
<label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
<div class="field-row image-row">
<div class="field portrait-field">
<label>Portrait</label>
<app-single-image-picker
[imageId]="portraitImageId"
aspectRatio="1 / 1"
hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
</div>
<div class="field header-field">
<label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ characterId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
@if (characterId) {
<button
type="button"
class="btn-danger"
(click)="deleteCharacter()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
}
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ characterId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
<button
*ngIf="characterId"
type="button"
class="btn-danger"
(click)="deleteCharacter()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
</div>
<app-ai-chat-drawer
*ngIf="characterId && campaignId"
[campaignId]="campaignId"
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
welcomeMessage="Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>
@if (characterId && campaignId) {
<app-ai-chat-drawer
[campaignId]="campaignId"
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
welcomeMessage="Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
@@ -26,7 +26,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-character-edit',
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './character-edit.component.html',
styleUrls: ['./character-edit.component.scss']
})

View File

@@ -5,10 +5,12 @@
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="characterId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
@if (characterId) {
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
}
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
@@ -18,11 +20,12 @@
<app-persona-view [persona]="character" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="characterId && campaignId"
[campaignId]="campaignId"
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>
@if (characterId && campaignId) {
<app-ai-chat-drawer
[campaignId]="campaignId"
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service';
@@ -17,7 +17,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
*/
@Component({
selector: 'app-character-view',
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
imports: [LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './character-view.component.html',
styleUrls: ['./character-view.component.scss']
})

View File

@@ -12,7 +12,9 @@
<h1>{{ catalogId ? 'Éditer le catalogue' : 'Nouveau catalogue d\'objets' }}</h1>
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div>
@if (errorMessage) {
<div class="error">{{ errorMessage }}</div>
}
<div class="form-row">
<label for="ic-name">Nom *</label>
@@ -29,13 +31,15 @@
<div class="ai-title"><lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA</div>
<p class="ai-hint">Décris la boutique/le butin ; l'IA propose les objets (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
<textarea rows="2" [(ngModel)]="aiPrompt"
placeholder="Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques"></textarea>
placeholder="Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques"></textarea>
<div class="ai-actions">
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
{{ generating ? 'Génération…' : 'Générer' }}
</button>
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span>
@if (aiError) {
<span class="ai-error">{{ aiError }}</span>
}
</div>
</div>
@@ -54,15 +58,19 @@
<span class="c-del"></span>
</div>
<div class="item-row" *ngFor="let it of items; let i = index">
<input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet">
<input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po">
<input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes">
<input class="c-desc" type="text" [(ngModel)]="it.description" placeholder="Effet / détails">
<button class="c-del btn-del" (click)="removeItem(i)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
@for (it of items; track it; let i = $index) {
<div class="item-row">
<input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet">
<input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po">
<input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes">
<input class="c-desc" type="text" [(ngModel)]="it.description" placeholder="Effet / détails">
<button class="c-del btn-del" (click)="removeItem(i)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
}
<p class="empty-hint" *ngIf="items.length === 0">Aucun objet — clique « Ajouter ».</p>
@if (items.length === 0) {
<p class="empty-hint">Aucun objet — clique « Ajouter ».</p>
}
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Sparkles } from 'lucide-angular';
@@ -14,7 +14,7 @@ import { ItemCatalog, CatalogItem, ItemCatalogCreate } from '../../../services/i
*/
@Component({
selector: 'app-item-catalog-edit',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './item-catalog-edit.component.html',
styleUrls: ['./item-catalog-edit.component.scss']
})

View File

@@ -18,14 +18,18 @@
</header>
<div class="icl-list">
<p class="empty" *ngIf="catalogs.length === 0">Aucun catalogue pour l'instant.</p>
<button class="icl-item" *ngFor="let c of catalogs" (click)="open(c)">
<lucide-icon [img]="Package" [size]="16"></lucide-icon>
<span class="icl-item-name">{{ c.name }}</span>
<span class="icl-item-count">{{ c.items.length }} objet(s)</span>
<span class="icl-del" (click)="remove(c, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
@if (catalogs.length === 0) {
<p class="empty">Aucun catalogue pour l'instant.</p>
}
@for (c of catalogs; track c) {
<button class="icl-item" (click)="open(c)">
<lucide-icon [img]="Package" [size]="16"></lucide-icon>
<span class="icl-item-name">{{ c.name }}</span>
<span class="icl-item-count">{{ c.items.length }} objet(s)</span>
<span class="icl-del" (click)="remove(c, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Package } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service';
@@ -13,7 +13,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-item-catalog-list',
imports: [CommonModule, LucideAngularModule],
imports: [LucideAngularModule],
templateUrl: './item-catalog-list.component.html',
styleUrls: ['./item-catalog-list.component.scss']
})

View File

@@ -1,33 +1,42 @@
<div class="ic-page" *ngIf="catalog">
<div class="ic-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> Éditer
</button>
@if (catalog) {
<div class="ic-page">
<div class="ic-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> Éditer
</button>
</div>
<header class="ic-header">
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> {{ catalog.name }}</h1>
@if (catalog.description) {
<p class="ic-desc">{{ catalog.description }}</p>
}
</header>
@if (catalog.items.length === 0) {
<p class="ic-empty">
Aucun objet — édite le catalogue pour en ajouter.
</p>
}
@for (g of groups; track g) {
<section class="ic-group">
@if (g.category !== '—') {
<h2>{{ g.category }}</h2>
}
<table>
<tbody>
@for (it of g.items; track it) {
<tr>
<td class="col-name">{{ it.name }}</td>
<td class="col-price">{{ it.price }}</td>
<td class="col-desc">{{ it.description }}</td>
</tr>
}
</tbody>
</table>
</section>
}
</div>
<header class="ic-header">
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> {{ catalog.name }}</h1>
<p class="ic-desc" *ngIf="catalog.description">{{ catalog.description }}</p>
</header>
<p class="ic-empty" *ngIf="catalog.items.length === 0">
Aucun objet — édite le catalogue pour en ajouter.
</p>
<section class="ic-group" *ngFor="let g of groups">
<h2 *ngIf="g.category !== '—'">{{ g.category }}</h2>
<table>
<tbody>
<tr *ngFor="let it of g.items">
<td class="col-name">{{ it.name }}</td>
<td class="col-price">{{ it.price }}</td>
<td class="col-desc">{{ it.description }}</td>
</tr>
</tbody>
</table>
</section>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Package } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service';
@@ -14,7 +14,7 @@ interface ItemGroup { category: string; items: CatalogItem[]; }
*/
@Component({
selector: 'app-item-catalog-view',
imports: [CommonModule, LucideAngularModule],
imports: [LucideAngularModule],
templateUrl: './item-catalog-view.component.html',
styleUrls: ['./item-catalog-view.component.scss']
})

View File

@@ -5,37 +5,55 @@
<span class="nac-name">{{ action.name }}</span>
</div>
<p class="nac-desc" *ngIf="action.description">{{ action.description }}</p>
@if (action.description) {
<p class="nac-desc">{{ action.description }}</p>
}
<!-- Cibles -->
<div class="nac-targets" *ngIf="status !== 'created' && needsArc">
<label>
Arc
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
<option *ngFor="let a of arcs" [value]="a.id">{{ a.name }}</option>
</select>
</label>
<label *ngIf="needsChapter">
Chapitre
<select [(ngModel)]="selectedChapterId">
<option *ngFor="let c of targetChapters" [value]="c.id">{{ c.name }}</option>
</select>
</label>
</div>
@if (status !== 'created' && needsArc) {
<div class="nac-targets">
<label>
Arc
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
@for (a of arcs; track a) {
<option [value]="a.id">{{ a.name }}</option>
}
</select>
</label>
@if (needsChapter) {
<label>
Chapitre
<select [(ngModel)]="selectedChapterId">
@for (c of targetChapters; track c) {
<option [value]="c.id">{{ c.name }}</option>
}
</select>
</label>
}
</div>
}
<p class="nac-warn" *ngIf="needsArc && arcs.length === 0">
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
</p>
@if (needsArc && arcs.length === 0) {
<p class="nac-warn">
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
</p>
}
<div class="nac-foot">
<button class="nac-create" *ngIf="status !== 'created'" (click)="create()" [disabled]="!canCreate">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
</button>
<button class="nac-open" *ngIf="status === 'created'" (click)="openCreated()">
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
</button>
<span class="nac-error" *ngIf="status === 'error'">{{ errorMsg }}</span>
@if (status !== 'created') {
<button class="nac-create" (click)="create()" [disabled]="!canCreate">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
</button>
}
@if (status === 'created') {
<button class="nac-open" (click)="openCreated()">
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
</button>
}
@if (status === 'error') {
<span class="nac-error">{{ errorMsg }}</span>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, Plus, Check, Drama, Clapperboard, BookText, GitBranch, Dices, ExternalLink } from 'lucide-angular';
@@ -15,7 +15,7 @@ import { NotebookAction } from '../../../services/notebook-action.model';
*/
@Component({
selector: 'app-notebook-action-card',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './notebook-action-card.component.html',
styleUrls: ['./notebook-action-card.component.scss']
})

View File

@@ -1,98 +1,117 @@
<div class="nbd-page" *ngIf="detail">
<div class="nbd-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
</button>
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
</div>
<div class="nbd-grid">
<!-- Sources -->
<aside class="nbd-sources">
<div class="nbd-sources-head">
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3>
<label class="btn-upload" [class.disabled]="uploading">
<lucide-icon [img]="Upload" [size]="13"></lucide-icon>
{{ uploading ? 'Indexation…' : 'Ajouter un PDF' }}
<input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading">
</label>
</div>
<p class="nbd-upload-error" *ngIf="uploadError">{{ uploadError }}</p>
<div class="nbd-source" *ngFor="let s of sources">
<lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
[size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon>
<div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta">
<span *ngIf="s.status === 'READY'">{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
<span *ngIf="s.status === 'INDEXING'">indexation en cours…</span>
<span *ngIf="s.status === 'FAILED'">échec de l'indexation</span>
</div>
@if (detail) {
<div class="nbd-page">
<div class="nbd-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
</button>
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
</div>
<div class="nbd-grid">
<!-- Sources -->
<aside class="nbd-sources">
<div class="nbd-sources-head">
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3>
<label class="btn-upload" [class.disabled]="uploading">
<lucide-icon [img]="Upload" [size]="13"></lucide-icon>
{{ uploading ? 'Indexation…' : 'Ajouter un PDF' }}
<input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading">
</label>
</div>
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<p class="nbd-empty" *ngIf="sources.length === 0 && !uploading">
Ajoute un PDF source pour commencer à discuter avec.
</p>
</aside>
<!-- Chat -->
<section class="nbd-chat">
<div class="nbd-messages">
<p class="nbd-empty" *ngIf="messages.length === 0">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
<span *ngIf="!hasReadySource()"><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
</p>
<div class="nbd-msg" *ngFor="let m of messages" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
<lucide-icon *ngIf="m.role === 'assistant'" [img]="Sparkles" [size]="12"></lucide-icon>
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
<ng-container *ngIf="m.role === 'assistant'; else userContent">
<ng-container *ngIf="parsedOf(m) as p">
<div class="nbd-deep-progress" *ngIf="sending && deepProgress && !p.text">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
@if (uploadError) {
<p class="nbd-upload-error">{{ uploadError }}</p>
}
@for (s of sources; track s) {
<div class="nbd-source">
<lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
[size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon>
<div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta">
@if (s.status === 'READY') {
<span>{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
}
@if (s.status === 'INDEXING') {
<span>indexation en cours…</span>
}
@if (s.status === 'FAILED') {
<span>échec de l'indexation</span>
}
</div>
<div class="nbd-msg-content">{{ p.text }}<span class="cursor" *ngIf="sending && !p.text && !p.actions.length && !deepProgress"></span></div>
<app-notebook-action-card
*ngFor="let a of p.actions; trackBy: trackAction"
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
</ng-container>
</ng-container>
<ng-template #userContent>
<div class="nbd-msg-content">{{ m.content }}</div>
</ng-template>
</div>
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
}
@if (sources.length === 0 && !uploading) {
<p class="nbd-empty">
Ajoute un PDF source pour commencer à discuter avec.
</p>
}
</aside>
<!-- Chat -->
<section class="nbd-chat">
<div class="nbd-messages">
@if (messages.length === 0) {
<p class="nbd-empty">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
@if (!hasReadySource()) {
<span><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
}
</p>
}
@for (m of messages; track m) {
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
@if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
@if (m.role === 'assistant') {
@if (parsedOf(m); as p) {
@if (sending && deepProgress && !p.text) {
<div class="nbd-deep-progress">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div>
}
<div class="nbd-msg-content">{{ p.text }}@if (sending && !p.text && !p.actions.length && !deepProgress) {
<span class="cursor"></span>
}</div>
@for (a of p.actions; track $index) {
<app-notebook-action-card
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
}
}
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
}
</div>
</div>
<div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
(keydown.enter)="$event.preventDefault(); send()"></textarea>
<button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()"
title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »">
<lucide-icon [img]="Layers" [size]="15"></lucide-icon>
</button>
<button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()"
title="Réponse rapide (recherche ciblée dans le document)">
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button>
</div>
</section>
<div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
(keydown.enter)="$event.preventDefault(); send()"></textarea>
<button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()"
title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »">
<lucide-icon [img]="Layers" [size]="15"></lucide-icon>
</button>
<button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()"
title="Réponse rapide (recherche ciblée dans le document)">
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button>
</div>
</section>
</div>
</div>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers } from 'lucide-angular';
@@ -20,7 +20,7 @@ import { NotebookActionCardComponent } from '../notebook-action-card/notebook-ac
*/
@Component({
selector: 'app-notebook-detail',
imports: [CommonModule, FormsModule, LucideAngularModule, NotebookActionCardComponent],
imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent],
templateUrl: './notebook-detail.component.html',
styleUrls: ['./notebook-detail.component.scss']
})
@@ -108,7 +108,6 @@ export class NotebookDetailComponent implements OnInit {
}
/** trackBy stable pour les cartes d'action (évite toute recréation parasite). */
trackAction(index: number): number { return index; }
load(): void {
this.service.get(this.notebookId).subscribe({

View File

@@ -15,7 +15,7 @@
<div class="nbl-create">
<input type="text" [(ngModel)]="newName" placeholder="Nom de l'atelier (ex: Adaptation du module X)"
(keyup.enter)="create()">
(keyup.enter)="create()">
<button class="btn-create" (click)="create()" [disabled]="creating">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ creating ? 'Création…' : 'Nouvel atelier' }}
@@ -23,13 +23,17 @@
</div>
<div class="nbl-list">
<p class="empty" *ngIf="notebooks.length === 0">Aucun atelier pour l'instant.</p>
<button class="nbl-item" *ngFor="let nb of notebooks" (click)="open(nb)">
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
<span class="nbl-item-name">{{ nb.name }}</span>
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
@if (notebooks.length === 0) {
<p class="empty">Aucun atelier pour l'instant.</p>
}
@for (nb of notebooks; track nb) {
<button class="nbl-item" (click)="open(nb)">
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
<span class="nbl-item-name">{{ nb.name }}</span>
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, BookOpen } from 'lucide-angular';
@@ -14,7 +14,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-notebook-list',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './notebook-list.component.html',
styleUrls: ['./notebook-list.component.scss']
})

View File

@@ -10,16 +10,17 @@
<lucide-icon [img]="Drama" [size]="22"></lucide-icon>
{{ npcId ? 'Éditer le PNJ' : 'Nouveau PNJ' }}
</h1>
<button
*ngIf="npcId"
type="button"
class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
@if (npcId) {
<button
type="button"
class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
}
</div>
</div>
@@ -33,85 +34,89 @@
[(ngModel)]="name"
name="name"
placeholder="Ex: Borin le forgeron, Dame Elara, Kael l'aubergiste..."
/>
</div>
<div class="field">
<label for="npc-folder">Dossier</label>
<input
id="npc-folder"
type="text"
[(ngModel)]="folder"
name="folder"
list="npc-folders"
placeholder="Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)"
/>
<datalist id="npc-folders">
<option *ngFor="let f of existingFolders" [value]="f"></option>
</datalist>
</div>
<div class="field-row image-row">
<div class="field portrait-field">
<label>Portrait</label>
<app-single-image-picker
[imageId]="portraitImageId"
aspectRatio="1 / 1"
hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
/>
</div>
<div class="field header-field">
<label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
<div class="field">
<label for="npc-folder">Dossier</label>
<input
id="npc-folder"
type="text"
[(ngModel)]="folder"
name="folder"
list="npc-folders"
placeholder="Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)"
/>
<datalist id="npc-folders">
@for (f of existingFolders; track f) {
<option [value]="f"></option>
}
</datalist>
</div>
<div class="field-row image-row">
<div class="field portrait-field">
<label>Portrait</label>
<app-single-image-picker
[imageId]="portraitImageId"
aspectRatio="1 / 1"
hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
</div>
<div class="field header-field">
<label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ npcId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
@if (npcId) {
<button
type="button"
class="btn-danger"
(click)="deleteNpc()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
}
</div>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ npcId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
<button
*ngIf="npcId"
type="button"
class="btn-danger"
(click)="deleteNpc()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
</div>
<app-ai-chat-drawer
*ngIf="npcId && campaignId"
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>
@if (npcId && campaignId) {
<app-ai-chat-drawer
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
@@ -21,7 +21,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-npc-edit',
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './npc-edit.component.html',
styleUrls: ['./npc-edit.component.scss']
})

View File

@@ -5,10 +5,12 @@
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="npcId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
@if (npcId) {
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
}
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
@@ -18,11 +20,12 @@
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="npcId && campaignId"
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>
@if (npcId && campaignId) {
<app-ai-chat-drawer
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
@@ -18,7 +18,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
*/
@Component({
selector: 'app-npc-view',
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
imports: [LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './npc-view.component.html',
styleUrls: ['./npc-view.component.scss']
})

View File

@@ -1,72 +1,83 @@
<div class="playthrough-page" *ngIf="playthrough">
<header class="page-header">
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<div class="header-info">
<h1>{{ playthrough.name }}</h1>
<p class="subtitle" *ngIf="playthrough.description">{{ playthrough.description }}</p>
</div>
<div class="header-actions">
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
@if (playthrough) {
<div class="playthrough-page">
<header class="page-header">
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
</div>
</header>
<!-- Bloc action principal : démarrer ou reprendre la session -->
<section class="play-action">
<button type="button" class="btn-primary big"
*ngIf="!activeOnThis"
[disabled]="startingSession"
(click)="startSession()">
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
Lancer une session
</button>
<button type="button" class="btn-primary big"
*ngIf="activeOnThis"
(click)="openSession(activeOnThis)">
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
Reprendre la session en cours
</button>
<button type="button" class="btn-secondary" (click)="openFlags()">
<lucide-icon [img]="Flag" [size]="14"></lucide-icon>
Faits de la partie
</button>
</section>
<!-- PJ -->
<section class="block">
<div class="block-header">
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2>
<button type="button" class="btn-add" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PJ
<div class="header-info">
<h1>{{ playthrough.name }}</h1>
@if (playthrough.description) {
<p class="subtitle">{{ playthrough.description }}</p>
}
</div>
<div class="header-actions">
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- Bloc action principal : démarrer ou reprendre la session -->
<section class="play-action">
@if (!activeOnThis) {
<button type="button" class="btn-primary big"
[disabled]="startingSession"
(click)="startSession()">
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
Lancer une session
</button>
}
@if (activeOnThis) {
<button type="button" class="btn-primary big"
(click)="openSession(activeOnThis)">
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
Reprendre la session en cours
</button>
}
<button type="button" class="btn-secondary" (click)="openFlags()">
<lucide-icon [img]="Flag" [size]="14"></lucide-icon>
Faits de la partie
</button>
</div>
<p class="empty" *ngIf="characters.length === 0">Aucun PJ pour cette partie.</p>
<ul class="character-list" *ngIf="characters.length > 0">
<li *ngFor="let c of characters">
<a [routerLink]="['/campaigns', campaignId, 'playthroughs', playthroughId, 'characters', c.id]">{{ c.name }}</a>
</li>
</ul>
</section>
<!-- Sessions -->
<section class="block">
<h2>Sessions</h2>
<p class="empty" *ngIf="sessions.length === 0">Aucune session encore. Lancez la première !</p>
<ul class="session-list" *ngIf="sessions.length > 0">
<li *ngFor="let s of sessions" (click)="openSession(s)">
<span class="session-name">{{ s.name }}</span>
<span class="session-status" [class.active]="s.active">{{ s.active ? 'En cours' : 'Terminée' }}</span>
</li>
</ul>
</section>
</div>
</section>
<!-- PJ -->
<section class="block">
<div class="block-header">
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2>
<button type="button" class="btn-add" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PJ
</button>
</div>
@if (characters.length === 0) {
<p class="empty">Aucun PJ pour cette partie.</p>
}
@if (characters.length > 0) {
<ul class="character-list">
@for (c of characters; track c) {
<li>
<a [routerLink]="['/campaigns', campaignId, 'playthroughs', playthroughId, 'characters', c.id]">{{ c.name }}</a>
</li>
}
</ul>
}
</section>
<!-- Sessions -->
<section class="block">
<h2>Sessions</h2>
@if (sessions.length === 0) {
<p class="empty">Aucune session encore. Lancez la première !</p>
}
@if (sessions.length > 0) {
<ul class="session-list">
@for (s of sessions; track s) {
<li (click)="openSession(s)">
<span class="session-name">{{ s.name }}</span>
<span class="session-status" [class.active]="s.active">{{ s.active ? 'En cours' : 'Terminée' }}</span>
</li>
}
</ul>
}
</section>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
@@ -25,7 +25,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-playthrough-detail',
imports: [CommonModule, RouterModule, LucideAngularModule],
imports: [RouterModule, LucideAngularModule],
templateUrl: './playthrough-detail.component.html',
styleUrls: ['./playthrough-detail.component.scss']
})

View File

@@ -1,20 +1,21 @@
<div class="flags-page" *ngIf="playthrough">
<header class="page-header">
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<div>
<h1>Faits — {{ playthrough.name }}</h1>
<p class="subtitle">
Faits booléens propres à cette partie. Toggle-les en jeu pour débloquer
les quêtes qui en dépendent.
</p>
</div>
</header>
<app-playthrough-flags-manager
[campaignId]="campaignId"
[playthroughId]="playthroughId">
</app-playthrough-flags-manager>
</div>
@if (playthrough) {
<div class="flags-page">
<header class="page-header">
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<div>
<h1>Faits — {{ playthrough.name }}</h1>
<p class="subtitle">
Faits booléens propres à cette partie. Toggle-les en jeu pour débloquer
les quêtes qui en dépendent.
</p>
</div>
</header>
<app-playthrough-flags-manager
[campaignId]="campaignId"
[playthroughId]="playthroughId">
</app-playthrough-flags-manager>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
@@ -20,7 +20,7 @@ import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-fl
*/
@Component({
selector: 'app-playthrough-flags-page',
imports: [CommonModule, RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent],
imports: [RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent],
templateUrl: './playthrough-flags-page.component.html',
styleUrls: ['./playthrough-flags-page.component.scss']
})

View File

@@ -13,7 +13,9 @@
<h1>{{ tableId ? 'Éditer la table' : 'Nouvelle table aléatoire' }}</h1>
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div>
@if (errorMessage) {
<div class="error">{{ errorMessage }}</div>
}
<div class="form-row">
<label for="rt-name">Nom *</label>
@@ -28,7 +30,7 @@
<div class="form-row">
<label for="rt-formula">Formule du dé *</label>
<input id="rt-formula" type="text" [(ngModel)]="diceFormula" placeholder="1d20, 2d6, d100…"
[class.invalid]="diceFormula && !formulaValid">
[class.invalid]="diceFormula && !formulaValid">
<small class="hint" [class.bad]="diceFormula && !formulaValid">
{{ formulaValid ? 'Formule valide' : 'Format attendu : NdM (ex. 1d20, 2d6, d100)' }}
</small>
@@ -41,13 +43,15 @@
</div>
<p class="ai-hint">Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
<textarea rows="2" [(ngModel)]="aiPrompt"
placeholder="Ex: rencontres aléatoires dans une forêt hantée, ton sombre"></textarea>
placeholder="Ex: rencontres aléatoires dans une forêt hantée, ton sombre"></textarea>
<div class="ai-actions">
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
{{ generating ? 'Génération…' : 'Générer (' + diceFormula + ')' }}
</button>
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span>
@if (aiError) {
<span class="ai-error">{{ aiError }}</span>
}
</div>
</div>
@@ -71,15 +75,19 @@
<span class="c-del"></span>
</div>
<div class="entry-row" *ngFor="let e of entries; let i = index">
<input class="c-range" type="number" [(ngModel)]="e.minRoll">
<input class="c-range" type="number" [(ngModel)]="e.maxRoll">
<input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat">
<input class="c-detail" type="text" [(ngModel)]="e.detail" placeholder="Détail (optionnel)">
<button class="c-del btn-del" (click)="removeEntry(i)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
@for (e of entries; track e; let i = $index) {
<div class="entry-row">
<input class="c-range" type="number" [(ngModel)]="e.minRoll">
<input class="c-range" type="number" [(ngModel)]="e.maxRoll">
<input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat">
<input class="c-detail" type="text" [(ngModel)]="e.detail" placeholder="Détail (optionnel)">
<button class="c-del btn-del" (click)="removeEntry(i)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
}
<p class="empty-hint" *ngIf="entries.length === 0">Aucune entrée — clique « Ajouter ».</p>
@if (entries.length === 0) {
<p class="empty-hint">Aucune entrée — clique « Ajouter ».</p>
}
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Wand2, Sparkles } from 'lucide-angular';
@@ -15,7 +15,7 @@ import { DiceUtils } from '../../../shared/dice.utils';
*/
@Component({
selector: 'app-random-table-edit',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './random-table-edit.component.html',
styleUrls: ['./random-table-edit.component.scss']
})

View File

@@ -1,57 +1,75 @@
<div class="rt-page" *ngIf="table">
<div class="rt-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Éditer
</button>
@if (table) {
<div class="rt-page">
<div class="rt-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Éditer
</button>
</div>
<header class="rt-header">
<h1><lucide-icon [img]="Dices" [size]="22"></lucide-icon> {{ table.name }}</h1>
@if (table.description) {
<p class="rt-desc">{{ table.description }}</p>
}
<span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span>
</header>
<!-- Zone de jet -->
<section class="rt-roll">
<button class="btn-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Lancer {{ table.diceFormula }}
</button>
@if (lastRoll) {
<div class="rt-result">
<span class="rt-total">{{ lastRoll.total }}</span>
@if (lastRoll.rolls.length > 1) {
<span class="rt-rolls">({{ lastRoll.rolls.join(' + ') }})</span>
}
<span class="rt-arrow"></span>
@if (matched) {
<span class="rt-matched">{{ matched.label }}</span>
}
@if (!matched) {
<span class="rt-nomatch">Aucune entrée pour ce résultat</span>
}
</div>
}
</section>
@if (matched?.detail) {
<div class="rt-detail">{{ matched?.detail }}</div>
}
<!-- Liste des entrées -->
<section class="rt-entries">
@if (table.entries.length === 0) {
<div class="rt-empty">
Aucune entrée — édite la table pour en ajouter.
</div>
}
@if (table.entries.length > 0) {
<table>
<thead>
<tr><th class="col-range">Jet</th><th>Résultat</th></tr>
</thead>
<tbody>
@for (e of table.entries; track e) {
<tr [class.matched]="isMatched(e)">
<td class="col-range">{{ rangeLabel(e) }}</td>
<td>
<div class="entry-label">{{ e.label }}</div>
@if (e.detail) {
<div class="entry-detail">{{ e.detail }}</div>
}
</td>
</tr>
}
</tbody>
</table>
}
</section>
</div>
<header class="rt-header">
<h1><lucide-icon [img]="Dices" [size]="22"></lucide-icon> {{ table.name }}</h1>
<p class="rt-desc" *ngIf="table.description">{{ table.description }}</p>
<span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span>
</header>
<!-- Zone de jet -->
<section class="rt-roll">
<button class="btn-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Lancer {{ table.diceFormula }}
</button>
<div class="rt-result" *ngIf="lastRoll">
<span class="rt-total">{{ lastRoll.total }}</span>
<span class="rt-rolls" *ngIf="lastRoll.rolls.length > 1">({{ lastRoll.rolls.join(' + ') }})</span>
<span class="rt-arrow"></span>
<span class="rt-matched" *ngIf="matched">{{ matched.label }}</span>
<span class="rt-nomatch" *ngIf="!matched">Aucune entrée pour ce résultat</span>
</div>
</section>
<div class="rt-detail" *ngIf="matched?.detail">{{ matched?.detail }}</div>
<!-- Liste des entrées -->
<section class="rt-entries">
<div class="rt-empty" *ngIf="table.entries.length === 0">
Aucune entrée — édite la table pour en ajouter.
</div>
<table *ngIf="table.entries.length > 0">
<thead>
<tr><th class="col-range">Jet</th><th>Résultat</th></tr>
</thead>
<tbody>
<tr *ngFor="let e of table.entries" [class.matched]="isMatched(e)">
<td class="col-range">{{ rangeLabel(e) }}</td>
<td>
<div class="entry-label">{{ e.label }}</div>
<div class="entry-detail" *ngIf="e.detail">{{ e.detail }}</div>
</td>
</tr>
</tbody>
</table>
</section>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular';
import { RandomTableService } from '../../../services/random-table.service';
@@ -14,7 +14,7 @@ import { DiceUtils, DiceRoll } from '../../../shared/dice.utils';
*/
@Component({
selector: 'app-random-table-view',
imports: [CommonModule, LucideAngularModule],
imports: [LucideAngularModule],
templateUrl: './random-table-view.component.html',
styleUrls: ['./random-table-view.component.scss']
})

View File

@@ -2,7 +2,9 @@
<div class="page-header">
<h1>Créer une nouvelle scène</h1>
<p class="chapter-ref" *ngIf="chapterName">Chapitre : {{ chapterName }}</p>
@if (chapterName) {
<p class="chapter-ref">Chapitre : {{ chapterName }}</p>
}
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
@@ -15,7 +17,7 @@
formControlName="name"
placeholder="Ex: Arrivée au village"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
/>
</div>
<div class="field">

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
@@ -19,7 +19,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
*/
@Component({
selector: 'app-scene-create',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './scene-create.component.html',
styleUrls: ['./scene-create.component.scss']
})

View File

@@ -7,9 +7,9 @@
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cette scène">
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cette scène">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
@@ -58,7 +58,7 @@
formControlName="name"
placeholder="Ex: Arrivée au village"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
/>
</div>
<div class="field">
@@ -136,60 +136,63 @@
<!-- Section : Branches narratives (graphe intra-chapitre) -->
<app-expandable-section title="Branches narratives" icon="🌿">
<div class="branches-hint" *ngIf="siblingScenes.length === 0">
<small class="field-hint">
💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches.
Créez d'abord d'autres scènes, puis revenez ici pour les connecter.
</small>
</div>
<div class="branches-list" *ngIf="siblingScenes.length > 0">
<div class="branch-item" *ngFor="let branch of branches; let i = index; trackBy: trackByIndex">
<div class="field">
<label>Libellé du choix</label>
<input
type="text"
[value]="branch.label"
(input)="updateBranchLabel(i, $any($event.target).value)"
placeholder="Ex: Si les joueurs attaquent le garde" />
</div>
<div class="field">
<label>Scène de destination *</label>
<select
(change)="updateBranchTarget(i, $any($event.target).value)">
<option value="" [selected]="!branch.targetSceneId">— Choisir une scène —</option>
<option *ngFor="let s of siblingScenes"
[value]="s.id"
[selected]="s.id === branch.targetSceneId">{{ s.name }}</option>
</select>
</div>
<div class="field">
<label>Condition MJ (optionnel)</label>
<input
type="text"
[value]="branch.condition || ''"
(input)="updateBranchCondition(i, $any($event.target).value)"
placeholder="Ex: Jet de Persuasion DD 15 réussi" />
</div>
<button type="button" class="btn-remove-branch" (click)="removeBranch(i)"
title="Supprimer cette branche">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Retirer
</button>
@if (siblingScenes.length === 0) {
<div class="branches-hint">
<small class="field-hint">
💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches.
Créez d'abord d'autres scènes, puis revenez ici pour les connecter.
</small>
</div>
}
<button type="button" class="btn-add-branch" (click)="addBranch()">
+ Ajouter une branche
</button>
<small class="field-hint">
Chaque branche représente une "sortie" possible depuis cette scène selon l'action des joueurs.
Les cibles sont limitées aux scènes du même chapitre.
</small>
</div>
@if (siblingScenes.length > 0) {
<div class="branches-list">
@for (branch of branches; track $index; let i = $index) {
<div class="branch-item">
<div class="field">
<label>Libellé du choix</label>
<input
type="text"
[value]="branch.label"
(input)="updateBranchLabel(i, $any($event.target).value)"
placeholder="Ex: Si les joueurs attaquent le garde" />
</div>
<div class="field">
<label>Scène de destination *</label>
<select
(change)="updateBranchTarget(i, $any($event.target).value)">
<option value="" [selected]="!branch.targetSceneId">— Choisir une scène —</option>
@for (s of siblingScenes; track s) {
<option
[value]="s.id"
[selected]="s.id === branch.targetSceneId">{{ s.name }}</option>
}
</select>
</div>
<div class="field">
<label>Condition MJ (optionnel)</label>
<input
type="text"
[value]="branch.condition || ''"
(input)="updateBranchCondition(i, $any($event.target).value)"
placeholder="Ex: Jet de Persuasion DD 15 réussi" />
</div>
<button type="button" class="btn-remove-branch" (click)="removeBranch(i)"
title="Supprimer cette branche">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Retirer
</button>
</div>
}
<button type="button" class="btn-add-branch" (click)="addBranch()">
+ Ajouter une branche
</button>
<small class="field-hint">
Chaque branche représente une "sortie" possible depuis cette scène selon l'action des joueurs.
Les cibles sont limitées aux scènes du même chapitre.
</small>
</div>
}
</app-expandable-section>
<!-- Section : Combat ou rencontre -->
@@ -210,26 +213,30 @@
</app-expandable-section>
<!-- Section : Pages Lore associées (B2 cross-context) -->
<app-expandable-section title="Pages Lore associées" icon="🔗" *ngIf="loreId">
@if (loreId) {
<app-expandable-section title="Pages Lore associées" icon="🔗">
<div class="field">
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page.
</small>
</div>
</app-expandable-section>
}
@if (!loreId) {
<div class="field">
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page.
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir épingler des pages du Lore à cette scène.
</small>
</div>
</app-expandable-section>
<div class="field" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir épingler des pages du Lore à cette scène.
</small>
</div>
}
<!-- Lieu explorable : pièces / donjon -->
<app-expandable-section title="Lieu explorable (donjon, crypte…)" icon="🏰" [initiallyOpen]="rooms.length > 0">

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -30,7 +30,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-scene-edit',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent],
imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent],
templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss']
})
@@ -218,7 +218,6 @@ export class SceneEditComponent implements OnInit, OnDestroy {
// ─────────────── Gestion des branches narratives ───────────────
trackByIndex = (i: number) => i;
addBranch(): void {
this.branches.push({ label: '', targetSceneId: '', condition: '' });

View File

@@ -1,147 +1,188 @@
<div class="view-page" *ngIf="scene">
<header class="view-header">
<div>
<h1>
<lucide-icon *ngIf="scene.icon" [img]="resolveCampaignIcon(scene.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ scene.name }}
</h1>
<p class="view-subtitle">Scène</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteScene()" title="Supprimer la scène">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(scene.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<!-- Description courte -->
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📝</span> Description</h2>
<p class="view-section-body" *ngIf="scene.description?.trim(); else emptyDesc">{{ scene.description }}</p>
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<!-- Contexte et ambiance -->
<div class="view-row" *ngIf="scene.location?.trim() || scene.timing?.trim()">
<section class="view-section" *ngIf="scene.location?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">📍</span> Lieu</h2>
<p class="view-section-body">{{ scene.location }}</p>
</section>
<section class="view-section" *ngIf="scene.timing?.trim()">
<h2 class="view-section-title"><span class="view-section-icon"></span> Moment</h2>
<p class="view-section-body">{{ scene.timing }}</p>
@if (scene) {
<div class="view-page">
<header class="view-header">
<div>
<h1>
@if (scene.icon) {
<lucide-icon [img]="resolveCampaignIcon(scene.icon)" [size]="22" class="title-icon"></lucide-icon>
}
{{ scene.name }}
</h1>
<p class="view-subtitle">Scène</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteScene()" title="Supprimer la scène">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- Illustrations (rendu editorial magazine) -->
@if ((scene.illustrationImageIds?.length ?? 0) > 0) {
<section class="view-section">
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
}
<!-- Cartes & plans -->
@if ((scene.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
}
<!-- Description courte -->
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📝</span> Description</h2>
@if (scene.description?.trim()) {
<p class="view-section-body">{{ scene.description }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<!-- Contexte et ambiance -->
@if (scene.location?.trim() || scene.timing?.trim()) {
<div class="view-row">
@if (scene.location?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📍</span> Lieu</h2>
<p class="view-section-body">{{ scene.location }}</p>
</section>
}
@if (scene.timing?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Moment</h2>
<p class="view-section-body">{{ scene.timing }}</p>
</section>
}
</div>
}
@if (scene.atmosphere?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🌫️</span> Ambiance et atmosphère</h2>
<p class="view-section-body">{{ scene.atmosphere }}</p>
</section>
}
<!-- Narration pour les joueurs -->
@if (scene.playerNarration?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Narration pour les joueurs</h2>
<p class="view-section-body">{{ scene.playerNarration }}</p>
</section>
}
<!-- Choix et conséquences -->
@if (scene.choicesConsequences?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔀</span> Choix et conséquences</h2>
<p class="view-section-body">{{ scene.choicesConsequences }}</p>
</section>
}
<!-- Combat ou rencontre -->
@if (scene.combatDifficulty?.trim() || scene.enemies?.trim()) {
@if (scene.combatDifficulty?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2>
<p class="view-section-body">{{ scene.combatDifficulty }}</p>
</section>
}
@if (scene.enemies?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2>
<p class="view-section-body">{{ scene.enemies }}</p>
</section>
}
}
<!-- Notes et secrets du MJ (privé) -->
@if (scene.gmSecretNotes?.trim()) {
<section class="view-section view-section--private">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes et secrets du MJ
</h2>
<p class="view-section-body">{{ scene.gmSecretNotes }}</p>
</section>
}
<!-- Pages Lore liées -->
@if (loreId && (scene.relatedPageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
@for (relId of scene.relatedPageIds; track relId) {
<a class="view-chip"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
}
</div>
</section>
}
<!-- Lieu explorable : pièces (mode donjon) -->
@if ((scene.rooms?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🏰</span> Pièces du lieu</h2>
<div class="rooms-readonly">
@for (r of scene.rooms; track r; let i = $index) {
<article class="room-readonly">
<header class="room-readonly-head">
<span class="room-readonly-index">#{{ i + 1 }}</span>
<span class="room-readonly-name">{{ r.name }}</span>
@if (r.floor !== null && r.floor !== undefined) {
<span class="room-readonly-floor">
Étage {{ r.floor }}
</span>
}
</header>
@if (r.description?.trim()) {
<p class="room-readonly-desc">{{ r.description }}</p>
}
<div class="room-readonly-grid">
@if (r.enemies?.trim()) {
<div>
<strong>⚔️ Ennemis</strong>
<p>{{ r.enemies }}</p>
</div>
}
@if (r.loot?.trim()) {
<div>
<strong>💰 Loot</strong>
<p>{{ r.loot }}</p>
</div>
}
@if (r.traps?.trim()) {
<div>
<strong>⚠️ Pièges</strong>
<p>{{ r.traps }}</p>
</div>
}
@if (r.gmNotes?.trim()) {
<div class="room-readonly-private">
<strong>🔒 Notes MJ</strong>
<p>{{ r.gmNotes }}</p>
</div>
}
</div>
@if ((r.branches?.length ?? 0) > 0) {
<div class="room-readonly-branches">
<strong>→ Sorties</strong>
<ul>
@for (b of r.branches; track b) {
<li>
<em>{{ b.label }}</em> → {{ roomNameById(scene, b.targetRoomId) }}
@if (b.condition?.trim()) {
<span class="branch-cond">(si : {{ b.condition }})</span>
}
</li>
}
</ul>
</div>
}
</article>
}
</div>
</section>
}
</div>
<section class="view-section" *ngIf="scene.atmosphere?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">🌫️</span> Ambiance et atmosphère</h2>
<p class="view-section-body">{{ scene.atmosphere }}</p>
</section>
<!-- Narration pour les joueurs -->
<section class="view-section" *ngIf="scene.playerNarration?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Narration pour les joueurs</h2>
<p class="view-section-body">{{ scene.playerNarration }}</p>
</section>
<!-- Choix et conséquences -->
<section class="view-section" *ngIf="scene.choicesConsequences?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">🔀</span> Choix et conséquences</h2>
<p class="view-section-body">{{ scene.choicesConsequences }}</p>
</section>
<!-- Combat ou rencontre -->
<ng-container *ngIf="scene.combatDifficulty?.trim() || scene.enemies?.trim()">
<section class="view-section" *ngIf="scene.combatDifficulty?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2>
<p class="view-section-body">{{ scene.combatDifficulty }}</p>
</section>
<section class="view-section" *ngIf="scene.enemies?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2>
<p class="view-section-body">{{ scene.enemies }}</p>
</section>
</ng-container>
<!-- Notes et secrets du MJ (privé) -->
<section class="view-section view-section--private" *ngIf="scene.gmSecretNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes et secrets du MJ
</h2>
<p class="view-section-body">{{ scene.gmSecretNotes }}</p>
</section>
<!-- Pages Lore liées -->
<section class="view-section" *ngIf="loreId && (scene.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of scene.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
<!-- Lieu explorable : pièces (mode donjon) -->
<section class="view-section" *ngIf="(scene.rooms?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🏰</span> Pièces du lieu</h2>
<div class="rooms-readonly">
<article class="room-readonly" *ngFor="let r of scene.rooms; let i = index">
<header class="room-readonly-head">
<span class="room-readonly-index">#{{ i + 1 }}</span>
<span class="room-readonly-name">{{ r.name }}</span>
<span class="room-readonly-floor" *ngIf="r.floor !== null && r.floor !== undefined">
Étage {{ r.floor }}
</span>
</header>
<p class="room-readonly-desc" *ngIf="r.description?.trim()">{{ r.description }}</p>
<div class="room-readonly-grid">
<div *ngIf="r.enemies?.trim()">
<strong>⚔️ Ennemis</strong>
<p>{{ r.enemies }}</p>
</div>
<div *ngIf="r.loot?.trim()">
<strong>💰 Loot</strong>
<p>{{ r.loot }}</p>
</div>
<div *ngIf="r.traps?.trim()">
<strong>⚠️ Pièges</strong>
<p>{{ r.traps }}</p>
</div>
<div *ngIf="r.gmNotes?.trim()" class="room-readonly-private">
<strong>🔒 Notes MJ</strong>
<p>{{ r.gmNotes }}</p>
</div>
</div>
<div class="room-readonly-branches" *ngIf="(r.branches?.length ?? 0) > 0">
<strong>→ Sorties</strong>
<ul>
<li *ngFor="let b of r.branches">
<em>{{ b.label }}</em> → {{ roomNameById(scene, b.targetRoomId) }}
<span class="branch-cond" *ngIf="b.condition?.trim()">(si : {{ b.condition }})</span>
</li>
</ul>
</div>
</article>
</div>
</section>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
@@ -24,7 +24,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-scene-view',
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './scene-view.component.html',
styleUrls: ['./scene-view.component.scss']
})