Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m53s
Build & Push Images / build (core) (push) Successful in 3m40s
Build & Push Images / build-switcher (push) Successful in 16s

mode séance, battlemaps multiples et export Foundry ciblé

- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
  ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
  Fusion quête/conteneur dans la sidebar, progression par partie (statuts
  disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
  automatiquement (arc technique « Quêtes libres », V21 : levée de la
  contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
  scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
  pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
  brouillons de scènes en propose→applique (brain : narrative-fields,
  scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
  épinglée (V19), récap « précédemment » (brain : session-recap), onglet
  « Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
  liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
  1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
  étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
  légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
  tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
  (respect du périmètre + une Scene Foundry par variante de carte,
  rétro-compatible anciens bundles).
This commit is contained in:
2026-07-03 15:29:23 +02:00
parent 268f4721ce
commit b2c3800bf8
278 changed files with 15570 additions and 1451 deletions

View File

@@ -0,0 +1,158 @@
<div class="clocks-manager">
@if (clocks.length === 0 && fronts.length === 0 && !loading) {
<p class="empty">{{ 'clocksManager.empty' | translate }}</p>
}
@for (group of groups; track group.front?.id ?? '__ungrouped__') {
<div class="front-group">
@if (group.front; as front) {
<div class="front-header">
<div class="front-title">
<span class="front-name">{{ front.name }}</span>
@if (front.description) { <span class="front-desc">{{ front.description }}</span> }
</div>
<span class="front-progress">{{ frontProgressLabel(group.clocks) }}</span>
<button type="button" class="btn-del-front" (click)="removeFront(front)"
[title]="'clocksManager.deleteFront' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
} @else {
<div class="front-header ungrouped">
<span class="front-name">{{ 'clocksManager.ungrouped' | translate }}</span>
</div>
}
<div class="clock-grid">
@for (clock of group.clocks; track clock.id) {
<div class="clock-card" [class.complete]="clock.complete" [class.editing]="editingId === clock.id">
<svg viewBox="0 0 64 64" class="clock-svg" role="img"
[attr.aria-label]="clock.name + ' ' + clock.filled + '/' + clock.segments">
@for (seg of segments(clock); track $index) {
<path [attr.d]="seg.d" fill="none"
[attr.stroke]="seg.filled ? '#8b80ff' : '#2f2f42'"
stroke-width="7" stroke-linecap="butt" />
}
<text x="32" y="36" text-anchor="middle" class="clock-count">{{ clock.filled }}/{{ clock.segments }}</text>
</svg>
@if (editingId === clock.id) {
<div class="clock-edit">
<input type="text" [(ngModel)]="editName" [placeholder]="'clocksManager.namePlaceholder' | translate" />
<select [(ngModel)]="editSegments" [attr.aria-label]="'clocksManager.segments' | translate">
@for (s of segmentOptions; track s) {
<option [ngValue]="s">{{ 'clocksManager.segmentsN' | translate:{ n: s } }}</option>
}
</select>
<select [(ngModel)]="editTriggerType" [attr.aria-label]="'clocksManager.triggerType' | translate">
@for (t of triggerTypeOptions; track t) {
<option [ngValue]="t">{{ 'clocksManager.triggerOption.' + t | translate }}</option>
}
</select>
@if (editTriggerType === 'FLAG_SET') {
<input type="text" [(ngModel)]="editTriggerRef" list="clock-flags-list"
[placeholder]="'clocksManager.flagPlaceholder' | translate" />
}
@if (editTriggerType === 'QUEST_COMPLETED') {
<select [(ngModel)]="editTriggerRef" [attr.aria-label]="'clocksManager.quest' | translate">
<option value="">{{ 'clocksManager.selectQuest' | translate }}</option>
@for (q of quests; track q.id) { <option [value]="q.id">{{ q.name }}</option> }
</select>
}
@if (fronts.length > 0) {
<select [(ngModel)]="editFront" [attr.aria-label]="'clocksManager.front' | translate">
<option value="">{{ 'clocksManager.noFront' | translate }}</option>
@for (f of fronts; track f.id) { <option [value]="f.id">{{ f.name }}</option> }
</select>
}
<div class="clock-actions">
<button type="button" class="btn-step" (click)="saveEdit(clock)" [disabled]="!editName.trim()"
[title]="'common.save' | translate">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-step" (click)="cancelEdit()" [title]="'common.cancel' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
</div>
} @else {
<div class="clock-name" [title]="clock.description || clock.name">{{ clock.name }}</div>
@if (triggerLabel(clock); as label) {
<div class="clock-trigger"><lucide-icon [img]="Zap" [size]="11"></lucide-icon> {{ label }}</div>
}
<div class="clock-actions">
<button type="button" class="btn-step" (click)="regress(clock)" [disabled]="clock.filled === 0"
[title]="'clocksManager.regress' | translate">
<lucide-icon [img]="Minus" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-step" (click)="advance(clock)" [disabled]="clock.filled >= clock.segments"
[title]="'clocksManager.advance' | translate">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-step" (click)="startEdit(clock)" [title]="'clocksManager.edit' | translate">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-del" (click)="remove(clock)" [title]="'common.delete' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
}
</div>
}
</div>
</div>
}
<!-- Créer un front -->
<div class="front-create">
<input type="text" [(ngModel)]="newFrontName" [placeholder]="'clocksManager.frontPlaceholder' | translate"
(keyup.enter)="createFront()" />
<button type="button" class="btn-add" (click)="createFront()" [disabled]="!newFrontName.trim()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ 'clocksManager.addFront' | translate }}
</button>
</div>
<!-- Créer une horloge -->
<div class="clock-create">
<input type="text" [(ngModel)]="newName" [placeholder]="'clocksManager.namePlaceholder' | translate"
(keyup.enter)="create()" />
<select [(ngModel)]="newSegments" [attr.aria-label]="'clocksManager.segments' | translate">
@for (s of segmentOptions; track s) {
<option [ngValue]="s">{{ 'clocksManager.segmentsN' | translate:{ n: s } }}</option>
}
</select>
<select [(ngModel)]="newTriggerType" [attr.aria-label]="'clocksManager.triggerType' | translate">
@for (t of triggerTypeOptions; track t) {
<option [ngValue]="t">{{ 'clocksManager.triggerOption.' + t | translate }}</option>
}
</select>
@if (newTriggerType === 'FLAG_SET') {
<input type="text" [(ngModel)]="newTriggerRef" list="clock-flags-list"
[placeholder]="'clocksManager.flagPlaceholder' | translate" />
<datalist id="clock-flags-list">
@for (f of referencedFlags; track f) { <option [value]="f"></option> }
</datalist>
}
@if (newTriggerType === 'QUEST_COMPLETED') {
<select [(ngModel)]="newTriggerRef" [attr.aria-label]="'clocksManager.quest' | translate">
<option value="">{{ 'clocksManager.selectQuest' | translate }}</option>
@for (q of quests; track q.id) {
<option [value]="q.id">{{ q.name }}</option>
}
</select>
}
@if (fronts.length > 0) {
<select [(ngModel)]="newClockFront" [attr.aria-label]="'clocksManager.front' | translate">
<option value="">{{ 'clocksManager.noFront' | translate }}</option>
@for (f of fronts; track f.id) {
<option [value]="f.id">{{ f.name }}</option>
}
</select>
}
<button type="button" class="btn-add" (click)="create()" [disabled]="!newName.trim()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ 'clocksManager.add' | translate }}
</button>
</div>
</div>

View File

@@ -0,0 +1,175 @@
.clocks-manager {
display: flex;
flex-direction: column;
gap: 1rem;
}
.empty {
margin: 0;
color: #9ca3af;
font-style: italic;
}
.front-group {
margin-bottom: 1.1rem;
}
.front-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.5rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid #2a2a3d;
.front-title { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.front-name { font-weight: 600; color: #cbb9ff; }
.front-desc { font-size: 0.8rem; color: #9ca3af; }
.front-progress { font-size: 0.85rem; color: #9ca3af; white-space: nowrap; }
&.ungrouped {
border-bottom-style: dashed;
.front-name { color: #9ca3af; font-weight: 500; font-size: 0.9rem; }
}
.btn-del-front {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
background: transparent;
border: 1px solid #3a3658;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
&:hover { background: #3a1d1d; border-color: #b4554f; color: #ffb4ae; }
}
}
.front-create {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
input[type="text"] { flex: 1; min-width: 160px; }
}
.clock-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.clock-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
width: 120px;
padding: 0.6rem;
background: #14141f;
border: 1px solid #2a2a3d;
border-radius: 10px;
&.complete {
border-color: #8b80ff;
box-shadow: inset 0 0 0 1px #8b80ff;
}
&.editing {
width: 240px;
}
.clock-edit {
display: flex;
flex-direction: column;
gap: 0.3rem;
width: 100%;
input, select { width: 100%; box-sizing: border-box; }
}
.clock-svg {
width: 72px;
height: 72px;
}
.clock-count {
fill: #e5e7eb;
font-size: 13px;
font-weight: 600;
}
.clock-name {
max-width: 100%;
font-size: 0.85rem;
font-weight: 500;
color: #f3f4f6;
text-align: center;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clock-trigger {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
padding: 0.1rem 0.4rem;
background: #201b3a;
border: 1px solid #3a3068;
border-radius: 999px;
color: #b9a8ff;
font-size: 0.7rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clock-actions {
display: flex;
gap: 0.3rem;
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
background: #1f1d3a;
border: 1px solid #3a3658;
border-radius: 6px;
color: #cfcbe8;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
&:hover:not(:disabled) { background: #2c2952; color: #fff; }
&:disabled { opacity: 0.35; cursor: default; }
}
.btn-del:hover:not(:disabled) {
background: #3a1d1d;
border-color: #b4554f;
color: #ffb4ae;
}
}
}
.clock-create {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
input[type="text"] { flex: 1; min-width: 160px; }
select { min-width: 120px; }
}

View File

@@ -0,0 +1,245 @@
import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { LucideAngularModule, Plus, Minus, Trash2, Zap, Pencil, Check, X } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ClockService } from '../../services/clock.service';
import { Clock, ClockTrigger } from '../../services/clock.model';
import { FrontService } from '../../services/front.service';
import { Front } from '../../services/front.model';
import { QuestService } from '../../services/quest.service';
import { Quest } from '../../services/campaign.model';
import { CampaignFlagService } from '../../services/campaign-flag.service';
/** Un groupe affiché : un Front (ou null = horloges libres) et ses horloges. */
interface ClockGroup { front: Front | null; clocks: Clock[]; }
/**
* Gestionnaire des Horloges de progression (Clocks) d'une Partie, regroupées par
* **Front** (menace) : visuel à segments, +/ (avancer/reculer), déclencheur auto
* (co-MJ), création/suppression d'horloges ET de fronts. Auto-chargé via
* {@code playthroughId} ; {@code campaignId} (optionnel) alimente les déclencheurs.
*/
@Component({
selector: 'app-clocks-manager',
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './clocks-manager.component.html',
styleUrls: ['./clocks-manager.component.scss']
})
export class ClocksManagerComponent implements OnInit, OnChanges {
@Input() playthroughId!: string;
@Input() campaignId = '';
readonly Plus = Plus;
readonly Minus = Minus;
readonly Trash2 = Trash2;
readonly Zap = Zap;
readonly Pencil = Pencil;
readonly Check = Check;
readonly X = X;
readonly segmentOptions = [2, 4, 6, 8, 10, 12];
readonly triggerTypeOptions: ClockTrigger[] = ['NONE', 'FLAG_SET', 'QUEST_COMPLETED', 'SESSION_ENDED'];
clocks: Clock[] = [];
fronts: Front[] = [];
quests: Quest[] = [];
referencedFlags: string[] = [];
loading = false;
// Formulaire de création d'horloge.
newName = '';
newSegments = 4;
newTriggerType: ClockTrigger = 'NONE';
newTriggerRef = '';
newClockFront = '';
// Formulaire de création de front.
newFrontName = '';
// Édition in-place d'une horloge.
editingId: string | null = null;
editName = '';
editSegments = 4;
editTriggerType: ClockTrigger = 'NONE';
editTriggerRef = '';
editFront = '';
constructor(
private clockService: ClockService,
private frontService: FrontService,
private questService: QuestService,
private campaignFlagService: CampaignFlagService,
private translate: TranslateService
) {}
ngOnInit(): void { this.reload(); }
ngOnChanges(changes: SimpleChanges): void {
if (changes['playthroughId'] || changes['campaignId']) this.reload();
}
reload(): void {
if (!this.playthroughId) { this.clocks = []; this.fronts = []; return; }
this.loading = true;
forkJoin({
clocks: this.clockService.list(this.playthroughId),
fronts: this.frontService.list(this.playthroughId).pipe(catchError(() => of([] as Front[]))),
quests: this.campaignId
? this.questService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Quest[])))
: of([] as Quest[]),
flags: this.campaignId
? this.campaignFlagService.listReferenced(this.campaignId).pipe(catchError(() => of([] as string[])))
: of([] as string[])
}).subscribe({
next: ({ clocks, fronts, quests, flags }) => {
this.clocks = clocks;
this.fronts = fronts;
this.quests = quests;
this.referencedFlags = flags;
this.loading = false;
},
error: () => { this.clocks = []; this.fronts = []; this.loading = false; }
});
}
/** Groupes affichés : un par Front (avec ses horloges) + un groupe « libres » si besoin. */
get groups(): ClockGroup[] {
const groups: ClockGroup[] = this.fronts.map(f => ({
front: f,
clocks: this.clocks.filter(c => c.frontId === f.id)
}));
const ungrouped = this.clocks.filter(c => !c.frontId);
if (ungrouped.length > 0) groups.push({ front: null, clocks: ungrouped });
return groups;
}
frontProgressLabel(clocks: Clock[]): string {
const filled = clocks.reduce((s, c) => s + c.filled, 0);
const total = clocks.reduce((s, c) => s + c.segments, 0);
return `${filled}/${total}`;
}
// ── Fronts ──────────────────────────────────────────────────────────────
createFront(): void {
const name = this.newFrontName.trim();
if (!name) return;
this.frontService.create(this.playthroughId, { name }).subscribe({
next: f => { this.fronts = [...this.fronts, f]; this.newFrontName = ''; }
});
}
removeFront(front: Front): void {
this.frontService.delete(this.playthroughId, front.id).subscribe({
next: () => {
this.fronts = this.fronts.filter(f => f.id !== front.id);
// Le backend orpheline les horloges (frontId -> null) : on reflète localement.
this.clocks = this.clocks.map(c => c.frontId === front.id ? { ...c, frontId: undefined } : c);
}
});
}
// ── Horloges ────────────────────────────────────────────────────────────
create(): void {
const name = this.newName.trim();
if (!name) return;
const needsRef = this.newTriggerType === 'FLAG_SET' || this.newTriggerType === 'QUEST_COMPLETED';
const ref = needsRef ? (this.newTriggerRef.trim() || undefined) : undefined;
this.clockService.create(this.playthroughId, {
name, segments: this.newSegments, triggerType: this.newTriggerType,
triggerRef: ref, frontId: this.newClockFront || undefined
}).subscribe({
next: c => {
this.clocks = [...this.clocks, c];
this.newName = '';
this.newSegments = 4;
this.newTriggerType = 'NONE';
this.newTriggerRef = '';
this.newClockFront = '';
}
});
}
advance(clock: Clock): void {
this.clockService.advance(this.playthroughId, clock.id).subscribe({ next: u => this.replace(u) });
}
regress(clock: Clock): void {
this.clockService.regress(this.playthroughId, clock.id).subscribe({ next: u => this.replace(u) });
}
remove(clock: Clock): void {
this.clockService.delete(this.playthroughId, clock.id).subscribe({
next: () => this.clocks = this.clocks.filter(c => c.id !== clock.id)
});
}
startEdit(clock: Clock): void {
this.editingId = clock.id;
this.editName = clock.name;
this.editSegments = clock.segments;
this.editTriggerType = clock.triggerType ?? 'NONE';
this.editTriggerRef = clock.triggerRef ?? '';
this.editFront = clock.frontId ?? '';
}
cancelEdit(): void { this.editingId = null; }
saveEdit(clock: Clock): void {
const name = this.editName.trim();
if (!name) return;
const needsRef = this.editTriggerType === 'FLAG_SET' || this.editTriggerType === 'QUEST_COMPLETED';
const ref = needsRef ? (this.editTriggerRef.trim() || undefined) : undefined;
this.clockService.update(this.playthroughId, clock.id, {
name, segments: this.editSegments, triggerType: this.editTriggerType,
triggerRef: ref, frontId: this.editFront || undefined
}).subscribe({
next: u => { this.replace(u); this.editingId = null; }
});
}
private replace(updated: Clock): void {
this.clocks = this.clocks.map(c => c.id === updated.id ? updated : c);
}
/** Libellé lisible du déclencheur auto (badge), ou null si aucun. */
triggerLabel(clock: Clock): string | null {
switch (clock.triggerType) {
case 'FLAG_SET':
return this.translate.instant('clocksManager.triggerFlag', { name: clock.triggerRef });
case 'QUEST_COMPLETED':
return this.translate.instant('clocksManager.triggerQuest', { name: this.questName(clock.triggerRef) });
case 'SESSION_ENDED':
return this.translate.instant('clocksManager.triggerSession');
default:
return null;
}
}
private questName(id?: string): string {
return this.quests.find(q => q.id === id)?.name ?? this.translate.instant('clocksManager.deletedQuest');
}
/**
* Arcs SVG des segments (anneau) d'une horloge : un arc par segment, rempli ou non.
* Évite le wedge plein (point central) → pas de cas dégénéré pour 1 segment.
*/
segments(clock: Clock): { d: string; filled: boolean }[] {
const n = Math.max(1, clock.segments);
const cx = 32, cy = 32, r = 26;
const gap = n > 1 ? 0.12 : 0.0001; // léger espace entre segments (radians)
const out: { d: string; filled: boolean }[] = [];
for (let i = 0; i < n; i++) {
const a0 = (i / n) * 2 * Math.PI - Math.PI / 2 + gap / 2;
const a1 = ((i + 1) / n) * 2 * Math.PI - Math.PI / 2 - gap / 2;
const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
const large = (a1 - a0) > Math.PI ? 1 : 0;
out.push({
d: `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`,
filled: i < clock.filled
});
}
return out;
}
}

View File

@@ -27,6 +27,23 @@
</div>
}
<!-- Références cassées (fiche supprimée) : exposées pour pouvoir les retirer -->
@if (brokenGroups.length > 0) {
<div class="linked-chips">
@for (b of brokenGroups; track b.id) {
<span class="chip chip-broken" [title]="'enemyLinkPicker.brokenTitle' | translate">
<span class="chip-label">
<lucide-icon [img]="AlertTriangle" [size]="12"></lucide-icon>
{{ 'enemyLinkPicker.broken' | translate }}{{ b.count > 1 ? ' ×' + b.count : '' }}
</span>
<button type="button" class="chip-remove" (click)="remove(b.id)" [title]="'enemyLinkPicker.removeLink' | translate">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
</button>
</span>
}
</div>
}
<!-- Input + dropdown de suggestions -->
<div class="search-wrapper">
<input

View File

@@ -75,6 +75,19 @@
}
}
// Chip d'une référence cassée (fiche du bestiaire supprimée) — teinte ambre = « à corriger ».
.chip-broken {
background: #3a2e14;
color: #fcd34d;
.chip-label { cursor: default; }
.chip-label:hover { color: #fde68a; }
.chip-remove {
border-left: 1px solid #4a3a1a;
&:hover { background: #4a3a1a; color: #fde68a; }
}
}
.search-wrapper {
position: relative;
}

View File

@@ -1,7 +1,7 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X, Skull } from 'lucide-angular';
import { LucideAngularModule, X, Skull, AlertTriangle } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { Enemy } from '../../services/enemy.model';
@@ -29,6 +29,7 @@ import { Enemy } from '../../services/enemy.model';
export class EnemyLinkPickerComponent {
readonly X = X;
readonly Skull = Skull;
readonly AlertTriangle = AlertTriangle;
/** IDs des ennemis actuellement liés (contrôlés par le parent). */
@Input() value: string[] = [];
@@ -60,6 +61,25 @@ export class EnemyLinkPickerComponent {
.filter((g): g is { enemy: Enemy; count: number } => !!g.enemy);
}
/**
* IDs liés dont la fiche est INTROUVABLE dans le bestiaire de la campagne (fiche
* supprimée — typiquement un ennemi supprimé puis recréé sous un nouvel id). Le picker
* les cachait silencieusement ; on les EXPOSE (chip « introuvable ») pour que
* l'utilisateur puisse les retirer — sinon la référence morte reste invisible mais
* persistée, et le guidage (readiness) la signale sans que rien ne soit actionnable.
*/
get brokenGroups(): { id: string; count: number }[] {
const counts = new Map<string, number>();
const order: string[] = [];
for (const id of this.value) {
if (!id) continue;
if (this.availableEnemies.some(e => e.id === id)) continue; // résolu → pas cassé
if (!counts.has(id)) order.push(id);
counts.set(id, (counts.get(id) ?? 0) + 1);
}
return order.map(id => ({ id, count: counts.get(id)! }));
}
/** Ennemis proposables dans le dropdown — filtrés par query, exclut les déjà liés. */
get suggestions(): Enemy[] {
const q = this.query.trim().toLowerCase();

View File

@@ -0,0 +1,60 @@
<div class="entity-assist">
<button type="button" class="assist-toggle" (click)="toggle()">
<lucide-icon [img]="open ? ChevronDown : ChevronRight" [size]="15"></lucide-icon>
<lucide-icon [img]="Sparkles" [size]="15" class="spark"></lucide-icon>
<span class="assist-title">{{ 'entityAssist.button' | translate }}</span>
</button>
@if (open) {
<div class="assist-body">
<p class="assist-hint">{{ 'entityAssist.hint' | translate }}</p>
<div class="assist-prompt">
<textarea [(ngModel)]="instruction" rows="2"
[placeholder]="'entityAssist.instructionPlaceholder' | translate"></textarea>
<button type="button" class="btn-generate" (click)="generate()" [disabled]="loading">
<lucide-icon [img]="loading ? RefreshCw : Sparkles" [size]="14" [class.spin]="loading"></lucide-icon>
{{ (loading ? 'entityAssist.generating' : (generated ? 'entityAssist.regenerate' : 'entityAssist.generate')) | translate }}
</button>
</div>
@if (error) {
<p class="assist-error">{{ error }}</p>
}
@if (generated && fields.length === 0 && !loading) {
<p class="assist-empty">{{ 'entityAssist.empty' | translate }}</p>
}
@if (fields.length > 0) {
<ul class="field-list">
@for (f of fields; track f.key) {
<li class="field-card" [class.rejected]="!f.accepted">
<label class="field-check">
<input type="checkbox" [(ngModel)]="f.accepted" />
<span class="field-name">{{ fieldLabel(f.key) }}</span>
</label>
@if (f.currentValue) {
<div class="field-current">
<span class="tag">{{ 'entityAssist.current' | translate }}</span>
<span class="val">{{ f.currentValue }}</span>
</div>
}
<div class="field-proposed">
<span class="tag">{{ 'entityAssist.proposed' | translate }}</span>
<span class="val">{{ f.proposedValue }}</span>
</div>
</li>
}
</ul>
<div class="assist-actions">
<button type="button" class="btn-apply" (click)="apply()" [disabled]="acceptedCount === 0">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
{{ 'entityAssist.applySelection' | translate:{ n: acceptedCount } }}
</button>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,147 @@
.entity-assist {
border: 1px solid #2a2440;
border-radius: 10px;
background: #12101d;
overflow: hidden;
}
.assist-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 0.85rem;
background: transparent;
border: none;
color: #d6ccff;
cursor: pointer;
text-align: left;
&:hover { background: #171327; }
.spark { color: #b9a8ff; }
.assist-title { font-weight: 600; font-size: 0.9rem; }
}
.assist-body {
padding: 0.2rem 0.85rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.assist-hint {
margin: 0;
color: #9ca3af;
font-size: 0.8rem;
}
.assist-prompt {
display: flex;
gap: 0.5rem;
align-items: flex-start;
textarea {
flex: 1;
resize: vertical;
min-height: 2.4rem;
}
}
.btn-generate,
.btn-apply {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.8rem;
border-radius: 7px;
border: 1px solid #3a3068;
background: #201b3a;
color: #d6ccff;
cursor: pointer;
font-size: 0.82rem;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
&:hover:not(:disabled) { background: #2c2358; color: #fff; }
&:disabled { opacity: 0.5; cursor: default; }
}
.btn-apply {
border-color: #2f6b46;
background: #163326;
color: #a7f3d0;
&:hover:not(:disabled) { background: #1c4531; color: #fff; }
}
.spin { animation: assist-spin 0.9s linear infinite; }
@keyframes assist-spin { to { transform: rotate(360deg); } }
.assist-error { margin: 0; color: #fca5a5; font-size: 0.82rem; }
.assist-empty { margin: 0; color: #9ca3af; font-style: italic; font-size: 0.82rem; }
.field-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.field-card {
padding: 0.55rem 0.7rem;
background: #16131f;
border: 1px solid #272233;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.3rem;
&.rejected { opacity: 0.5; }
}
.field-check {
display: flex;
align-items: center;
gap: 0.45rem;
cursor: pointer;
input { accent-color: #8b80ff; }
.field-name { font-weight: 600; color: #e5e7eb; font-size: 0.83rem; }
}
.field-current,
.field-proposed {
display: flex;
gap: 0.4rem;
font-size: 0.82rem;
line-height: 1.3;
.tag {
flex-shrink: 0;
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.05rem 0.35rem;
border-radius: 4px;
height: fit-content;
margin-top: 0.1rem;
}
.val { white-space: pre-wrap; word-break: break-word; }
}
.field-current {
.tag { background: #2a2233; color: #9ca3af; }
.val { color: #8b8595; text-decoration: line-through; text-decoration-color: #4b4458; }
}
.field-proposed {
.tag { background: #201b3a; color: #b9a8ff; }
.val { color: #ede9ff; }
}
.assist-actions {
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,113 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LucideAngularModule, Sparkles, RefreshCw, Check, X, ChevronDown, ChevronRight } from 'lucide-angular';
import { EntityAssistService } from '../../services/entity-assist.service';
import { FieldProposal } from '../../services/entity-assist.model';
interface ReviewField extends FieldProposal {
accepted: boolean;
}
/**
* Panneau « Étoffer avec l'IA » (Pilier A — co-création), générique par type d'entité
* narrative ({@code arc|chapter|scene}). Demande au co-MJ des propositions de valeurs pour
* les champs de l'entité, les affiche en diff (actuel → proposé) avec accept/reject champ
* par champ, et ÉMET les champs retenus.
*
* <p>Ne persiste rien : le parent (arc/chapter/scene-edit) applique les champs retenus au
* FORMULAIRE ; l'utilisateur enregistre ensuite normalement (human-in-the-loop).</p>
*/
@Component({
selector: 'app-entity-assist-panel',
standalone: true,
imports: [FormsModule, TranslatePipe, LucideAngularModule],
templateUrl: './entity-assist-panel.component.html',
styleUrls: ['./entity-assist-panel.component.scss']
})
export class EntityAssistPanelComponent implements OnChanges {
/** Type d'entité : 'arc' | 'chapter' | 'scene'. */
@Input() entityType = 'scene';
@Input() entityId = '';
@Input() campaignId = '';
/** Émet les champs ACCEPTÉS ; le parent les applique où il veut (formulaire, /apply…). */
@Output() applied = new EventEmitter<FieldProposal[]>();
open = false;
instruction = '';
loading = false;
generated = false;
error = '';
fields: ReviewField[] = [];
readonly Sparkles = Sparkles;
readonly RefreshCw = RefreshCw;
readonly Check = Check;
readonly X = X;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
constructor(private assist: EntityAssistService, private translate: TranslateService) {}
/**
* L'écran d'édition RÉUTILISE ce composant quand on navigue entre entités sœurs
* (même route pattern → pas de recréation). On repart donc d'un état vierge dès que
* l'entité ciblée change, sinon on afficherait les propositions de l'entité précédente.
*/
ngOnChanges(changes: SimpleChanges): void {
if (changes['entityId'] && !changes['entityId'].firstChange) {
this.open = false;
this.instruction = '';
this.loading = false;
this.generated = false;
this.error = '';
this.fields = [];
}
}
toggle(): void {
this.open = !this.open;
}
generate(): void {
if (!this.entityId || this.loading) return;
this.loading = true;
this.error = '';
this.generated = false;
this.fields = [];
this.assist.generateFields(this.entityType, this.entityId, this.campaignId, this.instruction.trim() || undefined).subscribe({
next: proposal => {
this.fields = (proposal.fields ?? []).map(f => ({ ...f, accepted: true }));
this.generated = true;
this.loading = false;
},
error: () => {
this.error = this.translate.instant('entityAssist.error');
this.loading = false;
}
});
}
get acceptedCount(): number {
return this.fields.filter(f => f.accepted).length;
}
apply(): void {
const accepted: FieldProposal[] = this.fields
.filter(f => f.accepted)
.map(f => ({ key: f.key, currentValue: f.currentValue, proposedValue: f.proposedValue }));
if (accepted.length > 0) {
this.applied.emit(accepted);
}
this.fields = [];
this.generated = false;
this.open = false;
}
/** Libellé lisible d'une clé de champ (fallback sur la clé brute si non traduite). */
fieldLabel(key: string): string {
const label = this.translate.instant('entityAssist.field.' + key);
return label === 'entityAssist.field.' + key ? key : label;
}
}

View File

@@ -6,6 +6,9 @@
<span class="section-icon">{{ icon }}</span>
}
{{ title }}
@if (filled) {
<span class="section-dot" aria-hidden="true"></span>
}
</span>
<lucide-icon [img]="isOpen ? ChevronUp : ChevronDown" [size]="16"></lucide-icon>
</button>

View File

@@ -42,6 +42,19 @@
font-size: 1rem;
}
// Pastille « cette section contient du contenu » (voir Input `filled`).
.section-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #6c63ff;
flex: 0 0 auto;
}
.private .section-dot {
background: #fca5a5;
}
.section-content {
padding: 1rem 1.1rem 1.2rem 1.1rem;
border-top: 1px solid #374151;

View File

@@ -25,6 +25,12 @@ export class ExpandableSectionComponent {
@Input() icon = ''; // Emoji ou caractère unicode (ex: '📍', '📖')
@Input() initiallyOpen = false;
@Input() variant: 'default' | 'private' = 'default'; // 'private' = notes MJ (couleur différente)
/**
* Pastille « contient du contenu » dans l'en-tête (replié comme déplié).
* Permet de voir d'un coup d'œil quelles sections optionnelles sont remplies
* sans avoir à les ouvrir une à une. Le parent calcule l'état.
*/
@Input() filled = false;
isOpen = false;

View File

@@ -0,0 +1,51 @@
<div class="node-picker">
<div class="node-list">
@for (n of nodes; track $index) {
<div class="node-chip">
<lucide-icon class="node-icon" [img]="n.nodeType === 'SCENE' ? MapPin : BookOpen" [size]="15"></lucide-icon>
<span class="node-name">{{ nodeName(n) || ('nodePicker.deletedNode' | translate) }}</span>
<span class="node-type">{{ (n.nodeType === 'SCENE' ? 'nodePicker.scene' : 'nodePicker.chapter') | translate }}</span>
<button type="button" (click)="moveUp($index)" [disabled]="$index === 0" [title]="'nodePicker.moveUp' | translate">
<lucide-icon [img]="ChevronUp" [size]="15"></lucide-icon>
</button>
<button type="button" (click)="moveDown($index)" [disabled]="$index === nodes.length - 1" [title]="'nodePicker.moveDown' | translate">
<lucide-icon [img]="ChevronDown" [size]="15"></lucide-icon>
</button>
<button type="button" (click)="remove($index)" [title]="'nodePicker.remove' | translate">
<lucide-icon [img]="X" [size]="15"></lucide-icon>
</button>
</div>
}
@if (nodes.length === 0) {
<p class="empty-hint">{{ 'nodePicker.empty' | translate }}</p>
}
</div>
<div class="add-row">
<select [(ngModel)]="selectedChapterId" [attr.aria-label]="'nodePicker.selectChapter' | translate">
<option value="">{{ 'nodePicker.selectChapter' | translate }}</option>
@for (c of availableChapters; track c.id) {
<option [value]="c.id">{{ c.name }}</option>
}
</select>
<button type="button" class="btn-secondary" (click)="addChapter()" [disabled]="!selectedChapterId">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ 'nodePicker.addChapter' | translate }}
</button>
</div>
<div class="add-row">
<select [(ngModel)]="selectedSceneId" [attr.aria-label]="'nodePicker.selectScene' | translate">
<option value="">{{ 'nodePicker.selectScene' | translate }}</option>
@for (s of availableScenes; track s.id) {
<option [value]="s.id">{{ sceneOptionLabel(s) }}</option>
}
</select>
<button type="button" class="btn-secondary" (click)="addScene()" [disabled]="!selectedSceneId">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ 'nodePicker.addScene' | translate }}
</button>
</div>
</div>

View File

@@ -0,0 +1,68 @@
.node-picker {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.node-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.node-chip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 8px;
.node-icon { color: #9ca3af; flex: none; }
.node-name {
flex: 1;
min-width: 0;
color: #e5e7eb;
font-size: 0.88rem;
}
.node-type {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #9ca3af;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem;
background: transparent;
border: none;
border-radius: 4px;
color: #9ca3af;
cursor: pointer;
transition: color 0.15s, background 0.15s;
&:hover:not(:disabled) { color: #fff; background: #2a2a3d; }
&:disabled { opacity: 0.3; cursor: default; }
}
}
.empty-hint {
margin: 0;
font-size: 0.85rem;
font-style: italic;
color: #9ca3af;
}
.add-row {
display: flex;
gap: 0.5rem;
align-items: center;
select { flex: 1; min-width: 0; }
}

View File

@@ -0,0 +1,101 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, X, ChevronUp, ChevronDown, BookOpen, MapPin } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { Chapter, Scene, QuestNodeRef, NodeType } from '../../services/campaign.model';
/**
* Sélecteur de nœuds narratifs (Chapitres / Scènes) traversés par une quête.
* Émet une liste ORDONNÉE de QuestNodeRef ; l'ordre = position dans la liste
* (réindexé à chaque changement). Réutilise les chapitres / scènes déjà chargés
* par {@code loadCampaignTreeData} — aucun appel réseau ici.
*/
@Component({
selector: 'app-node-picker',
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './node-picker.component.html',
styleUrls: ['./node-picker.component.scss']
})
export class NodePickerComponent {
readonly Plus = Plus;
readonly X = X;
readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown;
readonly BookOpen = BookOpen;
readonly MapPin = MapPin;
@Input() nodes: QuestNodeRef[] = [];
@Input() chapters: Chapter[] = [];
@Input() scenes: Scene[] = [];
@Output() nodesChange = new EventEmitter<QuestNodeRef[]>();
selectedChapterId = '';
selectedSceneId = '';
/** Chapitres pas encore rattachés (un même nœud ne se lie qu'une fois). */
get availableChapters(): Chapter[] {
return this.chapters.filter(c => !!c.id && !this.isLinked('CHAPTER', c.id));
}
/** Scènes pas encore rattachées. */
get availableScenes(): Scene[] {
return this.scenes.filter(s => !!s.id && !this.isLinked('SCENE', s.id));
}
private isLinked(type: NodeType, id: string): boolean {
return this.nodes.some(n => n.nodeType === type && n.nodeId === id);
}
addChapter(): void {
if (!this.selectedChapterId) return;
this.emit([...this.nodes, { nodeType: 'CHAPTER', nodeId: this.selectedChapterId, order: this.nodes.length }]);
this.selectedChapterId = '';
}
addScene(): void {
if (!this.selectedSceneId) return;
this.emit([...this.nodes, { nodeType: 'SCENE', nodeId: this.selectedSceneId, order: this.nodes.length }]);
this.selectedSceneId = '';
}
remove(index: number): void {
this.emit(this.nodes.filter((_, i) => i !== index));
}
moveUp(index: number): void {
if (index <= 0) return;
const next = [...this.nodes];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
this.emit(next);
}
moveDown(index: number): void {
if (index >= this.nodes.length - 1) return;
const next = [...this.nodes];
[next[index + 1], next[index]] = [next[index], next[index + 1]];
this.emit(next);
}
/** Réindexe {@code order} sur la position courante avant de propager. */
private emit(nodes: QuestNodeRef[]): void {
this.nodesChange.emit(nodes.map((n, i) => ({ ...n, order: i })));
}
// ── Libellés ──────────────────────────────────────────────────────────────
/** Libellé d'un nœud lié ('' si l'entité a été supprimée → géré dans le template). */
nodeName(n: QuestNodeRef): string {
if (n.nodeType === 'CHAPTER') {
return this.chapters.find(c => c.id === n.nodeId)?.name ?? '';
}
const scene = this.scenes.find(s => s.id === n.nodeId);
if (!scene) return '';
const chapter = this.chapters.find(c => c.id === scene.chapterId);
return chapter ? `${chapter.name} ${scene.name}` : scene.name;
}
/** Option du menu déroulant des scènes : préfixée du chapitre pour lever l'ambiguïté. */
sceneOptionLabel(s: Scene): string {
const chapter = this.chapters.find(c => c.id === s.chapterId);
return chapter ? `${chapter.name} ${s.name}` : s.name;
}
}

View File

@@ -3,7 +3,13 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { Chapter, Prerequisite } from '../../services/campaign.model';
import { Prerequisite } from '../../services/campaign.model';
/** Cible candidate pour un prérequis QUEST_COMPLETED (Chapter HUB hérité ou Quest). */
export interface PrerequisiteTarget {
id?: string;
name: string;
}
/**
* Éditeur des prérequis (conditions de déblocage) d'une quête.
@@ -22,8 +28,8 @@ export class PrerequisiteEditorComponent {
/** Liste courante. */
@Input() prerequisites: Prerequisite[] = [];
/** Quêtes candidates pour QUEST_COMPLETED (typiquement les chapitres frères du Hub). */
@Input() availableQuests: Chapter[] = [];
/** Quêtes candidates pour QUEST_COMPLETED (Chapitres HUB hérités ou Quests). */
@Input() availableQuests: PrerequisiteTarget[] = [];
/** Flags déjà connus de la campagne (autocomplete pour FLAG_SET). */
@Input() availableFlags: string[] = [];

View File

@@ -0,0 +1,50 @@
@if (assessment) {
<section class="readiness-panel" [class.ready]="isReady">
<button type="button" class="readiness-header" (click)="toggle()">
<lucide-icon [img]="collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
@if (isReady) {
<lucide-icon [img]="CheckCircle2" [size]="16" class="ready-icon"></lucide-icon>
} @else {
<lucide-icon [img]="AlertTriangle" [size]="16" class="warn-icon"></lucide-icon>
}
<span class="readiness-title">{{ 'readiness.title' | translate }}</span>
<span class="status-badge" [class]="'status-' + assessment.overallStatus">
{{ ('readiness.badge.' + assessment.overallStatus) | translate }}
</span>
@if (!collapsed) {
<span class="counts">
@if (blockingCount > 0) {
<span class="count-chip blocking">{{ 'readiness.blockingN' | translate:{ n: blockingCount } }}</span>
}
@if (recommendedCount > 0) {
<span class="count-chip recommended">{{ 'readiness.recommendedN' | translate:{ n: recommendedCount } }}</span>
}
</span>
}
</button>
@if (!collapsed) {
@if (isReady) {
<p class="ready-msg">{{ 'readiness.allSet' | translate }}</p>
} @else {
<ul class="gap-list">
@for (gap of assessment.gaps; track gap.ruleId + gap.entityId) {
<li class="gap-card" [class]="'sev-' + gap.severity">
<span class="sev-dot"></span>
<div class="gap-body">
<span class="gap-message">{{ gap.message }}</span>
@if (gap.entityName) {
<span class="gap-entity">{{ gap.entityName }}</span>
}
</div>
<a class="gap-cta" [routerLink]="gapLink(gap)" [queryParams]="gapQuery(gap)">
{{ 'readiness.fix' | translate }}
<lucide-icon [img]="ArrowRight" [size]="13"></lucide-icon>
</a>
</li>
}
</ul>
}
}
</section>
}

View File

@@ -0,0 +1,135 @@
.readiness-panel {
margin: 0 0 1.25rem;
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 10px;
overflow: hidden;
&.ready { border-color: #14532d; }
}
.readiness-header {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
padding: 0.7rem 0.9rem;
background: transparent;
border: none;
color: #e5e7eb;
cursor: pointer;
text-align: left;
&:hover { background: #111827; }
.ready-icon { color: #4ade80; }
.warn-icon { color: #fb923c; }
}
.readiness-title {
font-weight: 600;
font-size: 0.92rem;
}
.status-badge {
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
&.status-DRAFT { background: #3a1d1d; color: #fca5a5; }
&.status-PLAYABLE { background: #3a2e1a; color: #fcd34d; }
&.status-POLISHED { background: #14311f; color: #86efac; }
}
.counts {
display: inline-flex;
gap: 0.35rem;
margin-left: auto;
}
.count-chip {
padding: 0.1rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 500;
&.blocking { background: #3a1d1d; color: #fca5a5; }
&.recommended { background: #3a2e1a; color: #fcd34d; }
}
.ready-msg {
margin: 0;
padding: 0 0.9rem 0.9rem;
color: #86efac;
font-size: 0.85rem;
}
.gap-list {
list-style: none;
margin: 0;
padding: 0 0.6rem 0.6rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.gap-card {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.7rem;
background: #111827;
border: 1px solid #1f2937;
border-radius: 8px;
.sev-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
&.sev-BLOCKING .sev-dot { background: #ef4444; }
&.sev-RECOMMENDED .sev-dot { background: #fb923c; }
&.sev-OPTIONAL .sev-dot { background: #6b7280; }
}
.gap-body {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.gap-message {
color: #e5e7eb;
font-size: 0.83rem;
line-height: 1.25;
}
.gap-entity {
color: #9ca3af;
font-size: 0.74rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gap-cta {
display: inline-flex;
align-items: center;
gap: 0.2rem;
flex-shrink: 0;
padding: 0.3rem 0.6rem;
background: #1f1d3a;
border: 1px solid #3a3658;
border-radius: 6px;
color: #c7d2fe;
font-size: 0.76rem;
text-decoration: none;
transition: background 0.15s, color 0.15s;
&:hover { background: #2c2952; color: #fff; }
}

View File

@@ -0,0 +1,83 @@
import { Component, Input, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { LucideAngularModule, ChevronDown, ChevronRight, CheckCircle2, AlertTriangle, ArrowRight } from 'lucide-angular';
import { ReadinessService } from '../../services/readiness.service';
import { CampaignReadinessAssessment, ReadinessGap } from '../../services/readiness.model';
import { gapAction } from '../../campaigns/gap-action.helper';
/**
* Panneau « Prochaines étapes » (Pilier B — guidage co-MJ). Affiche le statut de
* préparation agrégé d'une campagne et la liste des manques à combler, chacun
* cliquable vers l'éditeur concerné. Purement indicatif (ne bloque rien).
*
* <p>Peut recevoir un {@link CampaignReadinessAssessment} déjà chargé via
* {@code [assessment]} (évite un second appel quand le parent l'a déjà) ; sinon
* il le charge lui-même à partir de {@code [campaignId]}.</p>
*/
@Component({
selector: 'app-readiness-panel',
standalone: true,
imports: [RouterLink, TranslatePipe, LucideAngularModule],
templateUrl: './readiness-panel.component.html',
styleUrls: ['./readiness-panel.component.scss']
})
export class ReadinessPanelComponent implements OnInit {
@Input() campaignId = '';
@Input() assessment: CampaignReadinessAssessment | null = null;
loading = false;
collapsed = false;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
readonly CheckCircle2 = CheckCircle2;
readonly AlertTriangle = AlertTriangle;
readonly ArrowRight = ArrowRight;
private static readonly STORAGE_KEY = 'loremind.readiness.collapsed';
constructor(private readinessService: ReadinessService) {}
ngOnInit(): void {
this.collapsed = localStorage.getItem(ReadinessPanelComponent.STORAGE_KEY) === '1';
if (!this.assessment && this.campaignId) {
this.fetch();
}
}
private fetch(): void {
this.loading = true;
this.readinessService.getReadiness(this.campaignId).subscribe({
next: a => { this.assessment = a; this.loading = false; },
error: () => { this.loading = false; }
});
}
toggle(): void {
this.collapsed = !this.collapsed;
localStorage.setItem(ReadinessPanelComponent.STORAGE_KEY, this.collapsed ? '1' : '0');
}
get blockingCount(): number {
return this.assessment?.counts?.['BLOCKING'] ?? 0;
}
get recommendedCount(): number {
return this.assessment?.counts?.['RECOMMENDED'] ?? 0;
}
get isReady(): boolean {
return !!this.assessment && this.assessment.gaps.length === 0;
}
/** Lien profond « réparateur » : route + query params (outil auto-ouvert sur la cible). */
gapLink(gap: ReadinessGap): string[] {
return gapAction(gap, this.campaignId).link;
}
gapQuery(gap: ReadinessGap): Record<string, string> | null {
return gapAction(gap, this.campaignId).queryParams ?? null;
}
}

View File

@@ -0,0 +1,60 @@
<div class="scene-draft">
<button type="button" class="draft-toggle" (click)="toggle()">
<lucide-icon [img]="open ? ChevronDown : ChevronRight" [size]="15"></lucide-icon>
<lucide-icon [img]="Sparkles" [size]="15" class="spark"></lucide-icon>
<span class="draft-title">{{ 'sceneDraft.button' | translate }}</span>
</button>
@if (open) {
<div class="draft-body">
<p class="draft-hint">{{ 'sceneDraft.hint' | translate }}</p>
<div class="draft-prompt">
<textarea [(ngModel)]="instruction" rows="2"
[placeholder]="'sceneDraft.instructionPlaceholder' | translate"></textarea>
<div class="draft-controls">
<label class="count-label">
{{ 'sceneDraft.countLabel' | translate }}
<select [(ngModel)]="count">
@for (c of countOptions; track c) { <option [ngValue]="c">{{ c }}</option> }
</select>
</label>
<button type="button" class="btn-generate" (click)="generate()" [disabled]="loading">
<lucide-icon [img]="loading ? RefreshCw : Sparkles" [size]="14" [class.spin]="loading"></lucide-icon>
{{ (loading ? 'sceneDraft.generating' : (generated ? 'sceneDraft.regenerate' : 'sceneDraft.generate')) | translate }}
</button>
</div>
</div>
@if (error) {
<p class="draft-error">{{ error }}</p>
}
@if (generated && drafts.length === 0 && !loading) {
<p class="draft-empty">{{ 'sceneDraft.empty' | translate }}</p>
}
@if (drafts.length > 0) {
<ul class="draft-list">
@for (d of drafts; track $index) {
<li class="draft-card" [class.rejected]="!d.accepted">
<label class="draft-check">
<input type="checkbox" [(ngModel)]="d.accepted" />
<span class="draft-name">{{ d.name }}</span>
</label>
@if (d.description) { <p class="draft-desc">{{ d.description }}</p> }
@if (d.playerNarration) { <p class="draft-narration">{{ d.playerNarration }}</p> }
</li>
}
</ul>
<div class="draft-actions">
<button type="button" class="btn-apply" (click)="apply()" [disabled]="acceptedCount === 0 || applying">
<lucide-icon [img]="applying ? RefreshCw : Check" [size]="14" [class.spin]="applying"></lucide-icon>
{{ (applying ? 'sceneDraft.creating' : 'sceneDraft.createSelection') | translate:{ n: acceptedCount } }}
</button>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,145 @@
.scene-draft {
border: 1px solid #24303a;
border-radius: 10px;
background: #101820;
overflow: hidden;
}
.draft-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 0.85rem;
background: transparent;
border: none;
color: #b9e0ff;
cursor: pointer;
text-align: left;
&:hover { background: #14202b; }
.spark { color: #7fc8ff; }
.draft-title { font-weight: 600; font-size: 0.9rem; }
}
.draft-body {
padding: 0.2rem 0.85rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.draft-hint {
margin: 0;
color: #9ca3af;
font-size: 0.8rem;
}
.draft-prompt {
display: flex;
flex-direction: column;
gap: 0.45rem;
textarea { width: 100%; box-sizing: border-box; resize: vertical; min-height: 2.4rem; }
}
.draft-controls {
display: flex;
align-items: center;
gap: 0.6rem;
.count-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: #9ca3af;
font-size: 0.82rem;
select { padding: 0.25rem 0.4rem; }
}
}
.btn-generate,
.btn-apply {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.8rem;
border-radius: 7px;
border: 1px solid #2c5372;
background: #16283a;
color: #b9e0ff;
cursor: pointer;
font-size: 0.82rem;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
&:hover:not(:disabled) { background: #1d3651; color: #fff; }
&:disabled { opacity: 0.5; cursor: default; }
}
.btn-apply {
border-color: #2f6b46;
background: #163326;
color: #a7f3d0;
margin-left: auto;
&:hover:not(:disabled) { background: #1c4531; color: #fff; }
}
.spin { animation: draft-spin 0.9s linear infinite; }
@keyframes draft-spin { to { transform: rotate(360deg); } }
.draft-error { margin: 0; color: #fca5a5; font-size: 0.82rem; }
.draft-empty { margin: 0; color: #9ca3af; font-style: italic; font-size: 0.82rem; }
.draft-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.draft-card {
padding: 0.55rem 0.7rem;
background: #13202b;
border: 1px solid #223341;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.25rem;
&.rejected { opacity: 0.5; }
}
.draft-check {
display: flex;
align-items: center;
gap: 0.45rem;
cursor: pointer;
input { accent-color: #7fc8ff; }
.draft-name { font-weight: 600; color: #e5e7eb; font-size: 0.85rem; }
}
.draft-desc {
margin: 0;
color: #cbd5e1;
font-size: 0.82rem;
line-height: 1.3;
}
.draft-narration {
margin: 0;
color: #94a3b8;
font-size: 0.8rem;
line-height: 1.35;
font-style: italic;
white-space: pre-wrap;
}
.draft-actions {
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,119 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LucideAngularModule, Sparkles, RefreshCw, Check, ChevronDown, ChevronRight } from 'lucide-angular';
import { SceneDraftService } from '../../services/scene-draft.service';
import { SceneDraft } from '../../services/scene-draft.model';
interface ReviewDraft extends SceneDraft {
accepted: boolean;
}
/**
* Panneau « Générer des scènes » (Pilier A — capacité « create »). Le co-MJ ébauche
* plusieurs scènes pour un chapitre ; l'utilisateur révise/coche, puis les scènes retenues
* sont CRÉÉES dans le chapitre (contrairement à l'étoffage de champs, l'application persiste).
*
* <p>Émet {@code created} avec le nombre de scènes créées pour que le parent puisse
* naviguer (ex. vers le graphe du chapitre).</p>
*/
@Component({
selector: 'app-scene-draft-panel',
standalone: true,
imports: [FormsModule, TranslatePipe, LucideAngularModule],
templateUrl: './scene-draft-panel.component.html',
styleUrls: ['./scene-draft-panel.component.scss']
})
export class SceneDraftPanelComponent implements OnChanges {
@Input() chapterId = '';
@Input() campaignId = '';
/** Déplie le panneau à l'arrivée (bouton « Corriger » du guidage sur un chapitre vide). */
@Input() startOpen = false;
/** Nombre de scènes créées, pour que le parent réagisse (navigation/refresh). */
@Output() created = new EventEmitter<number>();
open = false;
instruction = '';
count = 4;
loading = false;
applying = false;
generated = false;
error = '';
drafts: ReviewDraft[] = [];
readonly countOptions = [3, 4, 5, 6];
readonly Sparkles = Sparkles;
readonly RefreshCw = RefreshCw;
readonly Check = Check;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
constructor(private draftService: SceneDraftService, private translate: TranslateService) {}
/** Repart d'un état vierge si le chapitre ciblé change (composant réutilisé). */
ngOnChanges(changes: SimpleChanges): void {
if (changes['startOpen'] && this.startOpen) {
this.open = true;
}
if (changes['chapterId'] && !changes['chapterId'].firstChange) {
this.open = false;
this.instruction = '';
this.loading = false;
this.applying = false;
this.generated = false;
this.error = '';
this.drafts = [];
}
}
toggle(): void {
this.open = !this.open;
}
generate(): void {
if (!this.chapterId || this.loading) return;
this.loading = true;
this.error = '';
this.generated = false;
this.drafts = [];
this.draftService.generate(this.chapterId, this.campaignId, this.instruction.trim(), this.count).subscribe({
next: proposal => {
this.drafts = (proposal.scenes ?? []).map(s => ({ ...s, accepted: true }));
this.generated = true;
this.loading = false;
},
error: () => {
this.error = this.translate.instant('sceneDraft.error');
this.loading = false;
}
});
}
get acceptedCount(): number {
return this.drafts.filter(d => d.accepted).length;
}
apply(): void {
const accepted: SceneDraft[] = this.drafts
.filter(d => d.accepted)
.map(d => ({ name: d.name, description: d.description, playerNarration: d.playerNarration }));
if (accepted.length === 0 || this.applying) return;
this.applying = true;
this.error = '';
this.draftService.apply(this.chapterId, { chapterId: this.chapterId, scenes: accepted }).subscribe({
next: createdScenes => {
this.applying = false;
this.drafts = [];
this.generated = false;
this.open = false;
this.created.emit(createdScenes.length);
},
error: () => {
this.applying = false;
this.error = this.translate.instant('sceneDraft.applyError');
}
});
}
}

View File

@@ -217,7 +217,7 @@ export class SecondarySidebarComponent implements OnDestroy {
*/
runCreateAction(event: Event, action: TreeCreateAction): void {
event.stopPropagation();
this.router.navigate([action.route]);
this.router.navigate([action.route], action.queryParams ? { queryParams: action.queryParams } : {});
}
/** True si le noeud a au moins un vrai enfant (utile pour le chevron). */

View File

@@ -19,6 +19,13 @@
@if (sidebar.iconFor(item); as icon) {
<lucide-icon [img]="icon" [size]="14" class="item-icon"></lucide-icon>
}
<!-- Pastille AVANT le libellé : les actions « + » (hover, à droite) ne la masquent jamais. -->
@if (item.statusDot) {
<span class="status-dot"
[class.blocking]="item.statusDot === 'blocking'"
[class.recommended]="item.statusDot === 'recommended'"
[title]="item.statusDotTitle || (('readiness.dot.' + item.statusDot) | translate)"></span>
}
{{ item.label }}
@if (!item.isAction && item.meta) {
<span class="tree-item-meta">{{ item.meta }}</span>

View File

@@ -47,6 +47,18 @@
color: #6b7280;
flex-shrink: 0;
}
// Pastille de readiness (guidage) : reste collée au libellé ; si un compteur
// `meta` est présent, il file à droite (margin-left:auto) et la pastille reste ici.
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
&.blocking { background: #ef4444; }
&.recommended { background: #fb923c; }
}
}
.item-icon {

View File

@@ -0,0 +1,130 @@
@if (report) {
<div class="prep-panel">
<!-- Dernière séance -->
@if (report.lastSession; as last) {
<p class="prep-last">
<lucide-icon [img]="CalendarCheck" [size]="14"></lucide-icon>
{{ (last.active ? 'sessionPrep.lastSessionActive' : 'sessionPrep.lastSessionEnded') | translate:{ name: last.name } }}
</p>
}
@if (allQuiet) {
<p class="prep-quiet">
<lucide-icon [img]="CheckCircle2" [size]="15"></lucide-icon>
{{ 'sessionPrep.allQuiet' | translate }}
</p>
}
<!-- Où en sont les joueurs -->
@if (report.questsInProgress.length > 0 || report.questsAvailable.length > 0) {
<div class="prep-section">
<h3><lucide-icon [img]="Compass" [size]="14"></lucide-icon> {{ 'sessionPrep.positionTitle' | translate }}</h3>
@if (report.questsInProgress.length > 0) {
<div class="prep-chip-row">
<span class="prep-chip-label">{{ 'sessionPrep.inProgress' | translate }}</span>
@for (q of report.questsInProgress; track q.id) {
<span class="prep-chip-group">
<a class="prep-chip in-progress" [routerLink]="['/campaigns', campaignId, 'quests', q.id]">{{ q.name }}</a>
<button type="button" class="chip-action" (click)="completeQuest(q)"
[title]="'sessionPrep.markCompleted' | translate">
<lucide-icon [img]="Check" [size]="12"></lucide-icon>
</button>
</span>
}
</div>
}
@if (report.questsAvailable.length > 0) {
<div class="prep-chip-row">
<span class="prep-chip-label">{{ 'sessionPrep.available' | translate }}</span>
@for (q of report.questsAvailable; track q.id) {
<span class="prep-chip-group">
<a class="prep-chip available" [routerLink]="['/campaigns', campaignId, 'quests', q.id]">{{ q.name }}</a>
<button type="button" class="chip-action" (click)="startQuest(q)"
[title]="'sessionPrep.markStarted' | translate">
<lucide-icon [img]="Play" [size]="12"></lucide-icon>
</button>
<button type="button" class="chip-action" (click)="completeQuest(q)"
[title]="'sessionPrep.markCompleted' | translate">
<lucide-icon [img]="Check" [size]="12"></lucide-icon>
</button>
</span>
}
</div>
}
</div>
}
<!-- Quêtes terminées : rappel discret + rouvrir en cas d'erreur -->
@if (report.questsCompleted.length > 0) {
<div class="prep-chip-row prep-completed">
<span class="prep-chip-label">{{ 'sessionPrep.completed' | translate }}</span>
@for (q of report.questsCompleted; track q.id) {
<span class="prep-chip-group">
<a class="prep-chip done" [routerLink]="['/campaigns', campaignId, 'quests', q.id]">{{ q.name }}</a>
<button type="button" class="chip-action" (click)="reopenQuest(q)"
[title]="'sessionPrep.reopen' | translate">
<lucide-icon [img]="RotateCcw" [size]="12"></lucide-icon>
</button>
</span>
}
</div>
}
<!-- Contenu probable de la prochaine séance -->
@if (report.hotspots.length > 0) {
<div class="prep-section">
<h3><lucide-icon [img]="MapPin" [size]="14"></lucide-icon> {{ 'sessionPrep.hotspotsTitle' | translate }}</h3>
<div class="prep-chip-row">
@for (node of report.hotspots; track node.nodeType + node.id) {
<a class="prep-chip node" [routerLink]="nodeLink(node)">
{{ node.name }}
<span class="node-kind">{{ ('sessionPrep.nodeKind.' + node.nodeType) | translate }}</span>
</a>
}
</div>
</div>
}
<!-- À combler avant de jouer -->
@if (report.gaps.length > 0) {
<div class="prep-section">
<h3 class="warn"><lucide-icon [img]="AlertTriangle" [size]="14"></lucide-icon> {{ 'sessionPrep.gapsTitle' | translate }}</h3>
<ul class="prep-gap-list">
@for (gap of report.gaps; track gap.ruleId + gap.entityId) {
<li class="prep-gap" [class.blocking]="gap.severity === 'BLOCKING'">
<span class="gap-msg">{{ gap.message }}</span>
<a class="gap-cta" [routerLink]="gapLink(gap)" [queryParams]="gapQuery(gap)">
{{ 'sessionPrep.fix' | translate }}
<lucide-icon [img]="ArrowRight" [size]="12"></lucide-icon>
</a>
</li>
}
</ul>
</div>
}
@if (report.otherGapCount > 0) {
<p class="prep-other">
<a [routerLink]="['/campaigns', campaignId]">{{ 'sessionPrep.otherGaps' | translate:{ n: report.otherGapCount } }}</a>
</p>
}
<!-- Menaces en mouvement -->
@if (report.clocks.length > 0) {
<div class="prep-section">
<h3><lucide-icon [img]="Timer" [size]="14"></lucide-icon> {{ 'sessionPrep.clocksTitle' | translate }}</h3>
<div class="prep-chip-row">
@for (clock of report.clocks; track clock.id) {
<span class="prep-clock" [class.nearly]="isNearlyFull(clock)" [class.full]="isFull(clock)">
{{ clock.name }} — {{ clock.filled }}/{{ clock.segments }}
@if (clock.frontName) { <span class="clock-front">({{ clock.frontName }})</span> }
@if (isFull(clock)) { <strong>{{ 'sessionPrep.clockFull' | translate }}</strong> }
@else if (isNearlyFull(clock)) { <strong>{{ 'sessionPrep.clockNearly' | translate }}</strong> }
</span>
}
</div>
</div>
}
</div>
}

View File

@@ -0,0 +1,180 @@
.prep-panel {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.prep-last {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
color: #9ca3af;
font-size: 0.85rem;
}
.prep-quiet {
display: flex;
align-items: center;
gap: 0.45rem;
margin: 0;
color: #86efac;
font-size: 0.88rem;
}
.prep-section {
display: flex;
flex-direction: column;
gap: 0.45rem;
h3 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
&.warn { color: #fb923c; }
}
}
.prep-chip-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.prep-chip-label {
color: #6b7280;
font-size: 0.78rem;
}
// Groupe chip + actions : le lien ouvre la quête, les petits boutons pilotent
// la progression (▶ commencer, ✓ terminer, ↺ rouvrir).
.prep-chip-group {
display: inline-flex;
align-items: center;
gap: 2px;
.chip-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 50%;
color: #9ca3af;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
&:hover { color: #fff; border-color: #6c63ff; background: #24243a; }
}
}
.prep-completed .prep-chip-label { color: #4b5563; }
.prep-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-size: 0.82rem;
text-decoration: none;
border: 1px solid transparent;
transition: filter 0.15s;
&:hover { filter: brightness(1.25); }
&.in-progress { background: #1e2f45; border-color: #3c6ea5; color: #93c5fd; }
&.available { background: #14311f; border-color: #2f6b46; color: #86efac; }
&.node { background: #1f1d3a; border-color: #3a3658; color: #cfcbe8; }
&.done { background: #16161f; border-color: #2a2a3d; color: #6b7280; text-decoration: line-through; }
.node-kind {
font-size: 0.68rem;
color: #8b8595;
text-transform: uppercase;
}
}
.prep-gap-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.prep-gap {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.7rem;
background: #14141f;
border: 1px solid #2a2a3d;
border-left: 3px solid #fb923c;
border-radius: 8px;
&.blocking { border-left-color: #ef4444; }
.gap-msg {
flex: 1;
min-width: 0;
color: #e5e7eb;
font-size: 0.83rem;
line-height: 1.3;
}
.gap-cta {
display: inline-flex;
align-items: center;
gap: 0.2rem;
flex-shrink: 0;
padding: 0.25rem 0.55rem;
background: #1f1d3a;
border: 1px solid #3a3658;
border-radius: 6px;
color: #c7d2fe;
font-size: 0.75rem;
text-decoration: none;
&:hover { background: #2c2952; color: #fff; }
}
}
.prep-other {
margin: 0;
font-size: 0.78rem;
a { color: #6b7280; text-decoration: none; }
a:hover { color: #9ca3af; text-decoration: underline; }
}
.prep-clock {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: #201b3a;
border: 1px solid #3a3068;
color: #b9a8ff;
font-size: 0.82rem;
.clock-front { color: #8b8595; font-size: 0.75rem; }
&.nearly { border-color: #b48a4f; color: #fcd34d; background: #3a301a; }
&.full { border-color: #b4554f; color: #fca5a5; background: #3a1d1d; }
strong { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; }
}

View File

@@ -0,0 +1,107 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { LucideAngularModule, CalendarCheck, Compass, MapPin, AlertTriangle, Timer, CheckCircle2, ArrowRight, Play, Check, RotateCcw } from 'lucide-angular';
import { SessionPrepService } from '../../services/session-prep.service';
import { QuestService } from '../../services/quest.service';
import { SessionPrepReport, PrepClock, PrepNode, PrepQuest } from '../../services/session-prep.model';
import { ReadinessGap } from '../../services/readiness.model';
import { gapAction } from '../../campaigns/gap-action.helper';
/**
* Panneau « Préparer la prochaine séance » (Phase 3 co-MJ) sur la page d'une Partie.
* Croise la position des joueurs (quêtes actives, dernière séance), le contenu probable,
* les manques du guidage CIBLÉS sur ce contenu, et les horloges en mouvement.
* Read-only : chaque élément deep-linke vers l'éditeur / la fiche concernée.
*/
@Component({
selector: 'app-session-prep-panel',
standalone: true,
imports: [RouterLink, TranslatePipe, LucideAngularModule],
templateUrl: './session-prep-panel.component.html',
styleUrls: ['./session-prep-panel.component.scss']
})
export class SessionPrepPanelComponent implements OnChanges {
@Input() playthroughId = '';
@Input() campaignId = '';
report: SessionPrepReport | null = null;
loading = false;
readonly CalendarCheck = CalendarCheck;
readonly Compass = Compass;
readonly MapPin = MapPin;
readonly AlertTriangle = AlertTriangle;
readonly Timer = Timer;
readonly CheckCircle2 = CheckCircle2;
readonly ArrowRight = ArrowRight;
readonly Play = Play;
readonly Check = Check;
readonly RotateCcw = RotateCcw;
constructor(private prepService: SessionPrepService,
private questService: QuestService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['playthroughId'] && this.playthroughId) {
this.load();
}
}
private load(): void {
this.loading = true;
this.report = null;
this.prepService.getPrep(this.playthroughId).subscribe({
next: r => { this.report = r; this.loading = false; },
error: () => { this.loading = false; }
});
}
/** Rien à signaler : ni quête active, ni manque ciblé, ni horloge en mouvement. */
get allQuiet(): boolean {
const r = this.report;
return !!r && r.questsInProgress.length === 0 && r.questsAvailable.length === 0
&& r.gaps.length === 0 && r.clocks.length === 0;
}
/** Lien profond vers un nœud probable (chapitre ou scène). */
nodeLink(node: PrepNode): string[] {
const base = ['/campaigns', this.campaignId, 'arcs', node.arcId ?? ''];
return node.nodeType === 'SCENE'
? [...base, 'chapters', node.chapterId ?? '', 'scenes', node.id]
: [...base, 'chapters', node.id];
}
/** Lien profond « réparateur » — mapping partagé avec le panneau readiness du hub. */
gapLink(gap: ReadinessGap): string[] {
return gapAction(gap, this.campaignId).link;
}
gapQuery(gap: ReadinessGap): Record<string, string> | null {
return gapAction(gap, this.campaignId).queryParams ?? null;
}
// ─────────────── Progression des quêtes (pilotée depuis les chips) ───────────────
// C'est ICI que le MJ tient à jour « où en sont les joueurs » : Disponible → ▶ En
// cours → ✓ Terminée (elle disparaît des pistes), ↺ pour rouvrir en cas d'erreur.
private setProgression(quest: PrepQuest, status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED'): void {
this.questService.setProgression(this.playthroughId, quest.id, status).subscribe({
next: () => this.load(), // recharge le rapport : listes + manques ciblés à jour
error: () => console.error('Erreur lors de la mise à jour de la progression')
});
}
startQuest(quest: PrepQuest): void { this.setProgression(quest, 'IN_PROGRESS'); }
completeQuest(quest: PrepQuest): void { this.setProgression(quest, 'COMPLETED'); }
reopenQuest(quest: PrepQuest): void { this.setProgression(quest, 'NOT_STARTED'); }
isNearlyFull(clock: PrepClock): boolean {
return clock.segments - clock.filled === 1;
}
isFull(clock: PrepClock): boolean {
return clock.filled >= clock.segments;
}
}