Plusieurs ajouts :
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes. - Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement. - Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes. Passage en 0.9.1-beta
This commit is contained in:
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.9.0-beta",
|
||||
"version": "0.9.1-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.9.0-beta",
|
||||
"version": "0.9.1-beta",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.9.0-beta",
|
||||
"version": "0.9.1-beta",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -16,6 +16,8 @@ export const routes: Routes = [
|
||||
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
|
||||
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
|
||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) },
|
||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
|
||||
|
||||
@@ -17,6 +17,22 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Structure de l'arc</label>
|
||||
<div class="arc-type-choice">
|
||||
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
|
||||
<input type="radio" formControlName="type" value="LINEAR" />
|
||||
<span class="arc-type-title">Linéaire</span>
|
||||
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle classique.</span>
|
||||
</label>
|
||||
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
|
||||
<input type="radio" formControlName="type" value="HUB" />
|
||||
<span class="arc-type-title">Hub</span>
|
||||
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions — type sandbox (ex : Phandalin / Icespire Peak).</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="arc-create-description">Description</label>
|
||||
<textarea
|
||||
|
||||
@@ -3,6 +3,44 @@
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.arc-type-choice {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.arc-type-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface, #fff);
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
|
||||
&:hover { border-color: var(--color-primary, #2c6cd6); }
|
||||
|
||||
&.selected {
|
||||
border-color: var(--color-primary, #2c6cd6);
|
||||
background: rgba(66, 133, 244, 0.06);
|
||||
}
|
||||
|
||||
input[type="radio"] { display: none; }
|
||||
}
|
||||
|
||||
.arc-type-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.arc-type-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
// Override local : titre en violet (pas en blanc comme le .page-header global).
|
||||
.page-header h1 { color: #a5b4fc; }
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: ['']
|
||||
description: [''],
|
||||
// Type structurel : LINEAR (séquentiel) par défaut, HUB (sandbox/quêtes parallèles).
|
||||
type: ['LINEAR', Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
description: this.form.value.description,
|
||||
campaignId: this.campaignId,
|
||||
order: this.existingArcCount + 1,
|
||||
type: this.form.value.type,
|
||||
icon: this.selectedIcon
|
||||
}).subscribe({
|
||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
|
||||
|
||||
@@ -71,6 +71,22 @@
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Structure de l'arc</label>
|
||||
<div class="arc-type-choice">
|
||||
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
|
||||
<input type="radio" formControlName="type" value="LINEAR" />
|
||||
<span class="arc-type-title">Linéaire</span>
|
||||
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle.</span>
|
||||
</label>
|
||||
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
|
||||
<input type="radio" formControlName="type" value="HUB" />
|
||||
<span class="arc-type-title">Hub</span>
|
||||
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions (sandbox).</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||
|
||||
@@ -3,6 +3,44 @@
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.arc-type-choice {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.arc-type-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface, #fff);
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
|
||||
&:hover { border-color: var(--color-primary, #2c6cd6); }
|
||||
|
||||
&.selected {
|
||||
border-color: var(--color-primary, #2c6cd6);
|
||||
background: rgba(66, 133, 244, 0.06);
|
||||
}
|
||||
|
||||
input[type="radio"] { display: none; }
|
||||
}
|
||||
|
||||
.arc-type-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.arc-type-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
// Header local : titre à gauche, actions (Assistant IA) à droite.
|
||||
.page-header {
|
||||
display: flex;
|
||||
|
||||
@@ -85,6 +85,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
type: ['LINEAR', Validators.required],
|
||||
themes: [''],
|
||||
stakes: [''],
|
||||
gmNotes: [''],
|
||||
@@ -137,6 +138,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
this.form.patchValue({
|
||||
name: arc.name,
|
||||
description: arc.description ?? '',
|
||||
type: arc.type ?? 'LINEAR',
|
||||
themes: arc.themes ?? '',
|
||||
stakes: arc.stakes ?? '',
|
||||
gmNotes: arc.gmNotes ?? '',
|
||||
@@ -155,6 +157,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
description: this.form.value.description,
|
||||
campaignId: this.campaignId,
|
||||
order: this.arc.order ?? 1,
|
||||
type: this.form.value.type,
|
||||
themes: this.form.value.themes,
|
||||
stakes: this.form.value.stakes,
|
||||
gmNotes: this.form.value.gmNotes,
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
|
||||
{{ arc.name }}
|
||||
</h1>
|
||||
<p class="view-subtitle">Arc narratif</p>
|
||||
<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()">
|
||||
@@ -31,6 +33,40 @@
|
||||
<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 un chapitre pour ajouter une première quête.
|
||||
</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>
|
||||
|
||||
@@ -1 +1,112 @@
|
||||
// Styles partagés via styles/_view.scss
|
||||
|
||||
// === Vue Hub ===
|
||||
.hub-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
.view-section-title { margin: 0; }
|
||||
}
|
||||
|
||||
.hub-quest-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.hub-quest-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface, #fff);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
|
||||
min-height: 110px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary, #2c6cd6);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
&.locked {
|
||||
opacity: 0.7;
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
border-style: dashed;
|
||||
|
||||
&:hover {
|
||||
// Pas d'illusion d'interactivité pour les quêtes verrouillées
|
||||
transform: none;
|
||||
border-color: var(--color-border, #e2e2e2);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-quest-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.hub-quest-card-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-primary, #2c6cd6);
|
||||
}
|
||||
|
||||
.hub-quest-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hub-quest-card-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hub-quest-card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.hub-quest-card-locked-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.4rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px dashed rgba(128, 128, 128, 0.25);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
|
||||
> lucide-icon,
|
||||
> span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.hub-quest-card-prereq-list {
|
||||
list-style: disc;
|
||||
padding-left: 1rem;
|
||||
margin: 0.15rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Arc } from '../../../services/campaign.model';
|
||||
import { Arc, Chapter, Prerequisite } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
@@ -32,12 +32,22 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly AlertCircle = AlertCircle;
|
||||
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
arc: Arc | null = null;
|
||||
|
||||
/** Chapitres de l'arc courant — exploités pour le rendu HUB (grille de quêtes). */
|
||||
hubQuests: Chapter[] = [];
|
||||
|
||||
/**
|
||||
* Indexe les chapitres de toute la campagne par id pour résoudre les libellés
|
||||
* des prérequis QUEST_COMPLETED quand on les affiche dans les tooltips de verrouillage.
|
||||
*/
|
||||
private allChaptersById: Record<string, Chapter> = {};
|
||||
|
||||
/** ID du Lore associé à la campagne (null si pas d'univers lié). */
|
||||
loreId: string | null = null;
|
||||
/** Pages du Lore — pour résoudre relatedPageIds en titres. */
|
||||
@@ -85,10 +95,42 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
this.availablePages = pages;
|
||||
this.pageTitleService.set(arc.name);
|
||||
|
||||
// Quêtes du Hub : chapitres de l'arc courant, triés par order puis par nom.
|
||||
this.hubQuests = [...(treeData.chaptersByArc[this.arcId] ?? [])].sort((a, b) => {
|
||||
const oa = a.order ?? 0;
|
||||
const ob = b.order ?? 0;
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
// Index global pour résoudre les noms de quêtes référencées par les prérequis.
|
||||
this.allChaptersById = {};
|
||||
Object.values(treeData.chaptersByArc).forEach(list =>
|
||||
list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; })
|
||||
);
|
||||
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
/** Construit un libellé lisible pour un prérequis (tooltip de verrouillage). */
|
||||
describePrerequisite(p: Prerequisite): string {
|
||||
switch (p.kind) {
|
||||
case 'QUEST_COMPLETED':
|
||||
return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`;
|
||||
case 'SESSION_REACHED':
|
||||
return `Session ${p.minSessionNumber} atteinte`;
|
||||
case 'FLAG_SET':
|
||||
return `Fait : ${p.flagName}`;
|
||||
}
|
||||
}
|
||||
|
||||
openQuest(q: Chapter): void {
|
||||
if (!q.id) return;
|
||||
this.router.navigate([
|
||||
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', q.id
|
||||
]);
|
||||
}
|
||||
|
||||
titleOfRelated(pageId: string): string {
|
||||
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
||||
}
|
||||
|
||||
@@ -30,9 +30,12 @@ export function loadCampaignTreeData(
|
||||
characterService: CharacterService,
|
||||
npcService: NpcService
|
||||
): Observable<CampaignTreeData> {
|
||||
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
|
||||
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent
|
||||
// doivent passer par PlaythroughService et appeler characterService.getByPlaythrough).
|
||||
return forkJoin({
|
||||
arcs: service.getArcs(campaignId),
|
||||
characters: characterService.getByCampaign(campaignId),
|
||||
characters: of([] as Character[]),
|
||||
npcs: npcService.getByCampaign(campaignId)
|
||||
}).pipe(
|
||||
switchMap(({ arcs, characters, npcs }) => {
|
||||
|
||||
@@ -196,60 +196,38 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ Sessions de jeu ============ -->
|
||||
<section class="detail-section sessions-section" *ngIf="!editing">
|
||||
<!-- ============ Parties (Playthroughs) ============ -->
|
||||
<section class="detail-section playthroughs-section" *ngIf="!editing">
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
|
||||
Sessions de jeu
|
||||
Mes parties
|
||||
</h2>
|
||||
|
||||
<!-- Cas 1 : aucune session active dans l'app → on peut lancer -->
|
||||
<button *ngIf="!activeSessionGlobal"
|
||||
class="btn-add"
|
||||
[disabled]="startingSession"
|
||||
(click)="startSession()">
|
||||
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
|
||||
Lancer une nouvelle session
|
||||
</button>
|
||||
|
||||
<!-- Cas 2 : session active sur cette campagne → reprendre -->
|
||||
<button *ngIf="activeSessionOnCurrentCampaign"
|
||||
class="btn-add"
|
||||
(click)="openSession(activeSessionOnCurrentCampaign)">
|
||||
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
|
||||
Reprendre la session en cours
|
||||
</button>
|
||||
|
||||
<!-- Cas 3 : session active sur une autre campagne → bloqué -->
|
||||
<button *ngIf="isLaunchBlockedByOtherCampaign"
|
||||
class="btn-add"
|
||||
disabled
|
||||
title="Une session est déjà en cours sur une autre campagne. Termine-la d'abord.">
|
||||
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
|
||||
Session en cours ailleurs
|
||||
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouvelle partie
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sessions-grid" *ngIf="sessions.length > 0">
|
||||
<div class="session-card"
|
||||
*ngFor="let session of sessions"
|
||||
[class.session-card--active]="session.active"
|
||||
(click)="openSession(session)">
|
||||
<lucide-icon [img]="Dices" [size]="20" class="session-icon"></lucide-icon>
|
||||
<div class="session-info">
|
||||
<span class="session-name">{{ session.name }}</span>
|
||||
<span class="session-meta">
|
||||
<span class="session-status" *ngIf="session.active">● En cours</span>
|
||||
<span *ngIf="!session.active">Terminée le {{ session.endedAt | date:'dd/MM/yyyy' }}</span>
|
||||
</span>
|
||||
<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="sessions.length === 0">
|
||||
<p>Aucune session de jeu pour le moment.</p>
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,58 @@
|
||||
.section-hint {
|
||||
margin: -0.25rem 0 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
max-width: 70ch;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// ==== Parties (Playthroughs) ====
|
||||
.playthroughs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.playthrough-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface, #fff);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary, #2c6cd6);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.playthrough-icon { color: var(--color-primary, #2c6cd6); flex: 0 0 auto; }
|
||||
|
||||
.playthrough-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playthrough-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.playthrough-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.campaign-detail {
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
|
||||
@@ -13,6 +13,8 @@ import { GameSystem } from '../../../services/game-system.model';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { SessionService } from '../../../services/session.service';
|
||||
import { PlaythroughService } from '../../../services/playthrough.service';
|
||||
import { Playthrough } from '../../../services/campaign.model';
|
||||
import { Session } from '../../../services/session.model';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { Npc } from '../../../services/npc.model';
|
||||
@@ -69,6 +71,11 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
/** Indicateur de lancement en cours pour éviter les double-clics. */
|
||||
startingSession = false;
|
||||
|
||||
/** Parties (Playthroughs) de cette campagne. */
|
||||
playthroughs: Playthrough[] = [];
|
||||
/** Partie par défaut (1re) — fallback pour les actions session/PJ tant que l'UI Playthrough n'est pas finie. */
|
||||
defaultPlaythroughId: string | null = null;
|
||||
|
||||
/** Mode édition inline. */
|
||||
editing = false;
|
||||
editName = '';
|
||||
@@ -92,6 +99,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private sessionService: SessionService,
|
||||
private playthroughService: PlaythroughService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
@@ -109,11 +117,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
|
||||
)
|
||||
),
|
||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||
}))
|
||||
).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.playthroughs = playthroughs;
|
||||
this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
@@ -145,10 +156,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
|
||||
)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
),
|
||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.playthroughs = playthroughs;
|
||||
this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
@@ -186,9 +200,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
).subscribe(gs => this.linkedGameSystem = gs);
|
||||
}
|
||||
|
||||
/** Charge les fiches de personnages (PJ) de la campagne. */
|
||||
private loadCharacters(campaignId: string): void {
|
||||
this.characterService.getByCampaign(campaignId).pipe(
|
||||
/** Charge les PJ de la Partie par défaut (refonte Playthrough). */
|
||||
private loadCharacters(_campaignId: string): void {
|
||||
if (!this.defaultPlaythroughId) {
|
||||
this.characters = [];
|
||||
return;
|
||||
}
|
||||
this.characterService.getByPlaythrough(this.defaultPlaythroughId).pipe(
|
||||
catchError(() => of([] as Character[]))
|
||||
).subscribe(list => this.characters = list);
|
||||
}
|
||||
@@ -200,55 +218,33 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
).subscribe(list => this.npcs = list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les sessions de cette campagne ET la session active globale.
|
||||
* La session globale conditionne si le bouton "Lancer" est activable
|
||||
* (règle métier : une seule session active simultanément dans l'app).
|
||||
*/
|
||||
private loadSessions(campaignId: string): void {
|
||||
this.sessionService.getSessions(campaignId).pipe(
|
||||
catchError(() => of([] as Session[]))
|
||||
).subscribe(list => this.sessions = list);
|
||||
// Sessions retirées de cette vue (vivent dans Playthrough Detail).
|
||||
private loadSessions(_campaignId: string): void { this.sessions = []; }
|
||||
|
||||
this.sessionService.getActiveSession().pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe(active => this.activeSessionGlobal = active);
|
||||
// ─────────────── Playthroughs (Parties) ───────────────
|
||||
|
||||
openPlaythrough(p: Playthrough): void {
|
||||
this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', p.id]);
|
||||
}
|
||||
|
||||
/** True si une session est active mais sur une AUTRE campagne (lancement bloqué). */
|
||||
get isLaunchBlockedByOtherCampaign(): boolean {
|
||||
return !!this.activeSessionGlobal
|
||||
&& !!this.campaign
|
||||
&& this.activeSessionGlobal.campaignId !== this.campaign.id;
|
||||
}
|
||||
|
||||
/** Session active sur la campagne courante (le MJ joue déjà ici). */
|
||||
get activeSessionOnCurrentCampaign(): Session | null {
|
||||
if (!this.activeSessionGlobal || !this.campaign) return null;
|
||||
return this.activeSessionGlobal.campaignId === this.campaign.id
|
||||
? this.activeSessionGlobal
|
||||
: null;
|
||||
}
|
||||
|
||||
startSession(): void {
|
||||
if (!this.campaign || this.startingSession || this.isLaunchBlockedByOtherCampaign) return;
|
||||
this.startingSession = true;
|
||||
this.sessionService.startSession(this.campaign.id!).subscribe({
|
||||
next: session => {
|
||||
this.startingSession = false;
|
||||
this.router.navigate(['/sessions', session.id]);
|
||||
/** Crée une nouvelle Partie avec un nom par défaut, puis y navigue. */
|
||||
newPlaythroughInFlight = false;
|
||||
createPlaythrough(): void {
|
||||
if (!this.campaign || this.newPlaythroughInFlight) return;
|
||||
this.newPlaythroughInFlight = true;
|
||||
const defaultName = `Nouvelle partie ${this.playthroughs.length + 1}`;
|
||||
this.playthroughService.create({
|
||||
campaignId: this.campaign.id!,
|
||||
name: defaultName
|
||||
}).subscribe({
|
||||
next: created => {
|
||||
this.newPlaythroughInFlight = false;
|
||||
this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', created.id]);
|
||||
},
|
||||
error: () => {
|
||||
this.startingSession = false;
|
||||
console.error('Erreur lors du lancement de la session');
|
||||
}
|
||||
error: () => { this.newPlaythroughInFlight = false; }
|
||||
});
|
||||
}
|
||||
|
||||
openSession(session: Session): void {
|
||||
this.router.navigate(['/sessions', session.id]);
|
||||
}
|
||||
|
||||
createCharacter(): void {
|
||||
if (!this.campaign) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
|
||||
@@ -444,7 +440,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`);
|
||||
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
||||
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
|
||||
if (impact.playthroughs > 0) parts.push(`${impact.playthroughs} partie${impact.playthroughs > 1 ? 's' : ''} (avec ses PJ, sessions, faits)`);
|
||||
|
||||
const details: string[] = [];
|
||||
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);
|
||||
|
||||
@@ -26,6 +26,22 @@
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. -->
|
||||
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'">
|
||||
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2>
|
||||
<small class="field-hint">
|
||||
Définition du scénario : toutes les conditions doivent être remplies (ET) pour
|
||||
que la quête soit débloquée dans une Partie. La progression en cours et l'état
|
||||
des faits se gèrent dans l'écran de la Partie, pas ici.
|
||||
</small>
|
||||
<app-prerequisite-editor
|
||||
[prerequisites]="prerequisites"
|
||||
[availableQuests]="availableQuests"
|
||||
[availableFlags]="availableFlagNames"
|
||||
(prerequisitesChange)="onPrerequisitesChange($event)">
|
||||
</app-prerequisite-editor>
|
||||
</div>
|
||||
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>Illustrations</label>
|
||||
|
||||
@@ -3,6 +3,57 @@
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
// === Section Hub : prérequis + progression ===
|
||||
.hub-section {
|
||||
border: 1px solid rgba(66, 133, 244, 0.25);
|
||||
background: rgba(66, 133, 244, 0.03);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hub-section-title {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary, #2c6cd6);
|
||||
}
|
||||
|
||||
.progression-choice {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progression-option {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface, #fff);
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
|
||||
&:hover { border-color: var(--color-primary, #2c6cd6); }
|
||||
|
||||
&.selected {
|
||||
border-color: var(--color-primary, #2c6cd6);
|
||||
background: rgba(66, 133, 244, 0.08);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type="radio"] { display: none; }
|
||||
}
|
||||
|
||||
.hub-flags-panel {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px dashed var(--color-border, #d8d8d8);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
// Header local : titre à gauche, actions (Assistant IA) à droite.
|
||||
.page-header {
|
||||
display: flex;
|
||||
|
||||
@@ -11,27 +11,38 @@ import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Chapter } from '../../../services/campaign.model';
|
||||
import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { PrerequisiteEditorComponent } from '../../../shared/prerequisite-editor/prerequisite-editor.component';
|
||||
import { CampaignFlagService } from '../../../services/campaign-flag.service';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Chapitre.
|
||||
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId
|
||||
* Écran d'édition d'un Chapitre. Donnée de SCÉNARIO uniquement.
|
||||
*
|
||||
* Inclut le picker de pages Lore (B2 cross-context) si la campagne parente
|
||||
* est associée à un Lore.
|
||||
* Depuis Playthrough : la progression et le toggle des flags se font dans le
|
||||
* contexte d'une Partie (voir Playthrough Detail). On garde ici uniquement les
|
||||
* prérequis (conditions de déblocage), qui font partie du scénario.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-chapter-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
LucideAngularModule,
|
||||
LoreLinkPickerComponent,
|
||||
AiChatDrawerComponent,
|
||||
ImageGalleryComponent,
|
||||
IconPickerComponent,
|
||||
PrerequisiteEditorComponent
|
||||
],
|
||||
templateUrl: './chapter-edit.component.html',
|
||||
styleUrls: ['./chapter-edit.component.scss']
|
||||
})
|
||||
@@ -41,7 +52,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
selectedIcon: string | null = null;
|
||||
|
||||
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
||||
chatOpen = false;
|
||||
readonly chatQuickSuggestions = [
|
||||
'Propose des objectifs clairs pour les joueurs dans ce chapitre',
|
||||
@@ -63,6 +73,18 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
illustrationImageIds: string[] = [];
|
||||
mapImageIds: string[] = [];
|
||||
|
||||
/** Prérequis (donnée de scénario). */
|
||||
prerequisites: Prerequisite[] = [];
|
||||
|
||||
/** Quêtes candidates pour QUEST_COMPLETED (chapitres de la campagne, sauf celui-ci). */
|
||||
availableQuests: Chapter[] = [];
|
||||
|
||||
/** Faits déclarés au niveau Campagne (autocomplete FLAG_SET). */
|
||||
availableFlagNames: string[] = [];
|
||||
|
||||
/** L'arc parent — pour conditionner la section Hub (prérequis pertinents). */
|
||||
parentArc: Arc | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
@@ -73,7 +95,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private campaignFlagService: CampaignFlagService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -85,9 +108,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
|
||||
// réutilise le composant quand on navigue entre chapitres frères via
|
||||
// l'arbre (même route pattern), et ngOnInit ne se relance pas.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
@@ -124,6 +144,21 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
this.selectedIcon = chapter.icon ?? null;
|
||||
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
|
||||
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
|
||||
|
||||
this.prerequisites = [...(chapter.prerequisites ?? [])];
|
||||
|
||||
const allChapters: Chapter[] = [];
|
||||
Object.values(treeData.chaptersByArc).forEach(list => allChapters.push(...list));
|
||||
this.availableQuests = allChapters.filter(c => c.id !== this.chapterId);
|
||||
|
||||
this.parentArc = treeData.arcs.find(a => a.id === this.arcId) ?? null;
|
||||
|
||||
// Autocomplete des FLAG_SET : les noms de faits déjà référencés ailleurs
|
||||
// dans la campagne (déduit des autres quêtes).
|
||||
this.campaignFlagService.listReferenced(this.campaignId).subscribe({
|
||||
next: names => { this.availableFlagNames = names; }
|
||||
});
|
||||
|
||||
this.form.patchValue({
|
||||
name: chapter.name,
|
||||
description: chapter.description ?? '',
|
||||
@@ -136,6 +171,10 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
onPrerequisitesChange(next: Prerequisite[]): void {
|
||||
this.prerequisites = next;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || !this.chapter) return;
|
||||
this.campaignService.updateChapter(this.chapterId, {
|
||||
@@ -146,6 +185,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
gmNotes: this.form.value.gmNotes,
|
||||
playerObjectives: this.form.value.playerObjectives,
|
||||
narrativeStakes: this.form.value.narrativeStakes,
|
||||
prerequisites: this.prerequisites,
|
||||
relatedPageIds: this.relatedPageIds,
|
||||
illustrationImageIds: this.illustrationImageIds,
|
||||
mapImageIds: this.mapImageIds,
|
||||
@@ -176,10 +216,5 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
ngOnDestroy(): void {}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
|
||||
{{ chapter.name }}
|
||||
</h1>
|
||||
<p class="view-subtitle">Chapitre</p>
|
||||
<p class="view-subtitle">{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||
@@ -25,6 +25,31 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. -->
|
||||
<section class="view-section" *ngIf="parentArc?.type === 'HUB'">
|
||||
<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, la quête 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>
|
||||
|
||||
@@ -1 +1,35 @@
|
||||
// Styles partagés via styles/_view.scss
|
||||
|
||||
.quest-status-bar {
|
||||
border: 1px solid rgba(66, 133, 244, 0.25);
|
||||
background: rgba(66, 133, 244, 0.04);
|
||||
}
|
||||
|
||||
.quest-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quest-status-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quest-prereq-summary {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed rgba(66, 133, 244, 0.2);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #555);
|
||||
|
||||
ul {
|
||||
margin: 0.3rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
|
||||
li { margin-bottom: 0.15rem; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Chapter } from '../../../services/campaign.model';
|
||||
import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
@@ -38,9 +38,11 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
arcId = '';
|
||||
chapterId = '';
|
||||
chapter: Chapter | null = null;
|
||||
parentArc: Arc | null = null;
|
||||
|
||||
loreId: string | null = null;
|
||||
availablePages: Page[] = [];
|
||||
private allChaptersById: Record<string, Chapter> = {};
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -88,10 +90,28 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
this.availablePages = pages;
|
||||
this.pageTitleService.set(chapter.name);
|
||||
|
||||
// Arc parent (pour conditionner les sections Hub) + index des quêtes par id.
|
||||
this.parentArc = treeData.arcs.find(a => a.id === this.arcId) ?? null;
|
||||
this.allChaptersById = {};
|
||||
Object.values(treeData.chaptersByArc).forEach(list =>
|
||||
list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; })
|
||||
);
|
||||
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
describePrerequisite(p: Prerequisite): string {
|
||||
switch (p.kind) {
|
||||
case 'QUEST_COMPLETED':
|
||||
return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`;
|
||||
case 'SESSION_REACHED':
|
||||
return `Session ${p.minSessionNumber} atteinte`;
|
||||
case 'FLAG_SET':
|
||||
return `Fait : ${p.flagName}`;
|
||||
}
|
||||
}
|
||||
|
||||
titleOfRelated(pageId: string): string {
|
||||
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { PlaythroughService } from '../../../services/playthrough.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { TemplateField } from '../../../services/template.model';
|
||||
@@ -48,6 +49,8 @@ export class CharacterEditComponent implements OnInit {
|
||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||
|
||||
campaignId: string | null = null;
|
||||
/** Partie cible — déduite du 1er Playthrough de la campagne. */
|
||||
playthroughId: string | null = null;
|
||||
characterId: string | null = null;
|
||||
|
||||
name = '';
|
||||
@@ -64,6 +67,7 @@ export class CharacterEditComponent implements OnInit {
|
||||
private router: Router,
|
||||
private service: CharacterService,
|
||||
private campaignService: CampaignService,
|
||||
private playthroughService: PlaythroughService,
|
||||
private gameSystemService: GameSystemService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
@@ -77,6 +81,10 @@ export class CharacterEditComponent implements OnInit {
|
||||
if (this.campaignId) {
|
||||
this.loadTemplateForCampaign(this.campaignId);
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
// Résolution Partie par défaut (1er Playthrough) — nécessaire pour create/update.
|
||||
this.playthroughService.listByCampaign(this.campaignId).subscribe({
|
||||
next: list => { this.playthroughId = list.length > 0 ? list[0].id! : null; }
|
||||
});
|
||||
}
|
||||
|
||||
if (this.characterId) {
|
||||
@@ -113,7 +121,7 @@ export class CharacterEditComponent implements OnInit {
|
||||
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
if (!this.name.trim() || !this.campaignId || !this.playthroughId) return;
|
||||
const payload = {
|
||||
name: this.name.trim(),
|
||||
portraitImageId: this.portraitImageId,
|
||||
@@ -121,7 +129,7 @@ export class CharacterEditComponent implements OnInit {
|
||||
values: this.values,
|
||||
imageValues: this.imageValues,
|
||||
keyValueValues: this.keyValueValues,
|
||||
campaignId: this.campaignId
|
||||
playthroughId: this.playthroughId
|
||||
};
|
||||
const isCreation = !this.characterId;
|
||||
const req = this.characterId
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<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
|
||||
</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">
|
||||
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2>
|
||||
<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, '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>
|
||||
@@ -0,0 +1,109 @@
|
||||
.playthrough-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h1 { margin: 0 0 0.2rem; font-size: 1.6rem; }
|
||||
}
|
||||
|
||||
.header-info { flex: 1; }
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.play-action {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary.big,
|
||||
.btn-secondary.big {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.character-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
&:hover { border-color: var(--color-primary, #2c6cd6); }
|
||||
}
|
||||
}
|
||||
|
||||
.session-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease;
|
||||
|
||||
&:hover { border-color: var(--color-primary, #2c6cd6); }
|
||||
}
|
||||
}
|
||||
|
||||
.session-status {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
color: var(--color-text-muted, #666);
|
||||
|
||||
&.active {
|
||||
background: rgba(52, 168, 83, 0.15);
|
||||
color: #2f7a47;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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';
|
||||
import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil } from 'lucide-angular';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PlaythroughService } from '../../../services/playthrough.service';
|
||||
import { SessionService } from '../../../services/session.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Playthrough } from '../../../services/campaign.model';
|
||||
import { Session } from '../../../services/session.model';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Vue détail d'une Partie (Playthrough).
|
||||
* Minimal MVP — affiche les infos, le lien vers les faits, la liste des sessions
|
||||
* et les PJ de cette Partie.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-playthrough-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, LucideAngularModule],
|
||||
templateUrl: './playthrough-detail.component.html',
|
||||
styleUrls: ['./playthrough-detail.component.scss']
|
||||
})
|
||||
export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Play = Play;
|
||||
readonly Flag = Flag;
|
||||
readonly Users = Users;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Pencil = Pencil;
|
||||
|
||||
campaignId = '';
|
||||
playthroughId = '';
|
||||
|
||||
playthrough: Playthrough | null = null;
|
||||
sessions: Session[] = [];
|
||||
characters: Character[] = [];
|
||||
/** Session active de CETTE Partie (null si aucune). Plus de check global. */
|
||||
activeOnThis: Session | null = null;
|
||||
|
||||
startingSession = false;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private playthroughService: PlaythroughService,
|
||||
private sessionService: SessionService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const cid = pm.get('campaignId')!;
|
||||
const pid = pm.get('playthroughId')!;
|
||||
if (cid !== this.campaignId || pid !== this.playthroughId) {
|
||||
this.campaignId = cid;
|
||||
this.playthroughId = pid;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService),
|
||||
playthrough: this.playthroughService.getById(this.playthroughId),
|
||||
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
|
||||
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),
|
||||
activeOnThis: this.sessionService.getActiveByPlaythrough(this.playthroughId).pipe(catchError(() => of(null)))
|
||||
}).subscribe(({ campaign, allCampaigns, treeData, playthrough, sessions, characters, activeOnThis }) => {
|
||||
this.playthrough = playthrough;
|
||||
this.sessions = sessions;
|
||||
this.characters = characters;
|
||||
this.activeOnThis = activeOnThis;
|
||||
this.pageTitleService.set(`${playthrough.name} — ${campaign.name}`);
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
startSession(): void {
|
||||
if (this.startingSession || this.activeOnThis) return;
|
||||
this.startingSession = true;
|
||||
this.sessionService.startSession(this.playthroughId).subscribe({
|
||||
next: s => { this.startingSession = false; this.router.navigate(['/sessions', s.id]); },
|
||||
error: () => { this.startingSession = false; }
|
||||
});
|
||||
}
|
||||
|
||||
openSession(s: Session): void {
|
||||
this.router.navigate(['/sessions', s.id]);
|
||||
}
|
||||
|
||||
openFlags(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'flags']);
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!this.playthrough) return;
|
||||
this.playthroughService.deletionImpact(this.playthroughId).subscribe({
|
||||
next: impact => {
|
||||
const parts: string[] = [];
|
||||
if (impact.sessions > 0) parts.push(`${impact.sessions} session(s)`);
|
||||
if (impact.characters > 0) parts.push(`${impact.characters} PJ`);
|
||||
if (impact.flags > 0) parts.push(`${impact.flags} fait(s)`);
|
||||
if (impact.progressions > 0) parts.push(`${impact.progressions} progression(s) de quête`);
|
||||
const details: string[] = [];
|
||||
if (parts.length) details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
details.push('Cette action est irréversible.');
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la Partie',
|
||||
message: `Supprimer "${this.playthrough?.name}" ?`,
|
||||
details,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.playthroughService.delete(this.playthroughId).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId])
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
@@ -0,0 +1,24 @@
|
||||
.flags-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
max-width: 60ch;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PlaythroughService } from '../../../services/playthrough.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Playthrough } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-flags-manager/playthrough-flags-manager.component';
|
||||
|
||||
/**
|
||||
* Page dédiée aux "Faits" d'une Partie.
|
||||
* Route : /campaigns/:campaignId/playthroughs/:playthroughId/flags
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-playthrough-flags-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent],
|
||||
templateUrl: './playthrough-flags-page.component.html',
|
||||
styleUrls: ['./playthrough-flags-page.component.scss']
|
||||
})
|
||||
export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
|
||||
campaignId = '';
|
||||
playthroughId = '';
|
||||
playthrough: Playthrough | null = null;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private playthroughService: PlaythroughService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const cid = pm.get('campaignId')!;
|
||||
const pid = pm.get('playthroughId')!;
|
||||
if (cid !== this.campaignId || pid !== this.playthroughId) {
|
||||
this.campaignId = cid;
|
||||
this.playthroughId = pid;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService),
|
||||
playthrough: this.playthroughService.getById(this.playthroughId)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
|
||||
this.playthrough = playthrough;
|
||||
this.pageTitleService.set(`${playthrough.name} — Faits`);
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
}
|
||||
@@ -231,6 +231,18 @@
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Lieu explorable : pièces / donjon -->
|
||||
<app-expandable-section title="Lieu explorable (donjon, crypte…)" icon="🏰" [initiallyOpen]="rooms.length > 0">
|
||||
<small class="field-hint">
|
||||
Si cette scène représente un lieu à parcourir pièce par pièce, ajoutez-les ici.
|
||||
La scène bascule alors en mode « donjon » côté affichage.
|
||||
</small>
|
||||
<app-rooms-editor
|
||||
[rooms]="rooms"
|
||||
(roomsChange)="onRoomsChange($event)">
|
||||
</app-rooms-editor>
|
||||
</app-expandable-section>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Scene, SceneBranch } from '../../../services/campaign.model';
|
||||
import { Scene, SceneBranch, Room } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
||||
@@ -19,6 +19,7 @@ import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-l
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { RoomsEditorComponent } from '../../../shared/rooms-editor/rooms-editor.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@@ -29,7 +30,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
@Component({
|
||||
selector: 'app-scene-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent],
|
||||
templateUrl: './scene-edit.component.html',
|
||||
styleUrls: ['./scene-edit.component.scss']
|
||||
})
|
||||
@@ -67,6 +68,11 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
/** Branches narratives (état local mutable, persisté au submit). */
|
||||
branches: SceneBranch[] = [];
|
||||
|
||||
/** Pièces du lieu explorable (état local, persisté au submit). */
|
||||
rooms: Room[] = [];
|
||||
|
||||
onRoomsChange(next: Room[]): void { this.rooms = next; }
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
@@ -144,6 +150,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
this.mapImageIds = [...(scene.mapImageIds ?? [])];
|
||||
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
|
||||
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
|
||||
this.rooms = (scene.rooms ?? []).map(r => ({ ...r, branches: [...(r.branches ?? [])] }));
|
||||
this.form.patchValue({
|
||||
name: scene.name,
|
||||
description: scene.description ?? '',
|
||||
@@ -180,6 +187,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
illustrationImageIds: this.illustrationImageIds,
|
||||
mapImageIds: this.mapImageIds,
|
||||
branches: this.branches,
|
||||
rooms: this.rooms,
|
||||
icon: this.selectedIcon
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
|
||||
|
||||
@@ -100,4 +100,48 @@
|
||||
</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>
|
||||
|
||||
@@ -1 +1,84 @@
|
||||
// Styles partagés via styles/_view.scss
|
||||
|
||||
.rooms-readonly {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.room-readonly {
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.room-readonly-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.room-readonly-index {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
}
|
||||
|
||||
.room-readonly-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.room-readonly-floor {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(66, 133, 244, 0.1);
|
||||
color: #2c6cd6;
|
||||
}
|
||||
|
||||
.room-readonly-desc {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text, #333);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.room-readonly-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
> div {
|
||||
strong { display: block; font-size: 0.82rem; margin-bottom: 0.2rem; color: var(--color-text-muted, #555); }
|
||||
p { margin: 0; font-size: 0.88rem; white-space: pre-wrap; }
|
||||
}
|
||||
}
|
||||
|
||||
.room-readonly-private {
|
||||
background: rgba(192, 57, 43, 0.05);
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid rgba(192, 57, 43, 0.4);
|
||||
}
|
||||
|
||||
.room-readonly-branches {
|
||||
margin-top: 0.45rem;
|
||||
padding-top: 0.45rem;
|
||||
border-top: 1px dashed var(--color-border, #ececec);
|
||||
font-size: 0.85rem;
|
||||
|
||||
strong { display: block; font-size: 0.82rem; margin-bottom: 0.15rem; color: var(--color-text-muted, #555); }
|
||||
ul { margin: 0; padding-left: 1.1rem; }
|
||||
li { margin-bottom: 0.1rem; }
|
||||
}
|
||||
|
||||
.branch-cond {
|
||||
color: var(--color-text-muted, #777);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,11 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
||||
}
|
||||
|
||||
/** Résout le nom d'une pièce cible (pour afficher les sorties inter-pièces). */
|
||||
roomNameById(scene: Scene | null, roomId: string): string {
|
||||
return scene?.rooms?.find(r => r.id === roomId)?.name ?? '(pièce supprimée)';
|
||||
}
|
||||
|
||||
editMode(): void {
|
||||
this.router.navigate([
|
||||
'/campaigns', this.campaignId, 'arcs', this.arcId,
|
||||
|
||||
19
web/src/app/services/campaign-flag.service.ts
Normal file
19
web/src/app/services/campaign-flag.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Liste les noms de faits référencés par les conditions des quêtes d'une Campagne.
|
||||
* Modèle "déclaration implicite" : un fait existe dès qu'au moins une quête le
|
||||
* référence dans ses prérequis FLAG_SET.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CampaignFlagService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/** Noms de faits dédupliqués et triés. */
|
||||
listReferenced(campaignId: string): Observable<string[]> {
|
||||
return this.http.get<string[]>(`/api/campaigns/${campaignId}/flags`);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,27 @@ export interface CampaignCreate {
|
||||
gameSystemId?: string | null;
|
||||
}
|
||||
|
||||
/** Type structurel d'un Arc (miroir de l'enum Java ArcType). */
|
||||
export type ArcType = 'LINEAR' | 'HUB';
|
||||
|
||||
/** Statut de progression piloté manuellement par le MJ (persisté). */
|
||||
export type ProgressionStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED';
|
||||
|
||||
/**
|
||||
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
|
||||
* Calculé côté backend — ne jamais le re-dériver côté front.
|
||||
*/
|
||||
export type QuestStatus = 'LOCKED' | 'AVAILABLE' | 'IN_PROGRESS' | 'COMPLETED';
|
||||
|
||||
/**
|
||||
* Condition de déblocage d'une quête. Union TS discriminée sur `kind` —
|
||||
* miroir du sealed type Java Prerequisite. Le narrowing TS marche sur le `kind`.
|
||||
*/
|
||||
export type Prerequisite =
|
||||
| { kind: 'QUEST_COMPLETED'; questId: string }
|
||||
| { kind: 'SESSION_REACHED'; minSessionNumber: number }
|
||||
| { kind: 'FLAG_SET'; flagName: string };
|
||||
|
||||
export interface Arc {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -29,6 +50,9 @@ export interface Arc {
|
||||
order?: number;
|
||||
chapterCount?: number;
|
||||
|
||||
/** Type structurel (défaut LINEAR côté backend si omis). */
|
||||
type?: ArcType;
|
||||
|
||||
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS). */
|
||||
icon?: string | null;
|
||||
|
||||
@@ -55,6 +79,7 @@ export interface ArcCreate {
|
||||
description?: string;
|
||||
campaignId: string;
|
||||
order: number;
|
||||
type?: ArcType;
|
||||
icon?: string | null;
|
||||
|
||||
themes?: string;
|
||||
@@ -76,6 +101,20 @@ export interface Chapter {
|
||||
order?: number;
|
||||
icon?: string | null;
|
||||
|
||||
/** Conditions de déblocage (ET logique). Donnée de SCÉNARIO. */
|
||||
prerequisites?: Prerequisite[];
|
||||
|
||||
/**
|
||||
* Statut de progression read-only — peuplé par le backend uniquement quand
|
||||
* un playthroughId est passé en query param. Ne pas inclure en écriture.
|
||||
*/
|
||||
progressionStatus?: ProgressionStatus;
|
||||
|
||||
/**
|
||||
* Statut effectif read-only — idem, dépend du Playthrough.
|
||||
*/
|
||||
effectiveStatus?: QuestStatus;
|
||||
|
||||
// Champs narratifs enrichis
|
||||
gmNotes?: string;
|
||||
playerObjectives?: string;
|
||||
@@ -93,6 +132,8 @@ export interface ChapterCreate {
|
||||
order: number;
|
||||
icon?: string | null;
|
||||
|
||||
prerequisites?: Prerequisite[];
|
||||
|
||||
gmNotes?: string;
|
||||
playerObjectives?: string;
|
||||
narrativeStakes?: string;
|
||||
@@ -102,6 +143,35 @@ export interface ChapterCreate {
|
||||
mapImageIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partie / instance jouée d'une Campagne par une table donnée.
|
||||
* Porte la progression dynamique des quêtes, les flags et les sessions.
|
||||
*/
|
||||
export interface Playthrough {
|
||||
id?: string;
|
||||
campaignId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PlaythroughCreate {
|
||||
campaignId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valeur courante d'un fait pour une Partie donnée.
|
||||
* Pas de déclaration explicite : un fait existe dès qu'au moins une quête le
|
||||
* référence via un prérequis FLAG_SET.
|
||||
*/
|
||||
export interface PlaythroughFlag {
|
||||
name: string;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Branche narrative : sortie possible d'une scène vers une autre du même chapitre.
|
||||
* Pendant TS du Value Object Java SceneBranch.
|
||||
@@ -112,6 +182,37 @@ export interface SceneBranch {
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortie d'une pièce vers une autre pièce du même lieu explorable.
|
||||
* Pendant TS du record domaine RoomBranch.
|
||||
*/
|
||||
export interface RoomBranch {
|
||||
label: string;
|
||||
targetRoomId: string;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pièce d'un lieu explorable attachée à une Scene.
|
||||
* Dès qu'une Scene a au moins une Room, elle bascule sur le rendu « donjon ».
|
||||
*/
|
||||
export interface Room {
|
||||
/** UUID stable généré côté client à la création (sert de cible aux RoomBranch). */
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enemies?: string;
|
||||
loot?: string;
|
||||
traps?: string;
|
||||
gmNotes?: string;
|
||||
/** Étage : 0 = RdC, 1 = 1er etc. null/undefined = pas d'étage défini. */
|
||||
floor?: number | null;
|
||||
order: number;
|
||||
illustrationImageIds?: string[];
|
||||
mapImageId?: string | null;
|
||||
branches?: RoomBranch[];
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -136,6 +237,9 @@ export interface Scene {
|
||||
|
||||
/** Sorties narratives (graphe intra-chapitre). */
|
||||
branches?: SceneBranch[];
|
||||
|
||||
/** Pièces explorables. Vide = scène classique, non-vide = lieu type donjon. */
|
||||
rooms?: Room[];
|
||||
}
|
||||
|
||||
export interface SceneCreate {
|
||||
@@ -158,4 +262,5 @@ export interface SceneCreate {
|
||||
illustrationImageIds?: string[];
|
||||
mapImageIds?: string[];
|
||||
branches?: SceneBranch[];
|
||||
rooms?: Room[];
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface CampaignDeletionImpact {
|
||||
arcs: number;
|
||||
chapters: number;
|
||||
scenes: number;
|
||||
characters: number;
|
||||
playthroughs: number;
|
||||
}
|
||||
|
||||
/** Compte des entités qui seront supprimées en cascade avec un arc. */
|
||||
@@ -85,13 +85,20 @@ export class CampaignService {
|
||||
}
|
||||
|
||||
// ========== CHAPTER ==========
|
||||
getChapters(arcId: string): Observable<Chapter[]> {
|
||||
const params = new HttpParams().set('arcId', arcId);
|
||||
/**
|
||||
* Liste les chapitres d'un arc. Si {@code playthroughId} est fourni, le backend
|
||||
* enrichit les DTOs avec progressionStatus + effectiveStatus relatifs à la Partie.
|
||||
*/
|
||||
getChapters(arcId: string, playthroughId?: string): Observable<Chapter[]> {
|
||||
let params = new HttpParams().set('arcId', arcId);
|
||||
if (playthroughId) params = params.set('playthroughId', playthroughId);
|
||||
return this.http.get<Chapter[]>('/api/chapters', { params });
|
||||
}
|
||||
|
||||
getChapterById(id: string): Observable<Chapter> {
|
||||
return this.http.get<Chapter>(`/api/chapters/${id}`);
|
||||
getChapterById(id: string, playthroughId?: string): Observable<Chapter> {
|
||||
let params = new HttpParams();
|
||||
if (playthroughId) params = params.set('playthroughId', playthroughId);
|
||||
return this.http.get<Chapter>(`/api/chapters/${id}`, { params });
|
||||
}
|
||||
|
||||
createChapter(payload: ChapterCreate): Observable<Chapter> {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||
* Refonte 2026-04-30 : abandon du markdownContent au profit d'un systeme
|
||||
* template-based pilote par le GameSystem de la campagne.
|
||||
* - portraitImageId / headerImageId : champs universels hard-codes
|
||||
* - values : Map<champ template TEXT/NUMBER, valeur>
|
||||
* - imageValues : Map<champ template IMAGE, liste d'IDs d'images>
|
||||
* Fiche de personnage joueur (PJ) d'une Partie (Playthrough).
|
||||
* Refonte Playthrough : les PJ appartiennent à la Partie (table jouée),
|
||||
* plus à la Campagne (scénario).
|
||||
*/
|
||||
export interface Character {
|
||||
id?: string;
|
||||
@@ -15,7 +12,7 @@ export interface Character {
|
||||
imageValues?: Record<string, string[]>;
|
||||
/** Champs KEY_VALUE_LIST : fieldName -> label -> value. */
|
||||
keyValueValues?: Record<string, Record<string, string>>;
|
||||
campaignId: string;
|
||||
playthroughId: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
@@ -26,5 +23,5 @@ export interface CharacterCreate {
|
||||
values?: Record<string, string>;
|
||||
imageValues?: Record<string, string[]>;
|
||||
keyValueValues?: Record<string, Record<string, string>>;
|
||||
campaignId: string;
|
||||
playthroughId: string;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ export class CharacterService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getByCampaign(campaignId: string): Observable<Character[]> {
|
||||
return this.http.get<Character[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||
getByPlaythrough(playthroughId: string): Observable<Character[]> {
|
||||
return this.http.get<Character[]>(`${this.apiUrl}/playthrough/${playthroughId}`);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Character> {
|
||||
|
||||
31
web/src/app/services/playthrough-flag.service.ts
Normal file
31
web/src/app/services/playthrough-flag.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { PlaythroughFlag } from './campaign.model';
|
||||
|
||||
/**
|
||||
* Endpoints des flags narratifs d'une Partie (Playthrough).
|
||||
* Remplace l'ancien CampaignFlagService.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaythroughFlagService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
list(playthroughId: string): Observable<PlaythroughFlag[]> {
|
||||
return this.http.get<PlaythroughFlag[]>(`/api/playthroughs/${playthroughId}/flags`);
|
||||
}
|
||||
|
||||
setFlag(playthroughId: string, name: string, value: boolean): Observable<PlaythroughFlag> {
|
||||
return this.http.put<PlaythroughFlag>(
|
||||
`/api/playthroughs/${playthroughId}/flags/${encodeURIComponent(name)}`,
|
||||
{ name, value }
|
||||
);
|
||||
}
|
||||
|
||||
deleteFlag(playthroughId: string, name: string): Observable<void> {
|
||||
return this.http.delete<void>(
|
||||
`/api/playthroughs/${playthroughId}/flags/${encodeURIComponent(name)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
44
web/src/app/services/playthrough.service.ts
Normal file
44
web/src/app/services/playthrough.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Playthrough, PlaythroughCreate } from './campaign.model';
|
||||
|
||||
export interface PlaythroughDeletionImpact {
|
||||
sessions: number;
|
||||
characters: number;
|
||||
flags: number;
|
||||
progressions: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaythroughService {
|
||||
|
||||
private apiUrl = '/api/playthroughs';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
listByCampaign(campaignId: string): Observable<Playthrough[]> {
|
||||
const params = new HttpParams().set('campaignId', campaignId);
|
||||
return this.http.get<Playthrough[]>(this.apiUrl, { params });
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Playthrough> {
|
||||
return this.http.get<Playthrough>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(payload: PlaythroughCreate): Observable<Playthrough> {
|
||||
return this.http.post<Playthrough>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: Playthrough): Observable<Playthrough> {
|
||||
return this.http.put<Playthrough>(`${this.apiUrl}/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
deletionImpact(id: string): Observable<PlaythroughDeletionImpact> {
|
||||
return this.http.get<PlaythroughDeletionImpact>(`${this.apiUrl}/${id}/deletion-impact`);
|
||||
}
|
||||
}
|
||||
28
web/src/app/services/quest-progression.service.ts
Normal file
28
web/src/app/services/quest-progression.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ProgressionStatus } from './campaign.model';
|
||||
|
||||
/**
|
||||
* Endpoints de progression des quêtes pour un Playthrough.
|
||||
* Modèle "absence = NOT_STARTED" — envoyer NOT_STARTED supprime la ligne côté backend.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class QuestProgressionService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/** Map chapterId -> ProgressionStatus pour le Playthrough donné. */
|
||||
list(playthroughId: string): Observable<Record<string, ProgressionStatus>> {
|
||||
return this.http.get<Record<string, ProgressionStatus>>(
|
||||
`/api/playthroughs/${playthroughId}/quest-progressions`
|
||||
);
|
||||
}
|
||||
|
||||
setStatus(playthroughId: string, chapterId: string, status: ProgressionStatus): Observable<void> {
|
||||
return this.http.put<void>(
|
||||
`/api/playthroughs/${playthroughId}/quest-progressions/${chapterId}`,
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
export interface Session {
|
||||
id: string;
|
||||
name: string;
|
||||
campaignId: string;
|
||||
playthroughId: string;
|
||||
startedAt: string;
|
||||
/** Null/undefined = session en cours. */
|
||||
endedAt: string | null;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Session } from './session.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour le Play Context (gestion des Sessions de jeu).
|
||||
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
||||
* Depuis Playthrough : une Session est rattachée à un Playthrough, pas à une Campagne.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -15,20 +15,31 @@ export class SessionService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/** Lance une nouvelle session sur la campagne donnée. */
|
||||
startSession(campaignId: string): Observable<Session> {
|
||||
return this.http.post<Session>(this.apiUrl, { campaignId });
|
||||
/** Lance une nouvelle session sur la Partie (Playthrough) donnée. */
|
||||
startSession(playthroughId: string): Observable<Session> {
|
||||
return this.http.post<Session>(this.apiUrl, { playthroughId });
|
||||
}
|
||||
|
||||
/** Récupère la session active (204 No Content si aucune). */
|
||||
/**
|
||||
* Récupère UNE session active dans l'app (legacy, multi-actives possibles désormais).
|
||||
* Préférer {@link getActiveByPlaythrough} quand on veut le statut d'une Partie précise.
|
||||
*/
|
||||
getActiveSession(): Observable<Session | null> {
|
||||
return this.http.get<Session | null>(`${this.apiUrl}/active`, { observe: 'body' });
|
||||
}
|
||||
|
||||
getSessions(campaignId?: string): Observable<Session[]> {
|
||||
/** Récupère la session active de la Partie donnée (null si aucune). */
|
||||
getActiveByPlaythrough(playthroughId: string): Observable<Session | null> {
|
||||
return this.http.get<Session | null>(`${this.apiUrl}/active`, {
|
||||
params: new HttpParams().set('playthroughId', playthroughId),
|
||||
observe: 'body'
|
||||
});
|
||||
}
|
||||
|
||||
getSessions(playthroughId?: string): Observable<Session[]> {
|
||||
let params = new HttpParams();
|
||||
if (campaignId) {
|
||||
params = params.set('campaignId', campaignId);
|
||||
if (playthroughId) {
|
||||
params = params.set('playthroughId', playthroughId);
|
||||
}
|
||||
return this.http.get<Session[]>(this.apiUrl, { params });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="session-detail" *ngIf="session">
|
||||
|
||||
<a class="back-link" [routerLink]="['/campaigns', session.campaignId]">
|
||||
<a class="back-link" [routerLink]="campaignId ? ['/campaigns', campaignId] : ['/campaigns']">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour à la campagne
|
||||
</a>
|
||||
@@ -175,7 +175,8 @@
|
||||
<!-- Colonne droite : panneau référence (Dés / Personnages / Scènes) -->
|
||||
<aside class="play-aside">
|
||||
<app-session-reference-panel
|
||||
[campaignId]="session.campaignId"
|
||||
[campaignId]="campaignId ?? ''"
|
||||
[playthroughId]="session.playthroughId"
|
||||
[sessionId]="session.id"
|
||||
[canAddToJournal]="session.active"
|
||||
(rolled)="onDiceRolled($event)"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { SessionService } from '../../services/session.service';
|
||||
import { Session } from '../../services/session.model';
|
||||
import { PlaythroughService } from '../../services/playthrough.service';
|
||||
import {
|
||||
SessionEntry, SessionEntryInput, EntryType, ENTRY_TYPE_META
|
||||
} from '../../services/session-entry.model';
|
||||
@@ -54,6 +55,8 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
||||
readonly entryTypeMeta = ENTRY_TYPE_META;
|
||||
|
||||
session: Session | null = null;
|
||||
/** Résolu via Playthrough.campaignId (Session → Playthrough → Campaign). */
|
||||
campaignId: string | null = null;
|
||||
/** Timeline triée du plus récent au plus ancien (DESC) pour l'UX en partie. */
|
||||
entries: SessionEntry[] = [];
|
||||
|
||||
@@ -74,6 +77,7 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private sessionService: SessionService,
|
||||
private playthroughService: PlaythroughService,
|
||||
private entryService: SessionEntryService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
@@ -93,6 +97,11 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
||||
if (session) {
|
||||
this.pageTitleService.set(session.name);
|
||||
this.loadEntries(session.id);
|
||||
if (session.playthroughId) {
|
||||
this.playthroughService.getById(session.playthroughId).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe(pt => { this.campaignId = pt ? pt.campaignId : null; });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -168,9 +177,12 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
const campaignId = session.campaignId;
|
||||
const cid = this.campaignId;
|
||||
this.sessionService.deleteSession(session.id).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', campaignId]),
|
||||
next: () => {
|
||||
if (cid) this.router.navigate(['/campaigns', cid]);
|
||||
else this.router.navigate(['/campaigns']);
|
||||
},
|
||||
error: () => console.error('Erreur lors de la suppression de la session')
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
@Input() campaignId!: string;
|
||||
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
|
||||
@Input() playthroughId: string | null = null;
|
||||
@Input() sessionId!: string;
|
||||
@Input() canAddToJournal = true;
|
||||
@Output() rolled = new EventEmitter<DiceRollResult>();
|
||||
@@ -85,7 +87,11 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
private ensureCharactersLoaded(): void {
|
||||
if (this.charsLoaded || this.loadingChars || !this.campaignId) return;
|
||||
this.loadingChars = true;
|
||||
this.characterService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Character[])))
|
||||
// PJ : propres à la Partie. PNJ : campagne-scope.
|
||||
const chars$ = this.playthroughId
|
||||
? this.characterService.getByPlaythrough(this.playthroughId)
|
||||
: of([] as Character[]);
|
||||
chars$.pipe(catchError(() => of([] as Character[])))
|
||||
.subscribe(list => { this.characters = list; this.tryFinishCharsLoad(); });
|
||||
this.npcService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Npc[])))
|
||||
.subscribe(list => { this.npcs = list; this.tryFinishCharsLoad(); });
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="flags-manager">
|
||||
|
||||
<p class="flags-empty" *ngIf="!loading && rows.length === 0">
|
||||
Aucun fait référencé. Ajoutez une condition « Quand un fait est vrai » sur
|
||||
au moins une quête pour qu'il apparaisse ici.
|
||||
</p>
|
||||
|
||||
<ul class="flag-list" *ngIf="rows.length > 0">
|
||||
<li class="flag-row" *ngFor="let r of rows" [class.flag-on]="r.value">
|
||||
<label class="flag-toggle">
|
||||
<input type="checkbox" [checked]="r.value" (change)="toggle(r)" />
|
||||
<span class="flag-toggle-slider"></span>
|
||||
</label>
|
||||
<div class="flag-info">
|
||||
<span class="flag-name">{{ r.name }}</span>
|
||||
</div>
|
||||
<span class="flag-value">{{ r.value ? 'vrai' : 'faux' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,133 @@
|
||||
.flags-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.flag-add-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.flag-add-input {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #d8d8d8);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flags-empty {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.flag-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.flag-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.flag-row.flag-on {
|
||||
border-color: rgba(52, 168, 83, 0.45);
|
||||
background: rgba(52, 168, 83, 0.05);
|
||||
}
|
||||
|
||||
.flag-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flag-name {
|
||||
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flag-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.flag-value {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.flag-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-danger, #c0392b);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover { background: rgba(192, 57, 43, 0.08); }
|
||||
}
|
||||
|
||||
// Toggle switch
|
||||
.flag-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
flex: 0 0 36px;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:checked + .flag-toggle-slider {
|
||||
background: var(--color-primary, #34a853);
|
||||
}
|
||||
|
||||
&:checked + .flag-toggle-slider::before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flag-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #ccc;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignFlagService } from '../../services/campaign-flag.service';
|
||||
import { PlaythroughFlagService } from '../../services/playthrough-flag.service';
|
||||
import { forkJoin } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Toggle de l'état des faits d'une Partie.
|
||||
*
|
||||
* <p>La liste des faits est déduite des conditions FLAG_SET référencées par les
|
||||
* quêtes de la campagne. Pour chaque fait référencé, on affiche un toggle qui
|
||||
* met à jour la valeur du Playthrough courant.</p>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-playthrough-flags-manager',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './playthrough-flags-manager.component.html',
|
||||
styleUrls: ['./playthrough-flags-manager.component.scss']
|
||||
})
|
||||
export class PlaythroughFlagsManagerComponent implements OnInit, OnChanges {
|
||||
|
||||
@Input() campaignId!: string;
|
||||
@Input() playthroughId!: string;
|
||||
|
||||
rows: { name: string; value: boolean }[] = [];
|
||||
loading = false;
|
||||
|
||||
constructor(
|
||||
private campaignFlagService: CampaignFlagService,
|
||||
private playthroughFlagService: PlaythroughFlagService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void { this.reload(); }
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['campaignId'] || changes['playthroughId']) this.reload();
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
if (!this.campaignId || !this.playthroughId) {
|
||||
this.rows = [];
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
forkJoin({
|
||||
referenced: this.campaignFlagService.listReferenced(this.campaignId),
|
||||
values: this.playthroughFlagService.list(this.playthroughId)
|
||||
}).subscribe({
|
||||
next: ({ referenced, values }) => {
|
||||
const valueByName: Record<string, boolean> = {};
|
||||
for (const v of values) valueByName[v.name] = v.value;
|
||||
this.rows = referenced.map(name => ({
|
||||
name,
|
||||
value: valueByName[name] === true
|
||||
}));
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
toggle(row: { name: string; value: boolean }): void {
|
||||
const next = !row.value;
|
||||
this.playthroughFlagService.setFlag(this.playthroughId, row.name, next).subscribe({
|
||||
next: () => { row.value = next; }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<div class="prereq-editor">
|
||||
|
||||
<p class="prereq-editor-empty" *ngIf="prerequisites.length === 0">
|
||||
Aucune condition — la quête est immédiatement disponible.
|
||||
</p>
|
||||
|
||||
<ul class="prereq-list" *ngIf="prerequisites.length > 0">
|
||||
<li class="prereq-row" *ngFor="let p of prerequisites; let i = index; trackBy: trackByIndex">
|
||||
|
||||
<ng-container [ngSwitch]="p.kind">
|
||||
|
||||
<!-- QUEST_COMPLETED -->
|
||||
<ng-container *ngSwitchCase="'QUEST_COMPLETED'">
|
||||
<span class="prereq-row-label">Quête terminée :</span>
|
||||
<select class="prereq-input"
|
||||
[ngModel]="asQuestCompleted(p).questId"
|
||||
(ngModelChange)="onQuestIdChange(i, $event)"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="availableQuests.length === 0">
|
||||
<option *ngIf="availableQuests.length === 0" value="">(aucune autre quête)</option>
|
||||
<option *ngFor="let q of availableQuests" [value]="q.id">{{ q.name }}</option>
|
||||
</select>
|
||||
</ng-container>
|
||||
|
||||
<!-- SESSION_REACHED -->
|
||||
<ng-container *ngSwitchCase="'SESSION_REACHED'">
|
||||
<span class="prereq-row-label">À partir de la session :</span>
|
||||
<input type="number"
|
||||
class="prereq-input prereq-input-number"
|
||||
min="1"
|
||||
[ngModel]="asSessionReached(p).minSessionNumber"
|
||||
(ngModelChange)="onMinSessionChange(i, $event)"
|
||||
[ngModelOptions]="{ standalone: true }" />
|
||||
</ng-container>
|
||||
|
||||
<!-- FLAG_SET -->
|
||||
<ng-container *ngSwitchCase="'FLAG_SET'">
|
||||
<span class="prereq-row-label">Quand le fait est vrai :</span>
|
||||
<input type="text"
|
||||
class="prereq-input"
|
||||
placeholder="nom_du_fait"
|
||||
[attr.list]="'prereq-flag-list-' + i"
|
||||
[ngModel]="asFlagSet(p).flagName"
|
||||
(ngModelChange)="onFlagNameChange(i, $event)"
|
||||
[ngModelOptions]="{ standalone: true }" />
|
||||
<datalist [attr.id]="'prereq-flag-list-' + i">
|
||||
<option *ngFor="let f of availableFlags" [value]="f"></option>
|
||||
</datalist>
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<button type="button" class="prereq-remove" (click)="removeAt(i)" title="Retirer ce prérequis">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="prereq-add">
|
||||
<button type="button" class="btn-secondary prereq-add-btn" (click)="toggleAddMenu()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Ajouter une condition
|
||||
<lucide-icon [img]="ChevronDown" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
|
||||
<div class="prereq-add-menu" *ngIf="addMenuOpen">
|
||||
<button type="button" (click)="addPrerequisite('QUEST_COMPLETED')">
|
||||
Après une autre quête
|
||||
</button>
|
||||
<button type="button" (click)="addPrerequisite('SESSION_REACHED')">
|
||||
À partir d'une session
|
||||
</button>
|
||||
<button type="button" (click)="addPrerequisite('FLAG_SET')">
|
||||
Quand un fait est vrai
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
.prereq-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.prereq-editor-empty {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prereq-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.prereq-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.prereq-row-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #555);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prereq-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #d8d8d8);
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
background: var(--color-bg, #fafafa);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #2c6cd6);
|
||||
}
|
||||
}
|
||||
|
||||
.prereq-input-number {
|
||||
flex: 0 0 90px;
|
||||
}
|
||||
|
||||
.prereq-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-danger, #c0392b);
|
||||
cursor: pointer;
|
||||
padding: 0.3rem;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover { background: rgba(192, 57, 43, 0.08); }
|
||||
}
|
||||
|
||||
.prereq-add {
|
||||
position: relative;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.prereq-add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.prereq-add-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: rgba(66, 133, 244, 0.08); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular';
|
||||
import { Chapter, Prerequisite } from '../../services/campaign.model';
|
||||
|
||||
/**
|
||||
* Éditeur des prérequis (conditions de déblocage) d'une quête.
|
||||
* Composant standalone & contrôlé : reçoit la liste, émet la nouvelle liste à chaque changement.
|
||||
*
|
||||
* Combinaison ET (MVP). UI : liste de lignes + bouton "Ajouter une condition" qui ouvre
|
||||
* un menu à 3 entrées (quête, session, fait).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-prerequisite-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './prerequisite-editor.component.html',
|
||||
styleUrls: ['./prerequisite-editor.component.scss']
|
||||
})
|
||||
export class PrerequisiteEditorComponent {
|
||||
/** Liste courante. */
|
||||
@Input() prerequisites: Prerequisite[] = [];
|
||||
|
||||
/** Quêtes candidates pour QUEST_COMPLETED (typiquement les chapitres frères du Hub). */
|
||||
@Input() availableQuests: Chapter[] = [];
|
||||
|
||||
/** Flags déjà connus de la campagne (autocomplete pour FLAG_SET). */
|
||||
@Input() availableFlags: string[] = [];
|
||||
|
||||
/** Émis chaque fois que la liste change (toute édition / ajout / suppression). */
|
||||
@Output() prerequisitesChange = new EventEmitter<Prerequisite[]>();
|
||||
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
|
||||
/** Ouvre/ferme le menu d'ajout. */
|
||||
addMenuOpen = false;
|
||||
|
||||
toggleAddMenu(): void { this.addMenuOpen = !this.addMenuOpen; }
|
||||
|
||||
addPrerequisite(kind: Prerequisite['kind']): void {
|
||||
let next: Prerequisite;
|
||||
switch (kind) {
|
||||
case 'QUEST_COMPLETED':
|
||||
next = { kind: 'QUEST_COMPLETED', questId: this.availableQuests[0]?.id ?? '' };
|
||||
break;
|
||||
case 'SESSION_REACHED':
|
||||
next = { kind: 'SESSION_REACHED', minSessionNumber: 1 };
|
||||
break;
|
||||
case 'FLAG_SET':
|
||||
next = { kind: 'FLAG_SET', flagName: this.availableFlags[0] ?? '' };
|
||||
break;
|
||||
}
|
||||
this.prerequisites = [...this.prerequisites, next];
|
||||
this.prerequisitesChange.emit(this.prerequisites);
|
||||
this.addMenuOpen = false;
|
||||
}
|
||||
|
||||
removeAt(index: number): void {
|
||||
this.prerequisites = this.prerequisites.filter((_, i) => i !== index);
|
||||
this.prerequisitesChange.emit(this.prerequisites);
|
||||
}
|
||||
|
||||
/** Le champ binding change : on émet une nouvelle liste (immutable update). */
|
||||
updateAt(index: number, patched: Prerequisite): void {
|
||||
this.prerequisites = this.prerequisites.map((p, i) => (i === index ? patched : p));
|
||||
this.prerequisitesChange.emit(this.prerequisites);
|
||||
}
|
||||
|
||||
// === Handlers spécifiques par type (pour garder les templates simples) ===
|
||||
|
||||
onQuestIdChange(index: number, questId: string): void {
|
||||
this.updateAt(index, { kind: 'QUEST_COMPLETED', questId });
|
||||
}
|
||||
onMinSessionChange(index: number, n: number): void {
|
||||
this.updateAt(index, { kind: 'SESSION_REACHED', minSessionNumber: Number(n) || 1 });
|
||||
}
|
||||
onFlagNameChange(index: number, flagName: string): void {
|
||||
this.updateAt(index, { kind: 'FLAG_SET', flagName });
|
||||
}
|
||||
|
||||
/** trackBy stable pour *ngFor (sinon les inputs reset à chaque édition). */
|
||||
trackByIndex(i: number): number { return i; }
|
||||
|
||||
// === Casts typés pour le template ===
|
||||
// Angular strict templates ne fait pas le narrowing d'une union discriminée à travers
|
||||
// *ngSwitchCase. Ces helpers castent vers la bonne variante pour rendre les inputs lisibles.
|
||||
asQuestCompleted(p: Prerequisite): { kind: 'QUEST_COMPLETED'; questId: string } {
|
||||
return p as { kind: 'QUEST_COMPLETED'; questId: string };
|
||||
}
|
||||
asSessionReached(p: Prerequisite): { kind: 'SESSION_REACHED'; minSessionNumber: number } {
|
||||
return p as { kind: 'SESSION_REACHED'; minSessionNumber: number };
|
||||
}
|
||||
asFlagSet(p: Prerequisite): { kind: 'FLAG_SET'; flagName: string } {
|
||||
return p as { kind: 'FLAG_SET'; flagName: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<span [class]="cssClass" [attr.aria-label]="label" [title]="label">
|
||||
<lucide-icon [img]="icon" [size]="14"></lucide-icon>
|
||||
<span class="status-label" *ngIf="!compact">{{ label }}</span>
|
||||
</span>
|
||||
@@ -0,0 +1,43 @@
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Verrouillée — gris discret
|
||||
.status-locked {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
color: #6b6b6b;
|
||||
border-color: rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
|
||||
// Disponible — vert calme (prête à être lancée)
|
||||
.status-available {
|
||||
background: rgba(52, 168, 83, 0.12);
|
||||
color: #2f7a47;
|
||||
border-color: rgba(52, 168, 83, 0.3);
|
||||
}
|
||||
|
||||
// En cours — bleu actif
|
||||
.status-in_progress {
|
||||
background: rgba(66, 133, 244, 0.14);
|
||||
color: #2c6cd6;
|
||||
border-color: rgba(66, 133, 244, 0.35);
|
||||
}
|
||||
|
||||
// Terminée — violet doux (clos, pas neutre)
|
||||
.status-completed {
|
||||
background: rgba(120, 80, 200, 0.12);
|
||||
color: #6d4fb5;
|
||||
border-color: rgba(120, 80, 200, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LucideAngularModule, Lock, Circle, Play, CheckCircle2, LucideIconData } from 'lucide-angular';
|
||||
import { QuestStatus } from '../../services/campaign.model';
|
||||
|
||||
/**
|
||||
* Badge visuel pour un QuestStatus (vue Hub).
|
||||
* Composant standalone, sans dépendance métier.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-quest-status-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './quest-status-badge.component.html',
|
||||
styleUrls: ['./quest-status-badge.component.scss']
|
||||
})
|
||||
export class QuestStatusBadgeComponent {
|
||||
@Input() status: QuestStatus | undefined | null = 'AVAILABLE';
|
||||
|
||||
/** Variante visuelle compacte (sans label) — utile pour les listes denses. */
|
||||
@Input() compact = false;
|
||||
|
||||
get icon(): LucideIconData {
|
||||
switch (this.status) {
|
||||
case 'LOCKED': return Lock;
|
||||
case 'IN_PROGRESS': return Play;
|
||||
case 'COMPLETED': return CheckCircle2;
|
||||
case 'AVAILABLE':
|
||||
default: return Circle;
|
||||
}
|
||||
}
|
||||
|
||||
get label(): string {
|
||||
switch (this.status) {
|
||||
case 'LOCKED': return 'Verrouillée';
|
||||
case 'IN_PROGRESS': return 'En cours';
|
||||
case 'COMPLETED': return 'Terminée';
|
||||
case 'AVAILABLE':
|
||||
default: return 'Disponible';
|
||||
}
|
||||
}
|
||||
|
||||
get cssClass(): string {
|
||||
return `status-badge status-${(this.status ?? 'AVAILABLE').toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
126
web/src/app/shared/rooms-editor/rooms-editor.component.html
Normal file
126
web/src/app/shared/rooms-editor/rooms-editor.component.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<div class="rooms-editor">
|
||||
|
||||
<p class="rooms-empty" *ngIf="rooms.length === 0">
|
||||
Aucune pièce. La scène se comporte comme un beat narratif classique.
|
||||
Ajoutez une première pièce pour la convertir en lieu explorable (donjon, crypte…).
|
||||
</p>
|
||||
|
||||
<ul class="room-list" *ngIf="rooms.length > 0">
|
||||
<li class="room-card" *ngFor="let r of rooms; let i = index; trackBy: trackById">
|
||||
|
||||
<!-- Header repliable -->
|
||||
<header class="room-head" (click)="toggleExpanded(r.id)">
|
||||
<button type="button" class="room-toggle" type="button">
|
||||
<lucide-icon [img]="isExpanded(r.id) ? ChevronUp : ChevronDown" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
<span class="room-index">#{{ i + 1 }}</span>
|
||||
<input type="text" class="room-name-input"
|
||||
[ngModel]="r.name"
|
||||
(ngModelChange)="patch(r, { name: $event })"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="Nom de la pièce" />
|
||||
<input type="number" class="room-floor-input"
|
||||
[ngModel]="r.floor"
|
||||
(ngModelChange)="patch(r, { floor: $event === null || $event === '' ? null : +$event })"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="Ét." title="Étage (0 = RdC)" />
|
||||
<div class="room-actions" (click)="$event.stopPropagation()">
|
||||
<button type="button" class="btn-icon" (click)="moveUp(r)" [disabled]="i === 0" title="Monter">↑</button>
|
||||
<button type="button" class="btn-icon" (click)="moveDown(r)" [disabled]="i === rooms.length - 1" title="Descendre">↓</button>
|
||||
<button type="button" class="btn-icon-danger" (click)="removeRoom(r)" title="Supprimer la pièce">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Corps déplié -->
|
||||
<div class="room-body" *ngIf="isExpanded(r.id)">
|
||||
|
||||
<div class="field">
|
||||
<label>Description (lue/résumée aux joueurs)</label>
|
||||
<textarea rows="3"
|
||||
[ngModel]="r.description"
|
||||
(ngModelChange)="patch(r, { description: $event })"
|
||||
placeholder="Atmosphère, ce que voient les PJ en entrant…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Ennemis / créatures / boss</label>
|
||||
<textarea rows="3"
|
||||
[ngModel]="r.enemies"
|
||||
(ngModelChange)="patch(r, { enemies: $event })"
|
||||
placeholder="2 gobelins, 1 ogre boss (PV 60, AC 14)…"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Loot / trésors</label>
|
||||
<textarea rows="3"
|
||||
[ngModel]="r.loot"
|
||||
(ngModelChange)="patch(r, { loot: $event })"
|
||||
placeholder="50 po, potion de soin, clé en argent…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Pièges / dangers</label>
|
||||
<textarea rows="2"
|
||||
[ngModel]="r.traps"
|
||||
(ngModelChange)="patch(r, { traps: $event })"
|
||||
placeholder="Trappe (DC 15 Perception), flèches empoisonnées…"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notes MJ (privé)</label>
|
||||
<textarea rows="2"
|
||||
[ngModel]="r.gmNotes"
|
||||
(ngModelChange)="patch(r, { gmNotes: $event })"
|
||||
placeholder="Le boss connaît les PJ par leur nom, indice…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branches inter-pièces -->
|
||||
<div class="field">
|
||||
<label>
|
||||
<lucide-icon [img]="GitBranch" [size]="14"></lucide-icon>
|
||||
Sorties / portes vers d'autres pièces
|
||||
</label>
|
||||
<ul class="branch-list" *ngIf="(r.branches?.length ?? 0) > 0">
|
||||
<li class="branch-row" *ngFor="let b of r.branches; let bi = index">
|
||||
<input type="text" class="branch-label"
|
||||
[ngModel]="b.label"
|
||||
(ngModelChange)="updateBranch(r, bi, { label: $event })"
|
||||
placeholder="Porte nord" />
|
||||
<span class="branch-arrow">→</span>
|
||||
<select class="branch-target"
|
||||
[ngModel]="b.targetRoomId"
|
||||
(ngModelChange)="updateBranch(r, bi, { targetRoomId: $event })"
|
||||
[disabled]="otherRooms(r).length === 0">
|
||||
<option *ngIf="otherRooms(r).length === 0" value="">(aucune autre pièce)</option>
|
||||
<option *ngFor="let o of otherRooms(r)" [value]="o.id">{{ o.name }}</option>
|
||||
</select>
|
||||
<input type="text" class="branch-condition"
|
||||
[ngModel]="b.condition"
|
||||
(ngModelChange)="updateBranch(r, bi, { condition: $event })"
|
||||
placeholder="Condition (optionnelle)" />
|
||||
<button type="button" class="btn-icon-danger" (click)="removeBranch(r, bi)">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button type="button" class="btn-secondary btn-small"
|
||||
(click)="addBranch(r)"
|
||||
[disabled]="otherRooms(r).length === 0">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
|
||||
Ajouter une sortie
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button type="button" class="btn-primary" (click)="addRoom()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Ajouter une pièce
|
||||
</button>
|
||||
|
||||
</div>
|
||||
159
web/src/app/shared/rooms-editor/rooms-editor.component.scss
Normal file
159
web/src/app/shared/rooms-editor/rooms-editor.component.scss
Normal file
@@ -0,0 +1,159 @@
|
||||
.rooms-editor { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
.rooms-empty {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.room-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.room-card {
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface, #fff);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
cursor: pointer;
|
||||
background: rgba(66, 133, 244, 0.04);
|
||||
|
||||
&:hover { background: rgba(66, 133, 244, 0.07); }
|
||||
}
|
||||
|
||||
.room-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted, #666);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.room-index {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.room-name-input {
|
||||
flex: 1;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover, &:focus {
|
||||
background: var(--color-bg, #fafafa);
|
||||
border-color: var(--color-border, #d8d8d8);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.room-floor-input {
|
||||
width: 56px;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border: 1px solid var(--color-border, #d8d8d8);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.room-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-icon, .btn-icon-danger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:hover { background: rgba(0,0,0,0.05); }
|
||||
&:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
}
|
||||
.btn-icon-danger {
|
||||
color: var(--color-danger, #c0392b);
|
||||
&:hover { background: rgba(192, 57, 43, 0.08); }
|
||||
}
|
||||
|
||||
.room-body {
|
||||
padding: 0.85rem 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
border-top: 1px solid var(--color-border, #eaeaea);
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.field label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted, #555);
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.field textarea, .field input[type="text"] {
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--color-border, #d8d8d8);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 0.88rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
// Branches
|
||||
.branch-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.branch-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr auto 1.5fr 1.5fr auto;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border: 1px solid var(--color-border, #ececec);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg, #fafafa);
|
||||
}
|
||||
.branch-arrow { font-weight: 700; color: var(--color-text-muted, #888); }
|
||||
.branch-label, .branch-condition, .branch-target {
|
||||
padding: 0.3rem 0.45rem;
|
||||
border: 1px solid var(--color-border, #d8d8d8);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
133
web/src/app/shared/rooms-editor/rooms-editor.component.ts
Normal file
133
web/src/app/shared/rooms-editor/rooms-editor.component.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Plus, Trash2, ChevronDown, ChevronUp, GitBranch, X } from 'lucide-angular';
|
||||
import { Room, RoomBranch } from '../../services/campaign.model';
|
||||
|
||||
/**
|
||||
* Éditeur de pièces (Rooms) d'une Scene explorable.
|
||||
*
|
||||
* Contrôlé : reçoit la liste, émet la nouvelle liste à chaque mutation.
|
||||
* Une pièce est repliée par défaut (juste le nom visible) ; un clic l'expand
|
||||
* pour éditer narration / ennemis / loot / pièges / notes MJ / étage.
|
||||
*
|
||||
* Les branches (graphe inter-pièces) sont éditables dans la card expand.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-rooms-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './rooms-editor.component.html',
|
||||
styleUrls: ['./rooms-editor.component.scss']
|
||||
})
|
||||
export class RoomsEditorComponent {
|
||||
|
||||
@Input() rooms: Room[] = [];
|
||||
@Output() roomsChange = new EventEmitter<Room[]>();
|
||||
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
readonly GitBranch = GitBranch;
|
||||
readonly X = X;
|
||||
|
||||
/** Set des IDs de pièces expandées (UI state local). */
|
||||
private expanded = new Set<string>();
|
||||
|
||||
isExpanded(id: string): boolean { return this.expanded.has(id); }
|
||||
toggleExpanded(id: string): void {
|
||||
if (this.expanded.has(id)) this.expanded.delete(id);
|
||||
else this.expanded.add(id);
|
||||
}
|
||||
|
||||
/** Génère un UUID v4 simplifié pour l'ID stable d'une pièce. */
|
||||
private newId(): string {
|
||||
return 'room-' + Math.random().toString(36).slice(2) + '-' + Date.now().toString(36);
|
||||
}
|
||||
|
||||
addRoom(): void {
|
||||
const nextOrder = this.rooms.length > 0
|
||||
? Math.max(...this.rooms.map(r => r.order ?? 0)) + 1
|
||||
: 0;
|
||||
const newRoom: Room = {
|
||||
id: this.newId(),
|
||||
name: `Salle ${this.rooms.length + 1}`,
|
||||
order: nextOrder,
|
||||
branches: []
|
||||
};
|
||||
this.rooms = [...this.rooms, newRoom];
|
||||
this.expanded.add(newRoom.id);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
removeRoom(room: Room): void {
|
||||
this.rooms = this.rooms
|
||||
.filter(r => r.id !== room.id)
|
||||
.map(r => ({
|
||||
...r,
|
||||
// Nettoyer les branches qui pointaient vers la pièce supprimée
|
||||
branches: (r.branches ?? []).filter(b => b.targetRoomId !== room.id)
|
||||
}));
|
||||
this.expanded.delete(room.id);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Patch un champ d'une pièce et émet. */
|
||||
patch(room: Room, patch: Partial<Room>): void {
|
||||
this.rooms = this.rooms.map(r => r.id === room.id ? { ...r, ...patch } : r);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
// === Branches inter-pièces ===
|
||||
|
||||
addBranch(room: Room): void {
|
||||
const others = this.rooms.filter(r => r.id !== room.id);
|
||||
const branch: RoomBranch = {
|
||||
label: 'Porte',
|
||||
targetRoomId: others[0]?.id ?? '',
|
||||
condition: ''
|
||||
};
|
||||
this.patch(room, { branches: [...(room.branches ?? []), branch] });
|
||||
}
|
||||
|
||||
removeBranch(room: Room, index: number): void {
|
||||
const next = (room.branches ?? []).filter((_, i) => i !== index);
|
||||
this.patch(room, { branches: next });
|
||||
}
|
||||
|
||||
updateBranch(room: Room, index: number, patch: Partial<RoomBranch>): void {
|
||||
const next = (room.branches ?? []).map((b, i) => i === index ? { ...b, ...patch } : b);
|
||||
this.patch(room, { branches: next });
|
||||
}
|
||||
|
||||
/** Pièces candidates pour cibler une branche (toutes sauf celle d'origine). */
|
||||
otherRooms(room: Room): Room[] {
|
||||
return this.rooms.filter(r => r.id !== room.id);
|
||||
}
|
||||
|
||||
/** Réorganise l'ordre via boutons up/down (simple : swap avec voisin). */
|
||||
moveUp(room: Room): void {
|
||||
const idx = this.rooms.findIndex(r => r.id === room.id);
|
||||
if (idx <= 0) return;
|
||||
const arr = [...this.rooms];
|
||||
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
|
||||
this.rooms = arr.map((r, i) => ({ ...r, order: i }));
|
||||
this.emit();
|
||||
}
|
||||
|
||||
moveDown(room: Room): void {
|
||||
const idx = this.rooms.findIndex(r => r.id === room.id);
|
||||
if (idx === -1 || idx >= this.rooms.length - 1) return;
|
||||
const arr = [...this.rooms];
|
||||
[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
|
||||
this.rooms = arr.map((r, i) => ({ ...r, order: i }));
|
||||
this.emit();
|
||||
}
|
||||
|
||||
trackById(_: number, r: Room): string { return r.id; }
|
||||
|
||||
private emit(): void {
|
||||
this.roomsChange.emit(this.rooms);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user