Mise en ligne de la version 0.2.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 46s
Build & Push Images / build (core) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m25s

This commit is contained in:
2026-04-21 14:25:17 +02:00
parent ebee8e106b
commit ba8a503b3e
300 changed files with 35329 additions and 1 deletions

View File

@@ -0,0 +1,18 @@
<div class="app-container">
<app-sidebar></app-sidebar>
<ng-container *ngIf="sidebarConfig$ | async as config">
<app-secondary-sidebar
[title]="config.title"
[items]="config.items"
[createActions]="config.createActions"
[bottomPanel]="config.bottomPanel || null">
</app-secondary-sidebar>
</ng-container>
<main class="main-content">
<router-outlet></router-outlet>
</main>
</div>
<app-global-search></app-global-search>

View File

@@ -0,0 +1,10 @@
.app-container {
display: flex;
height: 100vh;
}
.main-content {
flex: 1;
padding: 2rem;
overflow-y: auto;
}

View File

@@ -0,0 +1,33 @@
import { Component, HostListener } from '@angular/core';
import { AsyncPipe, NgIf } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { SidebarComponent } from './sidebar/sidebar.component';
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
import { LayoutService } from './services/layout.service';
import { GlobalSearchService } from './services/global-search.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf],
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
readonly sidebarConfig$ = this.layoutService.secondarySidebar$;
constructor(
private layoutService: LayoutService,
private globalSearch: GlobalSearchService
) {}
@HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
// Ctrl+K (Windows/Linux) ou Cmd+K (macOS)
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
this.globalSearch.toggle();
}
}
}

29
web/src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:folderId/edit', loadComponent: () => import('./lore/lore-node-edit/lore-node-edit.component').then(m => m.LoreNodeEditComponent) },
{ path: 'lore/:loreId/templates/create', loadComponent: () => import('./lore/template-create/template-create.component').then(m => m.TemplateCreateComponent) },
{ path: 'lore/:loreId/templates/:templateId', loadComponent: () => import('./lore/template-edit/template-edit.component').then(m => m.TemplateEditComponent) },
{ path: 'lore/:loreId/pages/create', loadComponent: () => import('./lore/page-create/page-create.component').then(m => m.PageCreateComponent) },
{ path: 'lore/:loreId/nodes/:nodeId/pages/create', loadComponent: () => import('./lore/page-create/page-create.component').then(m => m.PageCreateComponent) },
{ path: 'lore/:loreId/pages/:pageId', loadComponent: () => import('./lore/page-view/page-view.component').then(m => m.PageViewComponent) },
{ 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-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
{ path: 'settings', loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
];

View File

@@ -0,0 +1,37 @@
<div class="arc-create-page">
<div class="page-header">
<h1>Créer un nouvel arc narratif</h1>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="arc-form">
<div class="field">
<label>Nom de l'arc *</label>
<input
type="text"
formControlName="name"
placeholder="Ex: L'Ombre du Nord"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label>Description</label>
<textarea
formControlName="description"
placeholder="Décrivez l'arc narratif principal..."
rows="5">
</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer l'arc
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,18 @@
.arc-create-page {
padding: 2.5rem 2rem;
max-width: 640px;
}
// Override local : titre en violet (pas en blanc comme le .page-header global).
.page-header h1 { color: #a5b4fc; }
.arc-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
// Spécificité locale : le bouton "Créer l'arc" prend toute la largeur dispo.
// Tous les autres styles (.field, .btn-primary de base, .btn-secondary, .form-actions)
// sont fournis globalement par @app/styles/_forms.scss et _buttons.scss.
.btn-primary { flex: 1; }

View File

@@ -0,0 +1,95 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
/**
* Écran de création d'un nouvel Arc narratif (contexte Campagne).
* Formulaire simple : nom + description. L'ordre est auto-calculé depuis
* le nombre d'arcs existants dans la campagne courante.
*/
@Component({
selector: 'app-arc-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './arc-create.component.html',
styleUrls: ['./arc-create.component.scss']
})
export class ArcCreateComponent implements OnInit, OnDestroy {
readonly BookOpen = BookOpen;
form: FormGroup;
campaignId = '';
private existingArcCount = 0;
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: ['']
});
}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.loadLayout();
}
private loadLayout(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length;
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
submit(): void {
if (this.form.invalid) return;
this.campaignService.createArc({
name: this.form.value.name,
description: this.form.value.description,
campaignId: this.campaignId,
order: this.existingArcCount + 1
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
error: () => console.error('Erreur lors de la création de l\'arc')
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,143 @@
<div class="edit-page">
<div class="page-header">
<div>
<h1>{{ arc?.name || 'Arc' }}</h1>
<p class="subtitle">Arc narratif</p>
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cet arc">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
</div>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Glisse-depose ou clique sur "+ Ajouter" pour uploader. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
</div>
<div class="field">
<label>Titre de l'arc *</label>
<input
type="text"
formControlName="name"
placeholder="Ex: L'Ombre du Nord"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label>Synopsis de l'arc</label>
<textarea
formControlName="description"
placeholder="Décrivez l'histoire principale de cet arc narratif..."
rows="5">
</textarea>
</div>
<div class="field-row">
<div class="field">
<label>Thèmes principaux</label>
<textarea
formControlName="themes"
placeholder="Quels sont les thèmes explorés dans cet arc ? (trahison, rédemption...)"
rows="4">
</textarea>
</div>
<div class="field">
<label>Enjeux globaux</label>
<textarea
formControlName="stakes"
placeholder="Quels sont les enjeux majeurs de cet arc pour les personnages ?"
rows="4">
</textarea>
</div>
</div>
<div class="field">
<label>Notes et planification du MJ</label>
<textarea
formControlName="gmNotes"
placeholder="Vos notes sur la direction de l'arc, les twists prévus, les révélations importantes..."
rows="5">
</textarea>
<small class="field-hint">Ces notes sont privées et ne seront pas exportées vers FoundryVTT.</small>
</div>
<div class="field">
<label>Récompenses et progression</label>
<textarea
formControlName="rewards"
placeholder="Quelles récompenses les joueurs obtiendront-ils ? Objets, niveaux, connaissances, contacts..."
rows="4">
</textarea>
</div>
<div class="field">
<label>Dénouement prévu</label>
<textarea
formControlName="resolution"
placeholder="Comment cet arc devrait-il se terminer ? Quelles sont les issues possibles ?"
rows="4">
</textarea>
</div>
<!-- ===== Pages Lore associées (phase B2 cross-context) ===== -->
<div class="field" *ngIf="loreId">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.
</small>
</div>
<div class="field lore-hint" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form>
</div>
<!-- Drawer chat IA (hors .edit-page pour couvrir le viewport à droite) -->
<app-ai-chat-drawer
[campaignId]="campaignId"
entityType="arc"
[entityId]="arcId"
[isOpen]="chatOpen"
welcomeMessage="Je vois cet arc. Demande-moi d'enrichir ses thèmes, ses enjeux ou son dénouement."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,34 @@
.edit-page {
padding: 2.5rem 2rem;
max-width: 640px;
}
// Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
.header-actions {
display: flex;
gap: 0.5rem;
}
}
// Formulaire vertical classique.
.edit-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
// Override local : dans ce header, le bouton Supprimer est poussé à droite
// via margin-left auto (les boutons Annuler/Sauvegarder restent groupés à gauche).
// Styles de base fournis globalement par @app/styles/_buttons.scss (.btn-danger).
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
}

View File

@@ -0,0 +1,186 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Arc } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } 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';
/**
* Écran de détail/modification d'un Arc.
* Route : /campaigns/:campaignId/arcs/:arcId
*
* Intègre le picker de pages Lore (phase B2 cross-context) :
* si la campagne parente est associée à un Lore (`campaign.loreId`), les pages
* de ce Lore sont proposées dans un autocomplete pour lier cet arc à des
* personnages / lieux / objets du Lore.
*/
@Component({
selector: 'app-arc-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
templateUrl: './arc-edit.component.html',
styleUrls: ['./arc-edit.component.scss']
})
export class ArcEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
readonly chatQuickSuggestions = [
'Propose 3 thèmes majeurs pour cet arc',
'Imagine des enjeux qui mettent la pression sur les joueurs',
'Suggère un dénouement en deux actes'
];
toggleChat(): void { this.chatOpen = !this.chatOpen; }
form: FormGroup;
campaignId = '';
arcId = '';
arc: Arc | null = null;
/** Pages disponibles pour le picker (vide si la campagne n'a pas de loreId). */
availablePages: Page[] = [];
/** ID du Lore associé à la campagne (null si campagne sans univers). */
loreId: string | null = null;
/** IDs des pages liées à cet arc (bind sur app-lore-link-picker). */
relatedPageIds: string[] = [];
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
illustrationImageIds: string[] = [];
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
themes: [''],
stakes: [''],
gmNotes: [''],
rewards: [''],
resolution: ['']
});
}
ngOnInit(): void {
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
// réutilise le composant quand on navigue entre arcs 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')!;
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.arcId = newArcId;
this.loadAll();
}
});
}
private loadAll(): void {
// On déclenche d'abord les 4 appels indépendants, puis on charge les pages
// du Lore associé UNIQUEMENT si la campagne en a un (switchMap conditionnel).
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
// Pas de loreId → pas de picker, on retourne une liste vide.
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(
switchMap(pages => of({ ...data, pages, loreId: lid }))
);
})
).subscribe(({ campaign, allCampaigns, arc, treeData, pages, loreId }) => {
this.arc = arc;
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
this.pageTitleService.set(arc.name);
this.form.patchValue({
name: arc.name,
description: arc.description ?? '',
themes: arc.themes ?? '',
stakes: arc.stakes ?? '',
gmNotes: arc.gmNotes ?? '',
rewards: arc.rewards ?? '',
resolution: arc.resolution ?? ''
});
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
submit(): void {
if (this.form.invalid || !this.arc) return;
this.campaignService.updateArc(this.arcId, {
name: this.form.value.name,
description: this.form.value.description,
campaignId: this.campaignId,
order: this.arc.order ?? 1,
themes: this.form.value.themes,
stakes: this.form.value.stakes,
gmNotes: this.form.value.gmNotes,
rewards: this.form.value.rewards,
resolution: this.form.value.resolution,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la sauvegarde')
});
}
delete(): void {
if (!confirm(`Supprimer l'arc "${this.arc?.name}" ? Cette action est irréversible.`)) return;
this.campaignService.deleteArc(this.arcId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,74 @@
<div class="view-page" *ngIf="arc">
<header class="view-header">
<div>
<h1>{{ arc.name }}</h1>
<p class="view-subtitle">Arc narratif</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
</div>
</header>
<!-- Illustrations en tete de page (si presentes) -->
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []"></app-image-gallery>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
<p class="view-section-body" *ngIf="arc.description?.trim(); else emptyDesc">{{ arc.description }}</p>
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<div class="view-row">
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Thèmes principaux</h2>
<p class="view-section-body" *ngIf="arc.themes?.trim(); else emptyThemes">{{ arc.themes }}</p>
<ng-template #emptyThemes><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> Enjeux globaux</h2>
<p class="view-section-body" *ngIf="arc.stakes?.trim(); else emptyStakes">{{ arc.stakes }}</p>
<ng-template #emptyStakes><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
</div>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2>
<p class="view-section-body" *ngIf="arc.rewards?.trim(); else emptyRewards">{{ arc.rewards }}</p>
<ng-template #emptyRewards><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2>
<p class="view-section-body" *ngIf="arc.resolution?.trim(); else emptyResolution">{{ arc.resolution }}</p>
<ng-template #emptyResolution><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<!-- Notes MJ (bloc privé rouge discret) -->
<section class="view-section view-section--private" *ngIf="arc.gmNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes et planification du MJ
</h2>
<p class="view-section-body">{{ arc.gmNotes }}</p>
</section>
<!-- Pages Lore liées (chips cliquables) -->
<section class="view-section" *ngIf="loreId && (arc.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of arc.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
</div>

View File

@@ -0,0 +1 @@
// Styles partagés via styles/_view.scss

View File

@@ -0,0 +1,107 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Arc } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
/**
* Écran de consultation d'un Arc narratif (lecture seule).
* Route : /campaigns/:campaignId/arcs/:arcId
* Bouton "Modifier" → /campaigns/:campaignId/arcs/:arcId/edit
*/
@Component({
selector: 'app-arc-view',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './arc-view.component.html',
styleUrls: ['./arc-view.component.scss']
})
export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
campaignId = '';
arcId = '';
arc: Arc | null = null;
/** 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. */
availablePages: Page[] = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.arcId = newArcId;
this.load();
}
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
})
).subscribe(({ campaign, allCampaigns, arc, treeData, pages, loreId }) => {
this.arc = arc;
this.loreId = loreId;
this.availablePages = pages;
this.pageTitleService.set(arc.name);
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
titleOfRelated(pageId: string): string {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}
editMode(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'edit']);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,69 @@
<div class="modal-backdrop" (click)="onCancel()">
<div class="modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Créer une nouvelle Campagne</h2>
<button class="btn-close" (click)="onCancel()">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</div>
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label>Nom de la campagne *</label>
<input
type="text"
formControlName="name"
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label>Description / Pitch</label>
<textarea
formControlName="description"
placeholder="Résumez l'intrigue principale de votre campagne..."
rows="5"
></textarea>
</div>
<div class="field">
<label>Nombre de joueurs</label>
<input type="number" formControlName="playerCount" min="1" />
</div>
<div class="field">
<label>Univers associé</label>
<select formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
<p class="hint">
Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.
</p>
</div>
<div class="info-box">
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
<ul>
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
</ul>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
Créer la campagne
</button>
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,123 @@
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: #111827;
border: 1px solid #1f2937;
border-radius: 16px;
padding: 2rem;
width: 100%;
max-width: 600px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
h2 { color: white; font-size: 1.25rem; font-weight: 600; }
}
.btn-close {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
border-radius: 6px;
display: flex;
transition: color 0.2s;
&:hover { color: white; }
}
.field {
margin-bottom: 1.5rem;
label {
display: block;
font-size: 0.875rem;
color: #d1d5db;
margin-bottom: 0.5rem;
}
input, textarea {
width: 100%;
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 0.75rem 1rem;
color: white;
font-size: 0.9rem;
outline: none;
resize: none;
transition: border-color 0.2s;
&::placeholder { color: #4b5563; }
&:focus { border-color: #6c63ff; }
&.invalid { border-color: #ef4444; }
}
input[type="number"] { width: 120px; }
}
.info-box {
background: #1f2937;
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 2rem;
font-size: 0.875rem;
color: #9ca3af;
line-height: 1.8;
ul {
margin: 0.5rem 0 0 1.25rem;
li strong { color: #d1d5db; }
}
}
.modal-actions {
display: flex;
gap: 1rem;
}
.btn-primary {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
.btn-secondary {
padding: 0.75rem 1.5rem;
background: #1f2937;
color: #d1d5db;
border: 1px solid #374151;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #374151; }
}

View File

@@ -0,0 +1,69 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { Lore } from '../../services/lore.model';
/**
* Payload émis vers le parent à la création d'une campagne.
* `loreId` est optionnel (null = campagne sans univers associé).
*/
export interface CampaignCreatePayload {
name: string;
description: string;
playerCount: number;
loreId: string | null;
}
@Component({
selector: 'app-campaign-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './campaign-create.component.html',
styleUrls: ['./campaign-create.component.scss']
})
export class CampaignCreateComponent implements OnInit {
@Output() close = new EventEmitter<void>();
@Output() created = new EventEmitter<CampaignCreatePayload>();
readonly BookCopy = BookCopy;
readonly X = X;
form: FormGroup;
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
availableLores: Lore[] = [];
constructor(private fb: FormBuilder, private loreService: LoreService) {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
playerCount: [4, [Validators.required, Validators.min(1)]],
// Valeur par défaut : chaîne vide = "— Aucun lore associé —".
// Le service normalise ensuite ""/null en null côté backend.
loreId: ['']
});
}
ngOnInit(): void {
this.loreService.getAllLores().subscribe({
next: (lores) => this.availableLores = lores,
error: () => this.availableLores = []
});
}
submit(): void {
if (this.form.invalid) return;
const raw = this.form.value;
this.created.emit({
name: raw.name,
description: raw.description,
playerCount: raw.playerCount,
loreId: raw.loreId ? raw.loreId : null
});
}
onCancel(): void {
this.close.emit();
}
}

View File

@@ -0,0 +1,93 @@
<div class="campaign-detail" *ngIf="campaign">
<!-- ============ Header : mode lecture ============ -->
<div class="detail-header" *ngIf="!editing">
<div class="header-texts">
<h1>{{ campaign.name }}</h1>
<p class="description">{{ campaign.description }}</p>
<div class="meta">
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
<a *ngIf="linkedLore"
class="badge badge-lore"
[routerLink]="['/lore', linkedLore.id]"
title="Ouvrir l'univers associé">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
{{ linkedLore.name }}
</a>
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
<span *ngIf="campaign.loreId && !linkedLore" class="badge badge-lore-missing" title="L'univers associé est introuvable">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
Univers introuvable
</span>
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="startEdit()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteCampaign()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
<!-- ============ Header : mode édition inline ============ -->
<div class="detail-header edit-mode" *ngIf="editing">
<div class="field">
<label>Nom</label>
<input type="text" [(ngModel)]="editName" name="editName" required />
</div>
<div class="field">
<label>Description</label>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div>
<div class="field">
<label>Univers associé</label>
<select [(ngModel)]="editLoreId" name="editLoreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancelEdit()">
Annuler
</button>
</div>
</div>
<div class="arcs-section">
<div class="section-header">
<h2>Arcs narratifs</h2>
<button class="btn-add">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
</div>
<div class="arcs-grid" *ngIf="arcs.length > 0">
<div class="arc-card" *ngFor="let arc of arcs">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ arc.chapterCount || 0 }} chapitres</span>
</div>
</div>
<div class="empty-state" *ngIf="arcs.length === 0">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p>
<button class="btn-add-first">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier arc
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,203 @@
.campaign-detail {
padding: 2.5rem 2rem;
}
.detail-header {
margin-bottom: 2.5rem;
h1 {
font-size: 1.75rem;
font-weight: 700;
color: white;
margin-bottom: 0.5rem;
}
.description {
color: #6b7280;
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 1rem;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: #1e3a5f;
color: #60a5fa;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 999px;
text-decoration: none;
}
// Lien cliquable vers le Lore associé (weak cross-context link).
.badge-lore {
background: #2d2450;
color: #a78bfa;
transition: background 0.15s ease;
&:hover {
background: #3d3168;
cursor: pointer;
}
}
// Cas dégradé : loreId renseigné mais Lore introuvable (supprimé).
.badge-lore-missing {
background: #3a1e1e;
color: #f87171;
font-style: italic;
}
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
.header-texts { flex: 1; min-width: 0; }
.header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
// Variante mode édition : champs empilés verticalement.
&.edit-mode {
flex-direction: column;
align-items: stretch;
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
label { color: #9ca3af; font-size: 0.8rem; font-weight: 500; }
input, textarea, select {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 8px;
color: white;
padding: 0.6rem 0.85rem;
font-size: 0.9rem;
font-family: inherit;
&:focus { outline: none; border-color: #6c63ff; }
}
textarea { resize: vertical; }
}
.header-actions { justify-content: flex-end; }
}
}
// Boutons partagés.
.btn-primary, .btn-secondary, .btn-danger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } }
.btn-secondary { background: #1f2937; color: #d1d5db; &:hover:not(:disabled) { background: #374151; } }
.btn-danger { background: #3a1e1e; color: #f87171; &:hover:not(:disabled) { background: #5a2e2e; } }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; }
}
.btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #5b52e0; }
}
.arcs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.arc-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover { border-color: #6c63ff; transform: translateY(-2px); }
.arc-icon { color: #6c63ff; }
.arc-name { color: white; font-size: 0.9rem; font-weight: 600; }
.arc-meta { color: #6b7280; font-size: 0.75rem; }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 2rem;
color: #6b7280;
.empty-icon { color: #374151; }
p { font-size: 0.95rem; }
}
.btn-add-first {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.25rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #5b52e0; }
}

View File

@@ -0,0 +1,194 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2 } from 'lucide-angular';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
import { CampaignService } from '../../services/campaign.service';
import { LoreService } from '../../services/lore.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Arc } from '../../services/campaign.model';
import { Lore } from '../../services/lore.model';
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../campaign-tree.helper';
@Component({
selector: 'app-campaign-detail',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink],
templateUrl: './campaign-detail.component.html',
styleUrls: ['./campaign-detail.component.scss']
})
export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Swords = Swords;
readonly Plus = Plus;
readonly Globe = Globe;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
campaign: Campaign | null = null;
arcs: Arc[] = [];
/** Lore associé si `campaign.loreId` est renseigné ; sinon null. */
linkedLore: Lore | null = null;
/** Lores disponibles pour changer l'association en mode édition. */
availableLores: Lore[] = [];
/** Mode édition inline. */
editing = false;
editName = '';
editDescription = '';
editLoreId = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private loreService: LoreService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
// switchMap annule automatiquement le load précédent si l'utilisateur
// change de campagne avant que le forkJoin ne réponde — évite qu'une
// réponse en retard écrase des données plus récentes (race condition).
this.route.paramMap.pipe(
map(pm => pm.get('id')),
filter((id): id is string => !!id && id !== this.campaign?.id),
switchMap(id => forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
)
}))
).subscribe(({ campaign, allCampaigns, treeData }) => {
this.campaign = campaign;
this.editing = false;
this.loadLinkedLore(campaign);
this.arcs = treeData.arcs;
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
});
}
/**
* Recharge explicitement après une mise à jour locale (ex: saveEdit).
* Contrairement au flux ngOnInit, on bypass le filter sur l'ID puisqu'on
* veut rafraîchir même si l'ID n'a pas changé.
*/
private reload(id: string): void {
forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.campaign = campaign;
this.editing = false;
this.loadLinkedLore(campaign);
this.arcs = treeData.arcs;
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
});
}
/**
* Charge le Lore associé (si loreId présent). On swallow l'erreur :
* si le Lore a été supprimé entre-temps, on affiche simplement "Univers introuvable".
*/
private loadLinkedLore(campaign: Campaign): void {
if (!campaign.loreId) {
this.linkedLore = null;
return;
}
this.loreService.getLoreById(campaign.loreId).pipe(
catchError(() => of(null))
).subscribe(lore => this.linkedLore = lore);
}
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
const campaignId = this.campaign!.id!;
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
id: c.id!,
name: c.name,
route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: this.campaign!.name,
items: buildCampaignTree(campaignId, data),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
}
// ─────────────── Édition / suppression de la Campagne ───────────────
startEdit(): void {
if (!this.campaign) return;
this.editName = this.campaign.name;
this.editDescription = this.campaign.description ?? '';
this.editLoreId = this.campaign.loreId ?? '';
// On charge les Lores disponibles pour le select uniquement à l'entrée en mode édition.
this.loreService.getAllLores().subscribe({
next: (lores) => this.availableLores = lores,
error: () => this.availableLores = []
});
this.editing = true;
}
cancelEdit(): void {
this.editing = false;
}
saveEdit(): void {
if (!this.campaign || !this.editName.trim()) return;
this.campaignService.updateCampaign(this.campaign.id!, {
name: this.editName.trim(),
description: this.editDescription,
playerCount: this.campaign.playerCount ?? 0,
loreId: this.editLoreId ? this.editLoreId : null
}).subscribe({
next: (updated) => {
this.campaign = updated;
this.editing = false;
// Recharge pour actualiser le badge "Univers" et le titre sidebar.
this.reload(updated.id!);
},
error: () => console.error('Erreur lors de la mise à jour de la campagne')
});
}
/**
* Suppression protégée : refus si la campagne contient des arcs.
* Les arcs contiennent potentiellement des chapitres/scènes construits longuement.
*/
deleteCampaign(): void {
if (!this.campaign) return;
if (this.arcs.length > 0) {
alert(
`Impossible de supprimer "${this.campaign.name}" : elle contient encore ${this.arcs.length} arc(s).\n` +
`Videz la campagne (arcs et chapitres) avant de la supprimer.`
);
return;
}
if (!confirm(`Supprimer définitivement la campagne "${this.campaign.name}" ?`)) return;
this.campaignService.deleteCampaign(this.campaign.id!).subscribe({
next: () => this.router.navigate(['/campaigns']),
error: () => console.error('Erreur lors de la suppression de la campagne')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,109 @@
import { Observable, forkJoin, of } from 'rxjs';
import { switchMap, map } from 'rxjs/operators';
import { CampaignService } from '../services/campaign.service';
import { TreeItem } from '../services/layout.service';
import { Arc, Chapter, Scene } from '../services/campaign.model';
/**
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
* et la transforme en TreeItem[] pour la secondary sidebar.
*
* Pourquoi un helper et pas un service ? C'est de la logique de présentation
* (mapping REST -> ViewModel de la sidebar), pas du domaine métier.
*/
export interface CampaignTreeData {
arcs: Arc[];
chaptersByArc: Record<string, Chapter[]>;
scenesByChapter: Record<string, Scene[]>;
}
export function loadCampaignTreeData(
service: CampaignService,
campaignId: string
): Observable<CampaignTreeData> {
return service.getArcs(campaignId).pipe(
switchMap(arcs => {
if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {} });
}
const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
);
return forkJoin(chapterCalls).pipe(
switchMap(chapterResults => {
const chaptersByArc: Record<string, Chapter[]> = {};
const allChapters: Chapter[] = [];
chapterResults.forEach(r => {
chaptersByArc[r.arcId] = r.chapters;
allChapters.push(...r.chapters);
});
if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {} });
}
const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
);
return forkJoin(sceneCalls).pipe(
map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter };
})
);
})
);
})
);
}
export function buildCampaignTree(campaignId: string, data: CampaignTreeData): TreeItem[] {
// Tri FR avec `numeric: true` pour que "1. Intro", "2. Voyage", "10. Final" soient
// classés 1, 2, 10 (et pas 1, 10, 2). `sensitivity: 'base'` ignore la casse.
const byName = (a: { name: string }, b: { name: string }) =>
a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' });
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded
// (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
// peuvent coexister et se marchaient sur les pieds dans le Set<string> global).
const sortedArcs = [...data.arcs].sort(byName);
return sortedArcs.map(arc => {
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byName);
const chapterItems: TreeItem[] = sortedChapters.map(ch => {
const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byName);
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
id: `scene-${sc.id}`,
label: sc.name,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
}));
sceneItems.push({
id: `new-scene-${ch.id}`,
label: '+ Nouvelle scène',
isAction: true,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`
});
return {
id: `chapter-${ch.id}`,
label: ch.name,
children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`
};
});
chapterItems.push({
id: `new-chapter-${arc.id}`,
label: '+ Nouveau chapitre',
isAction: true,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`
});
return {
id: `arc-${arc.id}`,
label: arc.name,
children: chapterItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}`
};
});
}

View File

@@ -0,0 +1,42 @@
<div class="campaigns-page">
<div class="campaigns-hero">
<lucide-icon [img]="Map" [size]="56" class="hero-icon"></lucide-icon>
<h1>Vos Campagnes</h1>
<p class="hero-subtitle">Rejoignez une campagne ou créez-en de nouvelles</p>
</div>
<div class="campaigns-grid">
<div class="campaign-card" *ngFor="let campaign of campaigns" (click)="navigateToDetail(campaign.id!)">
<div class="card-header">
<span class="status-badge en-cours">En cours</span>
<span class="card-date">{{ campaign.playerCount }} joueurs</span>
</div>
<h2>{{ campaign.name }}</h2>
<p class="card-description">{{ campaign.description }}</p>
<div class="card-stats">
<span>⚔️ {{ campaign.arcCount || 0 }} arcs</span>
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
</div>
</div>
<div class="campaign-card card-new" (click)="openCreateModal()">
<div class="new-icon">
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
</div>
<h2>Nouvelle Campagne</h2>
<p class="card-description">Créez une nouvelle aventure</p>
</div>
</div>
<p class="tip">💡 Astuce : Organisez vos arcs et chapitres pour ne rien oublier de vos aventures</p>
</div>
<app-campaign-create
*ngIf="showCreateModal"
(close)="onModalClose()"
(created)="onCampaignCreated($event)">
</app-campaign-create>

View File

@@ -0,0 +1,99 @@
.campaigns-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 2rem;
min-height: 100%;
}
.campaigns-hero {
text-align: center;
margin-bottom: 3rem;
.hero-icon { display: block; margin-bottom: 1rem; color: #6c63ff; }
h1 { font-size: 2rem; font-weight: 700; color: white; margin-bottom: 0.5rem; }
.hero-subtitle { color: #6b7280; font-size: 0.95rem; }
}
.campaigns-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
width: 100%;
max-width: 900px;
}
.campaign-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1.5rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover {
border-color: #6c63ff;
transform: translateY(-3px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.status-badge {
font-size: 0.7rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-weight: 600;
&.en-cours { background: #1e3a5f; color: #60a5fa; }
&.terminée { background: #1a3a2a; color: #4ade80; }
&.en-pause { background: #3a2a1a; color: #fb923c; }
}
.card-date { font-size: 0.75rem; color: #6b7280; }
h2 { color: white; font-size: 1.1rem; margin-bottom: 0.5rem; }
.card-description { color: #6b7280; font-size: 0.875rem; line-height: 1.5; margin-bottom: 1rem; }
.card-stats {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: #4b5563;
}
}
.card-new {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-style: dashed;
border-color: #374151;
text-align: center;
.new-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: #1f2937;
color: #6c63ff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
}
}
.tip {
margin-top: 3rem;
font-size: 0.8rem;
color: #4b5563;
}

View File

@@ -0,0 +1,60 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
import { CampaignService } from '../services/campaign.service';
import { Campaign } from '../services/campaign.model';
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign-create/campaign-create.component';
@Component({
selector: 'app-campaigns',
standalone: true,
imports: [CommonModule, LucideAngularModule, CampaignCreateComponent],
templateUrl: './campaigns.component.html',
styleUrls: ['./campaigns.component.scss']
})
export class CampaignsComponent implements OnInit {
readonly Map = Map;
readonly Plus = Plus;
campaigns: Campaign[] = [];
showCreateModal = false;
constructor(
private router: Router,
private campaignService: CampaignService
) {}
ngOnInit(): void {
this.loadCampaigns();
}
loadCampaigns(): void {
this.campaignService.getAllCampaigns().subscribe({
next: (data) => this.campaigns = data,
error: () => this.campaigns = []
});
}
openCreateModal(): void {
this.showCreateModal = true;
}
onModalClose(): void {
this.showCreateModal = false;
}
onCampaignCreated(data: CampaignCreatePayload): void {
this.campaignService.createCampaign(data).subscribe({
next: () => {
this.showCreateModal = false;
this.loadCampaigns();
},
error: () => console.error('Erreur lors de la création de la campagne')
});
}
navigateToDetail(id: string): void {
this.router.navigate(['/campaigns', id]);
}
}

View File

@@ -0,0 +1,38 @@
<div class="chapter-create-page">
<div class="page-header">
<h1>Créer un nouveau chapitre</h1>
<p class="arc-ref" *ngIf="arcName">Arc : {{ arcName }}</p>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
<div class="field">
<label>Nom du chapitre *</label>
<input
type="text"
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label>Description</label>
<textarea
formControlName="description"
placeholder="Décrivez ce chapitre..."
rows="5">
</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer le chapitre
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,21 @@
.chapter-create-page {
padding: 2.5rem 2rem;
max-width: 640px;
}
// Overrides locaux :
// - titre en violet (au lieu de blanc comme le .page-header global)
// - sous-titre .arc-ref spécifique à cet écran (référence à l'arc parent)
.page-header {
h1 { color: #a5b4fc; }
.arc-ref { color: #6b7280; font-size: 0.85rem; margin: 0; }
}
.chapter-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
// Le bouton "Créer" prend toute la largeur restante.
.btn-primary { flex: 1; }

View File

@@ -0,0 +1,97 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
/**
* Écran de création d'un nouveau chapitre rattaché à un arc.
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/create
*/
@Component({
selector: 'app-chapter-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss']
})
export class ChapterCreateComponent implements OnInit, OnDestroy {
form: FormGroup;
campaignId = '';
arcId = '';
arcName = '';
private existingChapterCount = 0;
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: ['']
});
}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.arcId = this.route.snapshot.paramMap.get('arcId')!;
this.loadLayout();
}
private loadLayout(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? '';
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
submit(): void {
if (this.form.invalid) return;
this.campaignService.createChapter({
name: this.form.value.name,
description: this.form.value.description,
arcId: this.arcId,
order: this.existingChapterCount + 1
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', created.id]),
error: () => console.error('Erreur lors de la création du chapitre')
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,125 @@
<div class="edit-page">
<div class="page-header">
<div>
<h1>{{ chapter?.name || 'Chapitre' }}</h1>
<p class="subtitle">Chapitre</p>
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
</div>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Ajoute des cartes, portraits ou ambiances pour illustrer ce chapitre.</small>
</div>
<div class="field">
<label>Titre du chapitre *</label>
<input
type="text"
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label>Synopsis du chapitre</label>
<textarea
formControlName="description"
placeholder="Décrivez brièvement ce qui se passe dans ce chapitre..."
rows="5">
</textarea>
</div>
<div class="field">
<label>Notes du Maître de Jeu</label>
<textarea
formControlName="gmNotes"
placeholder="Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements..."
rows="6">
</textarea>
<small class="field-hint">Ces notes sont privées et ne seront pas exportées vers FoundryVTT.</small>
</div>
<div class="field-row">
<div class="field">
<label>Objectifs des joueurs</label>
<textarea
formControlName="playerObjectives"
placeholder="Que doivent accomplir les joueurs dans ce chapitre ?"
rows="4">
</textarea>
</div>
<div class="field">
<label>Enjeux narratifs</label>
<textarea
formControlName="narrativeStakes"
placeholder="Quels sont les enjeux dramatiques ?"
rows="4">
</textarea>
</div>
</div>
<!-- ===== Pages Lore associées (B2 cross-context) ===== -->
<div class="field" *ngIf="loreId">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
</small>
</div>
<div class="field" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier ce chapitre à des pages du Lore.
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form>
</div>
<!-- Drawer chat IA (hors .edit-page pour couvrir le viewport à droite) -->
<app-ai-chat-drawer
[campaignId]="campaignId"
entityType="chapter"
[entityId]="chapterId"
[isOpen]="chatOpen"
welcomeMessage="Je vois ce chapitre. Demande-moi d'étoffer ses objectifs, ses enjeux ou sa scène d'ouverture."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,31 @@
.edit-page {
padding: 2.5rem 2rem;
max-width: 640px;
}
// Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
.header-actions {
display: flex;
gap: 0.5rem;
}
}
.edit-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
// Override local : bouton Supprimer poussé à droite (voir _buttons.scss pour la base).
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
}

View File

@@ -0,0 +1,173 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Chapter } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } 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';
/**
* Écran de détail/modification d'un Chapitre.
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId
*
* Inclut le picker de pages Lore (B2 cross-context) si la campagne parente
* est associée à un Lore.
*/
@Component({
selector: 'app-chapter-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss']
})
export class ChapterEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
readonly chatQuickSuggestions = [
'Propose des objectifs clairs pour les joueurs dans ce chapitre',
'Imagine 2 tensions narratives qui relancent l\'intérêt en milieu de chapitre',
'Suggère une scène d\'ouverture marquante'
];
toggleChat(): void { this.chatOpen = !this.chatOpen; }
form: FormGroup;
campaignId = '';
arcId = '';
chapterId = '';
chapter: Chapter | null = null;
availablePages: Page[] = [];
loreId: string | null = null;
relatedPageIds: string[] = [];
illustrationImageIds: string[] = [];
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
gmNotes: [''],
playerObjectives: [''],
narrativeStakes: ['']
});
}
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')!;
const newChapterId = pm.get('chapterId')!;
if (newChapterId !== this.chapterId ||
newArcId !== this.arcId ||
newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.arcId = newArcId;
this.chapterId = newChapterId;
this.loadAll();
}
});
}
private loadAll(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
})
).subscribe(({ campaign, allCampaigns, chapter, treeData, pages, loreId }) => {
this.chapter = chapter;
this.pageTitleService.set(chapter.name);
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.form.patchValue({
name: chapter.name,
description: chapter.description ?? '',
gmNotes: chapter.gmNotes ?? '',
playerObjectives: chapter.playerObjectives ?? '',
narrativeStakes: chapter.narrativeStakes ?? ''
});
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
submit(): void {
if (this.form.invalid || !this.chapter) return;
this.campaignService.updateChapter(this.chapterId, {
name: this.form.value.name,
description: this.form.value.description,
arcId: this.arcId,
order: this.chapter.order ?? 1,
gmNotes: this.form.value.gmNotes,
playerObjectives: this.form.value.playerObjectives,
narrativeStakes: this.form.value.narrativeStakes,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
error: () => console.error('Erreur lors de la sauvegarde')
});
}
delete(): void {
if (!confirm(`Supprimer le chapitre "${this.chapter?.name}" ? Cette action est irréversible.`)) return;
this.campaignService.deleteChapter(this.chapterId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,64 @@
<div class="graph-page">
<div class="page-header">
<div>
<h1>{{ chapter?.name || 'Chapitre' }} — Carte</h1>
<p class="subtitle">Organigramme des scènes et de leurs branches narratives</p>
</div>
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour au chapitre
</button>
</div>
<div class="graph-empty" *ngIf="scenes.length === 0">
<p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p>
</div>
<div class="graph-container" *ngIf="scenes.length > 0">
<svg [attr.width]="svgWidth" [attr.height]="svgHeight" class="graph-svg">
<defs>
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6b7280" />
</marker>
</defs>
<g class="edges">
<g class="edge" *ngFor="let edge of edges">
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
stroke="#6b7280" stroke-width="2"
marker-end="url(#arrowhead)" />
<text *ngIf="edge.label"
[attr.x]="edge.labelX"
[attr.y]="edge.labelY"
text-anchor="middle"
class="edge-label">
{{ edge.label }}
</text>
</g>
</g>
<g class="nodes">
<g class="node" *ngFor="let node of nodes" (click)="openScene(node.id)">
<title>{{ node.name }}</title>
<rect [attr.x]="node.x" [attr.y]="node.y"
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
rx="8" ry="8" class="node-box" />
<text [attr.x]="node.x + NODE_WIDTH / 2"
[attr.y]="node.y + NODE_HEIGHT / 2 + 5"
text-anchor="middle"
class="node-label">
{{ node.displayName }}
</text>
</g>
</g>
</svg>
<small class="graph-hint">
💡 Cliquez sur une scène pour l'ouvrir. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
</small>
</div>
</div>

View File

@@ -0,0 +1,86 @@
.graph-page {
padding: 2.5rem 2rem;
max-width: 100%;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
.subtitle {
color: #6b7280;
font-size: 0.9rem;
margin: 0.25rem 0 0;
}
}
.graph-empty {
padding: 2rem;
text-align: center;
color: #6b7280;
background: #f9fafb;
border-radius: 8px;
border: 1px dashed #d1d5db;
}
.graph-container {
background: #fafafa;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 20px;
overflow: auto;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
}
.graph-svg {
display: block;
max-width: 100%;
}
.node {
cursor: pointer;
.node-box {
fill: #ffffff;
stroke: #1f2937;
stroke-width: 2;
transition: fill 0.15s ease, stroke 0.15s ease;
}
.node-label {
font-size: 0.9rem;
font-weight: 500;
fill: #1f2937;
pointer-events: none;
}
&:hover .node-box {
fill: #eef2ff;
stroke: #4f46e5;
}
}
.edge-label {
font-size: 0.75rem;
fill: #4b5563;
font-style: italic;
// Halo blanc autour du texte pour garantir la lisibilité même s'il passe
// sur une ligne ou un autre élément.
paint-order: stroke;
stroke: #fafafa;
stroke-width: 3px;
stroke-linejoin: round;
}
.graph-hint {
display: block;
margin-top: 1rem;
color: #6b7280;
font-size: 0.85rem;
}

View File

@@ -0,0 +1,204 @@
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 { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
interface GraphEdge { label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
/**
* Vue graphique d'un chapitre : organigramme des scènes et branches narratives.
* Layout custom (BFS par niveaux) en SVG — évite une dépendance lourde type ngx-graph.
*/
@Component({
selector: 'app-chapter-graph',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule],
templateUrl: './chapter-graph.component.html',
styleUrls: ['./chapter-graph.component.scss']
})
export class ChapterGraphComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
campaignId = '';
arcId = '';
chapterId = '';
chapter: Chapter | null = null;
scenes: Scene[] = [];
nodes: GraphNode[] = [];
edges: GraphEdge[] = [];
readonly NODE_WIDTH = 220;
readonly NODE_HEIGHT = 64;
readonly H_SPACING = 50;
readonly V_SPACING = 90;
readonly MAX_LABEL_CHARS = 26;
svgWidth = 600;
svgHeight = 400;
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.campaignId = pm.get('campaignId')!;
this.arcId = pm.get('arcId')!;
this.chapterId = pm.get('chapterId')!;
this.load();
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter;
this.scenes = scenes;
this.pageTitleService.set(`${chapter.name} — Carte`);
this.buildGraph();
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
/**
* Layout en niveaux par BFS depuis la scène d'entrée (order le plus bas).
* Scènes non atteignables rassemblées dans un niveau "orphelin" tout en bas.
*/
private buildGraph(): void {
if (this.scenes.length === 0) {
this.nodes = []; this.edges = [];
this.svgWidth = 600; this.svgHeight = 200;
return;
}
const sorted = [...this.scenes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const entry = sorted[0];
const levelOf = new Map<string, number>();
levelOf.set(entry.id!, 0);
const queue: string[] = [entry.id!];
while (queue.length > 0) {
const curId = queue.shift()!;
const curLevel = levelOf.get(curId)!;
const curScene = this.scenes.find(s => s.id === curId);
if (!curScene?.branches) continue;
for (const b of curScene.branches) {
if (!levelOf.has(b.targetSceneId)) {
levelOf.set(b.targetSceneId, curLevel + 1);
queue.push(b.targetSceneId);
}
}
}
const reachableMax = levelOf.size > 0 ? Math.max(...Array.from(levelOf.values())) : 0;
const orphanLevel = reachableMax + 1;
for (const s of this.scenes) {
if (!levelOf.has(s.id!)) levelOf.set(s.id!, orphanLevel);
}
const byLevel = new Map<number, Scene[]>();
for (const s of this.scenes) {
const lvl = levelOf.get(s.id!)!;
if (!byLevel.has(lvl)) byLevel.set(lvl, []);
byLevel.get(lvl)!.push(s);
}
const maxPerLevel = Math.max(...Array.from(byLevel.values()).map(arr => arr.length));
const rowWidth = maxPerLevel * this.NODE_WIDTH + (maxPerLevel - 1) * this.H_SPACING;
const nodes: GraphNode[] = [];
for (const [lvl, arr] of byLevel.entries()) {
const count = arr.length;
const levelWidth = count * this.NODE_WIDTH + (count - 1) * this.H_SPACING;
const startX = (rowWidth - levelWidth) / 2;
arr.forEach((s, i) => {
nodes.push({
id: s.id!,
name: s.name,
displayName: this.truncate(s.name),
x: startX + i * (this.NODE_WIDTH + this.H_SPACING),
y: lvl * (this.NODE_HEIGHT + this.V_SPACING)
});
});
}
const nodeMap = new Map(nodes.map(n => [n.id, n]));
const edges: GraphEdge[] = [];
for (const scene of this.scenes) {
const from = nodeMap.get(scene.id!);
if (!from || !scene.branches) continue;
// On positionne chaque label a une fraction t differente de l'arete selon
// son index parmi les sorties du meme noeud source. Evite le chevauchement
// des labels au milieu quand plusieurs aretes convergent/divergent.
const siblings = scene.branches.filter(b => nodeMap.has(b.targetSceneId));
const count = siblings.length;
siblings.forEach((b, idx) => {
const to = nodeMap.get(b.targetSceneId)!;
const x1 = from.x + this.NODE_WIDTH / 2;
const y1 = from.y + this.NODE_HEIGHT;
const x2 = to.x + this.NODE_WIDTH / 2;
const y2 = to.y;
// t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes.
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
edges.push({
label: b.label,
x1, y1, x2, y2,
labelX: x1 + (x2 - x1) * t,
labelY: y1 + (y2 - y1) * t - 4
});
});
}
this.nodes = nodes;
this.edges = edges;
this.svgWidth = Math.max(rowWidth + 40, 600);
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
}
private truncate(text: string): string {
return text.length > this.MAX_LABEL_CHARS
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
: text;
}
openScene(sceneId: string): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', sceneId]);
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,65 @@
<div class="view-page" *ngIf="chapter">
<header class="view-header">
<div>
<h1>{{ chapter.name }}</h1>
<p class="view-subtitle">Chapitre</p>
</div>
<div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
title="Voir l'organigramme des scènes et de leurs branches">
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
Carte du chapitre
</button>
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
</div>
</header>
<!-- Illustrations -->
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []"></app-image-gallery>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Synopsis</h2>
<p class="view-section-body" *ngIf="chapter.description?.trim(); else emptyDesc">{{ chapter.description }}</p>
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<div class="view-row">
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> Objectifs des joueurs</h2>
<p class="view-section-body" *ngIf="chapter.playerObjectives?.trim(); else emptyObj">{{ chapter.playerObjectives }}</p>
<ng-template #emptyObj><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Enjeux narratifs</h2>
<p class="view-section-body" *ngIf="chapter.narrativeStakes?.trim(); else emptyNs">{{ chapter.narrativeStakes }}</p>
<ng-template #emptyNs><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
</div>
<section class="view-section view-section--private" *ngIf="chapter.gmNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes du Maître de Jeu
</h2>
<p class="view-section-body">{{ chapter.gmNotes }}</p>
</section>
<section class="view-section" *ngIf="loreId && (chapter.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of chapter.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
</div>

View File

@@ -0,0 +1 @@
// Styles partagés via styles/_view.scss

View File

@@ -0,0 +1,118 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Chapter } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
/**
* Écran de consultation d'un Chapitre (lecture seule).
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId
*/
@Component({
selector: 'app-chapter-view',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './chapter-view.component.html',
styleUrls: ['./chapter-view.component.scss']
})
export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Network = Network;
campaignId = '';
arcId = '';
chapterId = '';
chapter: Chapter | null = null;
loreId: string | null = null;
availablePages: Page[] = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!;
if (newChapterId !== this.chapterId ||
newArcId !== this.arcId ||
newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.arcId = newArcId;
this.chapterId = newChapterId;
this.load();
}
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
})
).subscribe(({ campaign, allCampaigns, chapter, treeData, pages, loreId }) => {
this.chapter = chapter;
this.loreId = loreId;
this.availablePages = pages;
this.pageTitleService.set(chapter.name);
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
titleOfRelated(pageId: string): string {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}
editMode(): void {
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'edit'
]);
}
openGraph(): void {
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'graph'
]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,38 @@
<div class="scene-create-page">
<div class="page-header">
<h1>Créer une nouvelle scène</h1>
<p class="chapter-ref" *ngIf="chapterName">Chapitre : {{ chapterName }}</p>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
<div class="field">
<label>Nom de la scène *</label>
<input
type="text"
formControlName="name"
placeholder="Ex: Arrivée au village"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label>Description</label>
<textarea
formControlName="description"
placeholder="Décrivez la scène, les événements clés, les PNJ présents..."
rows="6">
</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer la scène
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,18 @@
.scene-create-page {
padding: 2.5rem 2rem;
max-width: 640px;
}
// Overrides locaux : titre violet + sous-titre .chapter-ref (parent de la scène).
.page-header {
h1 { color: #a5b4fc; }
.chapter-ref { color: #6b7280; font-size: 0.85rem; margin: 0; }
}
.scene-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.btn-primary { flex: 1; }

View File

@@ -0,0 +1,99 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
/**
* Écran de création d'une nouvelle scène rattachée à un chapitre.
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create
*/
@Component({
selector: 'app-scene-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './scene-create.component.html',
styleUrls: ['./scene-create.component.scss']
})
export class SceneCreateComponent implements OnInit, OnDestroy {
form: FormGroup;
campaignId = '';
arcId = '';
chapterId = '';
chapterName = '';
private existingSceneCount = 0;
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: ['']
});
}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.arcId = this.route.snapshot.paramMap.get('arcId')!;
this.chapterId = this.route.snapshot.paramMap.get('chapterId')!;
this.loadLayout();
}
private loadLayout(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
this.chapterName = currentChapter?.name ?? '';
this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0;
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
submit(): void {
if (this.form.invalid) return;
this.campaignService.createScene({
name: this.form.value.name,
description: this.form.value.description,
chapterId: this.chapterId,
order: this.existingSceneCount + 1
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
error: () => console.error('Erreur lors de la création de la scène')
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,228 @@
<div class="edit-page">
<div class="page-header">
<div>
<h1>{{ scene?.name || 'Scène' }}</h1>
<p class="subtitle">Scène</p>
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cette scène">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
</div>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Carte du lieu, portrait des PNJ presents, ambiance visuelle...</small>
</div>
<div class="field">
<label>Titre de la scène *</label>
<input
type="text"
formControlName="name"
placeholder="Ex: Arrivée au village"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label>Description courte *</label>
<textarea
formControlName="description"
placeholder="Résumé en une ou deux phrases de ce qui se passe..."
rows="3">
</textarea>
</div>
<!-- Section : Contexte et ambiance -->
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
<div class="field-row">
<div class="field">
<label>Lieu</label>
<input type="text" formControlName="location" placeholder="Ex: Taverne du Dragon d'Or" />
</div>
<div class="field">
<label>Moment</label>
<input type="text" formControlName="timing" placeholder="Ex: Soir, à la tombée de la nuit" />
</div>
</div>
<div class="field">
<label>Ambiance et atmosphère</label>
<textarea
formControlName="atmosphere"
placeholder="Décrivez l'ambiance générale de la scène (sons, odeurs, lumière, émotions...)"
rows="4">
</textarea>
</div>
</app-expandable-section>
<!-- Section : Narration pour les joueurs -->
<app-expandable-section title="Narration pour les joueurs" icon="📖">
<div class="field">
<textarea
formControlName="playerNarration"
placeholder="Le texte que vous lirez aux joueurs pour planter le décor de cette scène..."
rows="6">
</textarea>
<small class="field-hint">Ce texte peut être lu directement à vos joueurs.</small>
</div>
</app-expandable-section>
<!-- Section : Notes et secrets du MJ (privé) -->
<app-expandable-section title="Notes et secrets du MJ" icon="🔒" variant="private">
<div class="field">
<textarea
formControlName="gmSecretNotes"
placeholder="Informations cachées, indices, éléments secrets que les joueurs ne doivent pas connaître..."
rows="5">
</textarea>
<small class="field-hint">Ces notes sont privées et visibles uniquement par le MJ.</small>
</div>
</app-expandable-section>
<!-- Section : Choix et conséquences -->
<app-expandable-section title="Choix et conséquences" icon="🔀">
<div class="field">
<textarea
formControlName="choicesConsequences"
placeholder="Décrivez les différentes options qui s'offrent aux joueurs et leurs conséquences..."
rows="5">
</textarea>
</div>
</app-expandable-section>
<!-- Section : Branches narratives (graphe intra-chapitre) -->
<app-expandable-section title="Branches narratives" icon="🌿">
<div class="branches-hint" *ngIf="siblingScenes.length === 0">
<small class="field-hint">
💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches.
Créez d'abord d'autres scènes, puis revenez ici pour les connecter.
</small>
</div>
<div class="branches-list" *ngIf="siblingScenes.length > 0">
<div class="branch-item" *ngFor="let branch of branches; let i = index; trackBy: trackByIndex">
<div class="field">
<label>Libellé du choix</label>
<input
type="text"
[value]="branch.label"
(input)="updateBranchLabel(i, $any($event.target).value)"
placeholder="Ex: Si les joueurs attaquent le garde" />
</div>
<div class="field">
<label>Scène de destination *</label>
<select
(change)="updateBranchTarget(i, $any($event.target).value)">
<option value="" [selected]="!branch.targetSceneId">— Choisir une scène —</option>
<option *ngFor="let s of siblingScenes"
[value]="s.id"
[selected]="s.id === branch.targetSceneId">{{ s.name }}</option>
</select>
</div>
<div class="field">
<label>Condition MJ (optionnel)</label>
<input
type="text"
[value]="branch.condition || ''"
(input)="updateBranchCondition(i, $any($event.target).value)"
placeholder="Ex: Jet de Persuasion DD 15 réussi" />
</div>
<button type="button" class="btn-remove-branch" (click)="removeBranch(i)"
title="Supprimer cette branche">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Retirer
</button>
</div>
<button type="button" class="btn-add-branch" (click)="addBranch()">
+ Ajouter une branche
</button>
<small class="field-hint">
Chaque branche représente une "sortie" possible depuis cette scène selon l'action des joueurs.
Les cibles sont limitées aux scènes du même chapitre.
</small>
</div>
</app-expandable-section>
<!-- Section : Combat ou rencontre -->
<app-expandable-section title="Combat ou rencontre" icon="⚔️">
<div class="field">
<label>Difficulté estimée</label>
<input type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
</div>
<div class="field">
<label>Ennemis et créatures</label>
<textarea
formControlName="enemies"
placeholder="Liste des ennemis présents dans cette scène..."
rows="4">
</textarea>
</div>
</app-expandable-section>
<!-- Section : Pages Lore associées (B2 cross-context) -->
<app-expandable-section title="Pages Lore associées" icon="🔗" *ngIf="loreId">
<div class="field">
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page.
</small>
</div>
</app-expandable-section>
<div class="field" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir épingler des pages du Lore à cette scène.
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form>
</div>
<!-- Drawer chat IA (hors .edit-page pour couvrir le viewport à droite) -->
<app-ai-chat-drawer
[campaignId]="campaignId"
entityType="scene"
[entityId]="sceneId"
[isOpen]="chatOpen"
welcomeMessage="Je vois cette scène. Demande-moi d'enrichir son ambiance, sa narration ou ses choix."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,83 @@
.edit-page {
padding: 2.5rem 2rem;
max-width: 760px;
}
// Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
.header-actions {
display: flex;
gap: 0.5rem;
}
}
.edit-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
// Override local : bouton Supprimer poussé à droite (voir _buttons.scss pour la base).
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
}
// Branches narratives : cartes empilées avec libellé / cible / condition / bouton retirer.
.branches-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.branch-item {
position: relative;
padding: 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.btn-add-branch {
align-self: flex-start;
padding: 0.5rem 0.9rem;
background: transparent;
color: #1f2937;
border: 1px dashed #9ca3af;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
&:hover {
background: #f3f4f6;
border-color: #1f2937;
}
}
.btn-remove-branch {
align-self: flex-end;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.7rem;
background: transparent;
color: #b91c1c;
border: 1px solid #fca5a5;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
&:hover {
background: #fef2f2;
}
}

View File

@@ -0,0 +1,228 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Scene, SceneBranch } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { ExpandableSectionComponent } from '../../shared/expandable-section/expandable-section.component';
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
/**
* Écran de détail/modification d'une Scène.
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId
*/
@Component({
selector: 'app-scene-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss']
})
export class SceneEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
readonly chatQuickSuggestions = [
'Propose une ambiance sensorielle immersive pour cette scène',
'Suggère une narration d\'ouverture à lire aux joueurs',
'Imagine 2 choix avec conséquences marquantes'
];
toggleChat(): void { this.chatOpen = !this.chatOpen; }
form: FormGroup;
campaignId = '';
arcId = '';
chapterId = '';
sceneId = '';
scene: Scene | null = null;
availablePages: Page[] = [];
loreId: string | null = null;
relatedPageIds: string[] = [];
illustrationImageIds: string[] = [];
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
siblingScenes: Scene[] = [];
/** Branches narratives (état local mutable, persisté au submit). */
branches: SceneBranch[] = [];
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
// Contexte et ambiance
location: [''],
timing: [''],
atmosphere: [''],
// Narration
playerNarration: [''],
// Secrets MJ
gmSecretNotes: [''],
// Choix
choicesConsequences: [''],
// Combat
combatDifficulty: [''],
enemies: ['']
});
}
ngOnInit(): void {
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
// réutilise le composant quand on navigue entre scènes 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')!;
const newChapterId = pm.get('chapterId')!;
const newSceneId = pm.get('sceneId')!;
if (newSceneId !== this.sceneId ||
newChapterId !== this.chapterId ||
newArcId !== this.arcId ||
newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.arcId = newArcId;
this.chapterId = newChapterId;
this.sceneId = newSceneId;
this.loadAll();
}
});
}
private loadAll(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
chapterScenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
})
).subscribe(({ campaign, allCampaigns, scene, chapterScenes, treeData, pages, loreId }) => {
this.scene = scene;
this.pageTitleService.set(scene.name);
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
this.form.patchValue({
name: scene.name,
description: scene.description ?? '',
location: scene.location ?? '',
timing: scene.timing ?? '',
atmosphere: scene.atmosphere ?? '',
playerNarration: scene.playerNarration ?? '',
gmSecretNotes: scene.gmSecretNotes ?? '',
choicesConsequences: scene.choicesConsequences ?? '',
combatDifficulty: scene.combatDifficulty ?? '',
enemies: scene.enemies ?? ''
});
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
submit(): void {
if (this.form.invalid || !this.scene) return;
this.campaignService.updateScene(this.sceneId, {
name: this.form.value.name,
description: this.form.value.description,
chapterId: this.chapterId,
order: this.scene.order ?? 1,
location: this.form.value.location,
timing: this.form.value.timing,
atmosphere: this.form.value.atmosphere,
playerNarration: this.form.value.playerNarration,
gmSecretNotes: this.form.value.gmSecretNotes,
choicesConsequences: this.form.value.choicesConsequences,
combatDifficulty: this.form.value.combatDifficulty,
enemies: this.form.value.enemies,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
branches: this.branches
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
error: () => console.error('Erreur lors de la sauvegarde')
});
}
delete(): void {
if (!confirm(`Supprimer la scène "${this.scene?.name}" ? Cette action est irréversible.`)) return;
this.campaignService.deleteScene(this.sceneId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]);
}
// ─────────────── Gestion des branches narratives ───────────────
trackByIndex = (i: number) => i;
addBranch(): void {
this.branches.push({ label: '', targetSceneId: '', condition: '' });
}
removeBranch(index: number): void {
this.branches.splice(index, 1);
}
updateBranchLabel(index: number, value: string): void {
this.branches[index].label = value;
}
updateBranchTarget(index: number, value: string): void {
this.branches[index].targetSceneId = value;
}
updateBranchCondition(index: number, value: string): void {
this.branches[index].condition = value;
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,90 @@
<div class="view-page" *ngIf="scene">
<header class="view-header">
<div>
<h1>{{ scene.name }}</h1>
<p class="view-subtitle">Scène</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
</div>
</header>
<!-- Illustrations -->
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []"></app-image-gallery>
</section>
<!-- Description courte -->
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📝</span> Description</h2>
<p class="view-section-body" *ngIf="scene.description?.trim(); else emptyDesc">{{ scene.description }}</p>
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<!-- Contexte et ambiance -->
<div class="view-row" *ngIf="scene.location?.trim() || scene.timing?.trim()">
<section class="view-section" *ngIf="scene.location?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">📍</span> Lieu</h2>
<p class="view-section-body">{{ scene.location }}</p>
</section>
<section class="view-section" *ngIf="scene.timing?.trim()">
<h2 class="view-section-title"><span class="view-section-icon"></span> Moment</h2>
<p class="view-section-body">{{ scene.timing }}</p>
</section>
</div>
<section class="view-section" *ngIf="scene.atmosphere?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">🌫️</span> Ambiance et atmosphère</h2>
<p class="view-section-body">{{ scene.atmosphere }}</p>
</section>
<!-- Narration pour les joueurs -->
<section class="view-section" *ngIf="scene.playerNarration?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Narration pour les joueurs</h2>
<p class="view-section-body">{{ scene.playerNarration }}</p>
</section>
<!-- Choix et conséquences -->
<section class="view-section" *ngIf="scene.choicesConsequences?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">🔀</span> Choix et conséquences</h2>
<p class="view-section-body">{{ scene.choicesConsequences }}</p>
</section>
<!-- Combat ou rencontre -->
<ng-container *ngIf="scene.combatDifficulty?.trim() || scene.enemies?.trim()">
<section class="view-section" *ngIf="scene.combatDifficulty?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2>
<p class="view-section-body">{{ scene.combatDifficulty }}</p>
</section>
<section class="view-section" *ngIf="scene.enemies?.trim()">
<h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2>
<p class="view-section-body">{{ scene.enemies }}</p>
</section>
</ng-container>
<!-- Notes et secrets du MJ (privé) -->
<section class="view-section view-section--private" *ngIf="scene.gmSecretNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes et secrets du MJ
</h2>
<p class="view-section-body">{{ scene.gmSecretNotes }}</p>
</section>
<!-- Pages Lore liées -->
<section class="view-section" *ngIf="loreId && (scene.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of scene.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
</div>

View File

@@ -0,0 +1 @@
// Styles partagés via styles/_view.scss

View File

@@ -0,0 +1,116 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Scene } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
/**
* Écran de consultation d'une Scène (lecture seule).
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId
*/
@Component({
selector: 'app-scene-view',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './scene-view.component.html',
styleUrls: ['./scene-view.component.scss']
})
export class SceneViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
campaignId = '';
arcId = '';
chapterId = '';
sceneId = '';
scene: Scene | null = null;
loreId: string | null = null;
availablePages: Page[] = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!;
const newSceneId = pm.get('sceneId')!;
if (newSceneId !== this.sceneId ||
newChapterId !== this.chapterId ||
newArcId !== this.arcId ||
newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.arcId = newArcId;
this.chapterId = newChapterId;
this.sceneId = newSceneId;
this.load();
}
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
})
).subscribe(({ campaign, allCampaigns, scene, treeData, pages, loreId }) => {
this.scene = scene;
this.loreId = loreId;
this.availablePages = pages;
this.pageTitleService.set(scene.name);
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
titleOfRelated(pageId: string): string {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}
editMode(): void {
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId,
'chapters', this.chapterId, 'scenes', this.sceneId, 'edit'
]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,54 @@
<div class="modal-backdrop" (click)="onCancel()">
<div class="modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Créer un nouveau Lore</h2>
<button class="btn-close" (click)="onCancel()">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</div>
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label>Nom de l'univers *</label>
<input
type="text"
formControlName="name"
placeholder="Ex: Royaume des Ombres, Cyberpunk 2157..."
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label>Description</label>
<textarea
formControlName="description"
placeholder="Décrivez brièvement votre univers, son ambiance, son genre..."
rows="5"
></textarea>
</div>
<div class="info-box">
<p><strong>💡 Astuce :</strong> Votre lore sera créé avec quelques templates par défaut :</p>
<ul>
<li>PNJ - Pour vos personnages</li>
<li>Lieu - Pour vos villes et régions</li>
<li>Faction - Pour vos organisations</li>
<li>Objet - Pour vos artefacts</li>
</ul>
<p class="info-footer">Vous pourrez créer vos propres templates ensuite !</p>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
Créer le lore
</button>
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,123 @@
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: #111827;
border: 1px solid #1f2937;
border-radius: 16px;
padding: 2rem;
width: 100%;
max-width: 600px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
h2 { color: white; font-size: 1.25rem; font-weight: 600; }
}
.btn-close {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
border-radius: 6px;
display: flex;
transition: color 0.2s;
&:hover { color: white; }
}
.field {
margin-bottom: 1.5rem;
label {
display: block;
font-size: 0.875rem;
color: #d1d5db;
margin-bottom: 0.5rem;
}
input, textarea {
width: 100%;
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 0.75rem 1rem;
color: white;
font-size: 0.9rem;
outline: none;
resize: none;
transition: border-color 0.2s;
&::placeholder { color: #4b5563; }
&:focus { border-color: #6c63ff; }
&.invalid { border-color: #ef4444; }
}
}
.info-box {
background: #1f2937;
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 2rem;
font-size: 0.875rem;
color: #9ca3af;
line-height: 1.6;
ul {
margin: 0.5rem 0 0.5rem 1.25rem;
li { margin-bottom: 0.15rem; }
}
.info-footer { color: #6b7280; margin-top: 0.5rem; }
}
.modal-actions {
display: flex;
gap: 1rem;
}
.btn-primary {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
.btn-secondary {
padding: 0.75rem 1.5rem;
background: #1f2937;
color: #d1d5db;
border: 1px solid #374151;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #374151; }
}

View File

@@ -0,0 +1,37 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
@Component({
selector: 'app-lore-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './lore-create.component.html',
styleUrls: ['./lore-create.component.scss']
})
export class LoreCreateComponent {
@Output() close = new EventEmitter<void>();
@Output() created = new EventEmitter<{ name: string; description: string }>();
readonly BookCopy = BookCopy;
readonly X = X;
form: FormGroup;
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
name: ['', Validators.required],
description: ['']
});
}
submit(): void {
if (this.form.invalid) return;
this.created.emit(this.form.value);
}
onCancel(): void {
this.close.emit();
}
}

View File

@@ -0,0 +1,70 @@
<div class="lore-detail" *ngIf="lore">
<!-- ============ Header : mode lecture ============ -->
<div class="detail-header" *ngIf="!editing">
<div class="header-texts">
<h1>{{ lore.name }}</h1>
<p class="description">{{ lore.description }}</p>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="startEdit()" title="Modifier le Lore">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteLore()" title="Supprimer le Lore">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
<!-- ============ Header : mode édition inline ============ -->
<div class="detail-header edit-mode" *ngIf="editing">
<div class="field">
<label>Nom</label>
<input type="text" [(ngModel)]="editName" name="editName" required />
</div>
<div class="field">
<label>Description</label>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancelEdit()">
Annuler
</button>
</div>
</div>
<!-- ============ Grille des dossiers racine ============ -->
<div class="nodes-section">
<div class="section-header">
<h2>Dossiers</h2>
<button class="btn-add" (click)="navigateToCreateNode()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau dossier
</button>
</div>
<!-- rootNodes : uniquement les dossiers racine (pas les sous-dossiers,
qui sont visibles dans l'arbre de la sidebar). -->
<div class="nodes-grid" *ngIf="rootNodes.length > 0">
<div class="node-card" *ngFor="let node of rootNodes" (click)="navigateToFolder(node.id!)">
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ node.name }}</span>
</div>
</div>
<div class="empty-state" *ngIf="rootNodes.length === 0">
<lucide-icon [img]="Folder" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun dossier pour le moment.</p>
<button class="btn-add-first" (click)="navigateToCreateNode()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier dossier
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,160 @@
.lore-detail {
padding: 2.5rem 2rem;
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 2.5rem;
.header-texts { flex: 1; min-width: 0; }
h1 {
font-size: 1.75rem;
font-weight: 700;
color: white;
margin-bottom: 0.5rem;
}
.description {
color: #6b7280;
font-size: 0.95rem;
line-height: 1.6;
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
// Variante mode édition : input / textarea en colonne.
&.edit-mode {
flex-direction: column;
align-items: stretch;
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
label { color: #9ca3af; font-size: 0.8rem; font-weight: 500; }
input, textarea {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 8px;
color: white;
padding: 0.6rem 0.85rem;
font-size: 0.9rem;
resize: vertical;
font-family: inherit;
&:focus { outline: none; border-color: #6c63ff; }
}
}
.header-actions { justify-content: flex-end; }
}
}
// Boutons partagés du header.
.btn-primary, .btn-secondary, .btn-danger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } }
.btn-secondary { background: #1f2937; color: #d1d5db; &:hover:not(:disabled) { background: #374151; } }
.btn-danger { background: #3a1e1e; color: #f87171; &:hover:not(:disabled) { background: #5a2e2e; } }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; }
}
.btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #5b52e0; }
}
.nodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
.node-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover { border-color: #6c63ff; transform: translateY(-2px); }
.node-icon { color: #6c63ff; }
.node-name { color: white; font-size: 0.9rem; font-weight: 600; }
.node-type { color: #6b7280; font-size: 0.75rem; }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 2rem;
color: #6b7280;
.empty-icon { color: #374151; }
p { font-size: 0.95rem; }
}
.btn-add-first {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.25rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #5b52e0; }
}

View File

@@ -0,0 +1,137 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Lore, LoreNode } from '../../services/lore.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
@Component({
selector: 'app-lore-detail',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './lore-detail.component.html',
styleUrls: ['./lore-detail.component.scss']
})
export class LoreDetailComponent implements OnInit, OnDestroy {
readonly Folder = Folder;
readonly Plus = Plus;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
lore: Lore | null = null;
/** Tous les dossiers du Lore (racines + enfants). */
allNodes: LoreNode[] = [];
/** Uniquement les dossiers racine — seuls affichés dans la grille principale. */
rootNodes: LoreNode[] = [];
/** Mode édition inline du header (nom + description). */
editing = false;
editName = '';
editDescription = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
// On s'abonne à paramMap (pas snapshot) pour recharger quand on switche
// d'un Lore à l'autre via la liste globale de la sidebar — Angular réutilise
// le même composant et ngOnInit ne se relance pas tout seul.
this.route.paramMap.subscribe(pm => {
const id = pm.get('id');
if (id && id !== this.lore?.id) {
this.load(id);
}
});
}
private load(id: string): void {
loadLoreSidebarData(id, this.loreService, this.templateService, this.pageService).subscribe(data => {
this.lore = data.lore;
this.allNodes = data.nodes;
// Bug d'affichage corrigé : on ne liste ici que les dossiers racine
// (les sous-dossiers apparaissent dans l'arbre de la sidebar quand on
// ouvre leur parent). parentId null OU chaîne vide = racine.
this.rootNodes = data.nodes.filter(n => !n.parentId);
this.layoutService.show(buildLoreSidebarConfig(data));
this.pageTitleService.set(data.lore.name);
// On sort du mode édition si on change de Lore en cours d'édition.
this.editing = false;
});
}
navigateToCreateNode(): void {
this.router.navigate(['/lore', this.lore!.id, 'nodes', 'create']);
}
navigateToFolder(nodeId: string): void {
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId, 'edit']);
}
// ─────────────── Édition / suppression du Lore ───────────────
startEdit(): void {
if (!this.lore) return;
this.editName = this.lore.name;
this.editDescription = this.lore.description ?? '';
this.editing = true;
}
cancelEdit(): void {
this.editing = false;
}
saveEdit(): void {
if (!this.lore || !this.editName.trim()) return;
this.loreService.updateLore(this.lore.id!, {
name: this.editName.trim(),
description: this.editDescription
}).subscribe({
next: (updated) => {
this.lore = updated;
this.editing = false;
// Recharge la sidebar pour que le titre soit à jour.
this.load(updated.id!);
},
error: () => console.error('Erreur lors de la mise à jour du Lore')
});
}
/**
* Suppression protégée : refus si le Lore contient encore des dossiers
* ou des pages. Protège contre un clic accidentel sur des données
* construites longuement. Logique côté frontend (pas d'appel HTTP
* supplémentaire) car les données sont déjà chargées.
*/
deleteLore(): void {
if (!this.lore) return;
if (this.allNodes.length > 0) {
alert(
`Impossible de supprimer "${this.lore.name}" : il contient encore ${this.allNodes.length} dossier(s).\n` +
`Videz le Lore (dossiers et pages) avant de le supprimer.`
);
return;
}
if (!confirm(`Supprimer définitivement le Lore "${this.lore.name}" ?`)) return;
this.loreService.deleteLore(this.lore.id!).subscribe({
next: () => this.router.navigate(['/lore']),
error: () => console.error('Erreur lors de la suppression du Lore')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,53 @@
import {
Folder,
Users, Swords, MapPin, Shield, Crown, Skull, Gem,
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData
} from 'lucide-angular';
/**
* Registre partagé d'icônes disponibles pour les dossiers (LoreNode).
*
* Utilisé à la fois par :
* - l'écran de création de dossier (grille de sélection)
* - la sidebar (résolution `iconKey → LucideIconData` pour afficher l'icône)
*
* Pourquoi factoriser ? Avant : deux sources de vérité risqueraient de
* diverger (ex: ajout d'une icône dans l'un sans l'autre).
*/
export interface IconOption {
key: string;
icon: LucideIconData;
}
export const LORE_ICON_OPTIONS: IconOption[] = [
{ key: 'users', icon: Users },
{ key: 'swords', icon: Swords },
{ key: 'map-pin', icon: MapPin },
{ key: 'shield', icon: Shield },
{ key: 'crown', icon: Crown },
{ key: 'skull', icon: Skull },
{ key: 'gem', icon: Gem },
{ key: 'book-open', icon: BookOpen },
{ key: 'scroll', icon: Scroll },
{ key: 'wand', icon: Wand2 },
{ key: 'sparkles', icon: Sparkles },
{ key: 'tree', icon: TreePine },
{ key: 'mountain', icon: Mountain },
{ key: 'ship', icon: Ship },
{ key: 'flame', icon: Flame },
{ key: 'star', icon: Star },
{ key: 'moon', icon: Moon },
{ key: 'key', icon: Key },
{ key: 'globe', icon: Globe },
{ key: 'compass', icon: Compass },
];
/** Icône par défaut pour un dossier sans icône. */
export const DEFAULT_FOLDER_ICON: LucideIconData = Folder;
/** Résout une clé d'icône en LucideIconData. Fallback : icône dossier par défaut. */
export function resolveIcon(key: string | null | undefined): LucideIconData {
if (!key) return DEFAULT_FOLDER_ICON;
return LORE_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_FOLDER_ICON;
}

View File

@@ -0,0 +1,71 @@
<div class="node-create-page">
<div class="page-header">
<h1>Créer un nouveau dossier</h1>
<p class="subtitle">Les dossiers permettent d'organiser vos pages par catégorie</p>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="node-form">
<div class="field">
<label>Nom du dossier *</label>
<input
type="text"
formControlName="name"
placeholder="Ex: Personnages, Créatures..."
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label>Dossier parent <span class="optional">(optionnel)</span></label>
<select formControlName="parentId">
<option value="">— Racine du Lore —</option>
<option *ngFor="let parent of availableParents" [value]="parent.id">{{ parent.name }}</option>
</select>
<p class="hint">Laissez vide pour créer un dossier à la racine du lore</p>
</div>
<div class="field">
<label>Icône</label>
<div class="icon-grid">
<button
type="button"
class="icon-btn"
*ngFor="let option of iconOptions"
[class.selected]="selectedIcon === option.key"
(click)="selectIcon(option.key)">
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
</button>
</div>
</div>
<div class="field">
<label>Description <span class="optional">(optionnel)</span></label>
<textarea
formControlName="description"
placeholder="Décrivez le type de contenu que ce dossier contiendra..."
rows="4">
</textarea>
</div>
<div class="field">
<label>Adresse</label>
<input
type="text"
formControlName="address"
placeholder="nom-du-dossier"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>
Créer le dossier
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,116 @@
.node-create-page {
padding: 2.5rem 2rem;
max-width: 640px;
}
.page-header {
margin-bottom: 2.5rem;
h1 { font-size: 1.5rem; font-weight: 700; color: white; margin-bottom: 0.4rem; }
.subtitle { color: #6b7280; font-size: 0.9rem; }
}
.node-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
label {
font-size: 0.875rem;
color: #d1d5db;
font-weight: 500;
}
.optional { color: #6b7280; font-weight: 400; }
input, textarea, select {
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 0.75rem 1rem;
color: white;
font-size: 0.9rem;
outline: none;
resize: none;
transition: border-color 0.2s;
&::placeholder { color: #4b5563; }
&:focus { border-color: #6c63ff; }
&.invalid { border-color: #ef4444; }
}
}
.icon-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 0.75rem;
}
.icon-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
&:hover { background: #374151; color: white; }
&.selected {
background: #1e1b4b;
border-color: #6c63ff;
color: #a5b4fc;
}
}
.form-actions {
display: flex;
gap: 1rem;
padding-top: 0.5rem;
}
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
.btn-secondary {
padding: 0.75rem 1.5rem;
background: #1f2937;
color: #d1d5db;
border: 1px solid #374151;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #374151; }
}

View File

@@ -0,0 +1,104 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, LucideIconData } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { LoreNode } from '../../services/lore.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
@Component({
selector: 'app-lore-node-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './lore-node-create.component.html',
styleUrls: ['./lore-node-create.component.scss']
})
export class LoreNodeCreateComponent implements OnInit, OnDestroy {
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
form: FormGroup;
loreId = '';
/** parentId optionnel depuis la route — si présent, pré-remplit le champ. */
preselectedParentId: string | null = null;
/** Liste des dossiers existants pour le select "Dossier parent". */
availableParents: LoreNode[] = [];
selectedIcon = this.iconOptions[0].key;
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
address: ['', Validators.required],
parentId: [''] // '' = racine
});
// Auto-génère l'adresse depuis le nom
this.form.get('name')!.valueChanges.subscribe(name => {
const slug = (name as string).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
this.form.get('address')!.setValue(slug, { emitEvent: false });
});
}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
this.preselectedParentId = this.route.snapshot.paramMap.get('parentId');
this.loadLayout();
}
private loadLayout(): void {
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService)
.subscribe(data => {
this.availableParents = data.nodes;
if (this.preselectedParentId) {
this.form.patchValue({ parentId: this.preselectedParentId });
}
this.layoutService.show(buildLoreSidebarConfig(data));
});
}
selectIcon(key: string): void {
this.selectedIcon = key;
}
getIcon(key: string): LucideIconData {
return this.iconOptions.find(o => o.key === key)!.icon;
}
submit(): void {
if (this.form.invalid) return;
const raw = this.form.value;
this.loreService.createLoreNode({
name: raw.name,
description: raw.description,
address: raw.address,
icon: this.selectedIcon,
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null,
loreId: this.loreId
}).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la création du dossier')
});
}
cancel(): void {
this.router.navigate(['/lore', this.loreId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,67 @@
<div class="page" *ngIf="node">
<header class="page-header">
<div>
<h1>Éditer le dossier</h1>
<p class="subtitle">
{{ childFolderCount }} sous-dossier(s) · {{ pageCount }} page(s)
</p>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button
type="button"
class="btn-danger"
[disabled]="!canDelete"
[title]="canDelete ? 'Supprimer le dossier' : 'Impossible : le dossier contient des éléments'"
(click)="delete()">
Supprimer
</button>
<button
type="submit"
class="btn-primary"
[disabled]="form.invalid"
(click)="save()">
Sauvegarder
</button>
</div>
</header>
<form [formGroup]="form" class="edit-form">
<div class="field">
<label>Nom du dossier *</label>
<input type="text" formControlName="name" />
</div>
<div class="field">
<label>Dossier parent <span class="optional">(optionnel)</span></label>
<select formControlName="parentId">
<option value="">— Racine du Lore —</option>
<option *ngFor="let parent of availableParents" [value]="parent.id">{{ parent.name }}</option>
</select>
<p class="hint">Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)</p>
</div>
<div class="field">
<label>Icône</label>
<div class="icon-grid">
<button
type="button"
class="icon-btn"
*ngFor="let option of iconOptions"
[class.selected]="selectedIcon === option.key"
(click)="selectIcon(option.key)">
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
</button>
</div>
</div>
<div class="info-box" *ngIf="!canDelete">
⚠️ Pour supprimer ce dossier, videz-le d'abord : déplacez ou supprimez ses
{{ childFolderCount }} sous-dossier(s) et ses {{ pageCount }} page(s).
</div>
</form>
</div>

View File

@@ -0,0 +1,141 @@
.page {
padding: 2rem 3rem;
max-width: 860px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
h1 {
font-size: 1.6rem;
font-weight: 700;
color: white;
margin: 0 0 0.25rem;
}
.subtitle {
color: #9ca3af;
font-size: 0.85rem;
margin: 0;
}
.header-actions {
display: flex;
gap: 0.6rem;
}
}
.edit-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.4rem;
label {
font-size: 0.82rem;
font-weight: 500;
color: #d1d5db;
}
.optional { color: #6b7280; font-weight: 400; }
input, select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.7rem 0.9rem;
border-radius: 6px;
font-size: 0.9rem;
&:focus {
outline: none;
border-color: #6c63ff;
}
}
}
.hint {
font-size: 0.76rem;
color: #6b7280;
margin: 0;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 0.5rem;
max-width: 500px;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 6px;
color: #d1d5db;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
&:hover { border-color: #3a3a55; background: #20203a; }
&.selected {
border-color: #6c63ff;
background: #1e1c3a;
color: #a5b4fc;
}
}
.info-box {
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-left: 3px solid #fca5a5;
padding: 0.75rem 1rem;
border-radius: 6px;
color: #d1d5db;
font-size: 0.85rem;
line-height: 1.5;
}
.btn-primary, .btn-secondary, .btn-danger {
padding: 0.6rem 1.1rem;
border: none;
border-radius: 6px;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary {
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5a52e0; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-secondary {
background: #2a2a3d;
color: #d1d5db;
&:hover { background: #363650; }
}
.btn-danger {
background: #3f1f1f;
color: #fca5a5;
&:hover:not(:disabled) { background: #5a2a2a; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}

View File

@@ -0,0 +1,163 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, LucideIconData } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model';
import { Page } from '../../services/page.model';
import {
loadLoreSidebarData,
buildLoreSidebarConfig,
collectDescendantIds
} from '../lore-sidebar.helper';
import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
/**
* Écran d'édition d'un dossier (LoreNode) existant.
*
* Fonctionnalités :
* - Renommer
* - Changer l'icône
* - Déplacer dans un autre dossier parent (ou vers la racine)
* - Supprimer (refusé si le dossier contient des sous-dossiers ou des pages)
*
* Prévention des cycles : le select "Dossier parent" exclut le dossier en cours
* d'édition ET tous ses descendants — sinon l'arbre deviendrait circulaire.
*/
@Component({
selector: 'app-lore-node-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './lore-node-edit.component.html',
styleUrls: ['./lore-node-edit.component.scss']
})
export class LoreNodeEditComponent implements OnInit, OnDestroy {
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
form: FormGroup;
loreId = '';
folderId = '';
node: LoreNode | null = null;
/** Dossiers proposables comme parent (tous sauf soi-même + descendants). */
availableParents: LoreNode[] = [];
/** Nombre de sous-dossiers directs (pour affichage + validation de suppression). */
childFolderCount = 0;
/** Nombre de pages dans ce dossier (pour affichage + validation de suppression). */
pageCount = 0;
selectedIcon: string | null = null;
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
parentId: ['']
});
}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
this.folderId = this.route.snapshot.paramMap.get('folderId')!;
// Réagir aux changements de :folderId (navigation entre dossiers dans la sidebar
// sans démonter le composant).
this.route.paramMap.subscribe(pm => {
const newId = pm.get('folderId')!;
if (newId !== this.folderId) {
this.folderId = newId;
this.load();
}
});
this.load();
}
private load(): void {
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
node: this.loreService.getLoreNodeById(this.folderId)
}).subscribe(({ sidebar, node }) => {
this.layoutService.show(buildLoreSidebarConfig(sidebar));
this.hydrate(node, sidebar.nodes, sidebar.pages);
});
}
private hydrate(node: LoreNode, allNodes: LoreNode[], allPages: Page[]): void {
this.node = node;
this.selectedIcon = node.icon ?? null;
this.form.patchValue({
name: node.name,
parentId: node.parentId ?? ''
});
// Liste des parents autorisés : tous les dossiers sauf soi + descendants.
const excluded = collectDescendantIds(node.id!, allNodes);
this.availableParents = allNodes.filter(n => !excluded.has(n.id!));
// Stats pour affichage + règle de suppression.
this.childFolderCount = allNodes.filter(n => n.parentId === node.id).length;
this.pageCount = allPages.filter(p => p.nodeId === node.id).length;
this.pageTitleService.set(node.name);
}
selectIcon(key: string): void {
this.selectedIcon = key;
}
save(): void {
if (this.form.invalid || !this.node) return;
const raw = this.form.value;
const updated: LoreNode = {
...this.node,
name: raw.name,
icon: this.selectedIcon,
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null
};
this.loreService.updateLoreNode(this.folderId, updated).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la sauvegarde du dossier')
});
}
get canDelete(): boolean {
return this.childFolderCount === 0 && this.pageCount === 0;
}
delete(): void {
if (!this.canDelete || !this.node) return;
if (!confirm(`Supprimer le dossier "${this.node.name}" ?`)) return;
this.loreService.deleteLoreNode(this.folderId).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la suppression du dossier')
});
}
cancel(): void {
this.router.navigate(['/lore', this.loreId]);
}
/** Retourne l'icône lucide à afficher dans l'aperçu du bouton "Sauvegarder". */
getIcon(key: string | null): LucideIconData | null {
if (!key) return null;
return this.iconOptions.find(o => o.key === key)?.icon ?? null;
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,165 @@
import { forkJoin, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { LoreService } from '../services/lore.service';
import { TemplateService } from '../services/template.service';
import { PageService } from '../services/page.service';
import { Lore, LoreNode } from '../services/lore.model';
import { Template } from '../services/template.model';
import { Page } from '../services/page.model';
import {
SecondarySidebarConfig, TreeItem, GlobalItem, BottomPanel
} from '../services/layout.service';
export interface LoreSidebarData {
lore: Lore;
allLores: Lore[];
nodes: LoreNode[];
templates: Template[];
pages: Page[];
}
/**
* Charge toutes les données nécessaires à la sidebar d'un Lore en parallèle.
* Centralise le pattern commun aux écrans lore-detail, lore-node-create,
* template-create, template-edit, page-create, page-edit.
*/
export function loadLoreSidebarData(
loreId: string,
loreService: LoreService,
templateService: TemplateService,
pageService: PageService
): Observable<LoreSidebarData> {
return forkJoin({
lore: loreService.getLoreById(loreId),
allLores: loreService.getAllLores(),
nodes: loreService.getLoreNodes(loreId),
templates: templateService.getByLoreId(loreId),
pages: pageService.getByLoreId(loreId)
}).pipe(
map(data => data as LoreSidebarData)
);
}
/**
* Construit la config complète de la SecondarySidebar pour un Lore, incluant
* le panneau "Templates" en bas.
*/
export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarConfig {
const { lore, allLores, nodes, templates, pages } = data;
// Regroupe les pages par nodeId et les sous-dossiers par parentId pour un accès O(1).
const pagesByNode = new Map<string, Page[]>();
for (const p of pages) {
const bucket = pagesByNode.get(p.nodeId) ?? [];
bucket.push(p);
pagesByNode.set(p.nodeId, bucket);
}
const childrenByParent = new Map<string, LoreNode[]>();
for (const n of nodes) {
const parentKey = n.parentId ?? '__root__';
const bucket = childrenByParent.get(parentKey) ?? [];
bucket.push(n);
childrenByParent.set(parentKey, bucket);
}
/**
* Construit récursivement le TreeItem d'un dossier :
* ses sous-dossiers, puis ses pages, puis les actions "+ Nouveau dossier" et "+ Nouvelle page".
*/
const buildFolderItem = (node: LoreNode): TreeItem => {
const subFolders = childrenByParent.get(node.id!) ?? [];
const nodePages = pagesByNode.get(node.id!) ?? [];
const children: TreeItem[] = [
...subFolders.map(buildFolderItem),
...nodePages.map(p => ({
id: `page-${p.id}`,
label: p.title,
route: `/lore/${lore.id}/pages/${p.id}`
})),
{
id: `create-folder-${node.id}`,
label: '+ Nouveau dossier',
isAction: true,
route: `/lore/${lore.id}/folders/${node.id}/create`
},
{
id: `create-page-${node.id}`,
label: '+ Nouvelle page',
isAction: true,
route: `/lore/${lore.id}/nodes/${node.id}/pages/create`
}
];
// IDs préfixés par type — chaque entité a sa propre séquence IDENTITY en base,
// donc node.id=1 et page.id=1 peuvent coexister et collisionner dans le
// Set<string> global de LayoutService.expanded.
return {
id: `folder-${node.id}`,
label: node.name,
iconKey: node.icon ?? undefined,
route: `/lore/${lore.id}/folders/${node.id}/edit`,
meta: nodePages.length > 0 ? String(nodePages.length) : undefined,
children
};
};
// L'arbre démarre aux dossiers racine (parentId nul ou vide).
const rootFolders = childrenByParent.get('__root__') ?? [];
const treeItems: TreeItem[] = rootFolders.map(buildFolderItem);
const globalItems: GlobalItem[] = allLores.map(l => ({
id: l.id!, name: l.name, route: `/lore/${l.id}`
}));
const templatesPanel: BottomPanel = {
id: 'templates',
title: 'Templates',
initiallyOpen: true,
items: [
...templates.map(t => ({
id: t.id!,
label: t.name,
meta: `${t.fieldCount ?? t.fields.length} champs`,
route: `/lore/${lore.id}/templates/${t.id}`
})),
{
id: 'create-template',
label: '+ Nouveau template',
isAction: true,
route: `/lore/${lore.id}/templates/create`
}
]
};
return {
title: lore.name,
items: treeItems,
createActions: [
{ id: 'create-node', label: '+ Dossier', variant: 'primary', route: `/lore/${lore.id}/nodes/create` },
{ id: 'create-page', label: '+ Page', variant: 'secondary', route: `/lore/${lore.id}/pages/create` }
],
globalItems,
globalBackLabel: 'Tous les lores',
globalBackRoute: '/lore',
bottomPanel: templatesPanel
};
}
/**
* Retourne l'ensemble des IDs de `rootId` et de tous ses descendants (sous-dossiers).
* Utilisé pour empêcher de choisir comme parent un dossier qui serait soi-même
* ou un de ses descendants (ce qui créerait un cycle dans l'arbre).
*/
export function collectDescendantIds(rootId: string, allNodes: LoreNode[]): Set<string> {
const ids = new Set<string>([rootId]);
let grew = true;
while (grew) {
grew = false;
for (const n of allNodes) {
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id!)) {
ids.add(n.id!);
grew = true;
}
}
}
return ids;
}

View File

@@ -0,0 +1,42 @@
<div class="lore-page">
<div class="lore-hero">
<lucide-icon [img]="BookOpen" [size]="56" class="hero-icon"></lucide-icon>
<h1>Vos Univers</h1>
<p class="hero-subtitle">Sélectionnez un lore existant ou créez un nouvel univers</p>
</div>
<div class="lore-grid">
<div class="lore-card" *ngFor="let lore of lores" (click)="navigateToDetail(lore.id!)">
<div class="card-header">
<lucide-icon [img]="Folder" [size]="20" class="card-icon"></lucide-icon>
<span class="card-date">Il y a 2h</span>
</div>
<h2>{{ lore.name }}</h2>
<p class="card-description">{{ lore.description }}</p>
<div class="card-stats">
<span>📄 {{ lore.pageCount || 0 }} pages</span>
<span>🌳 {{ lore.nodeCount || 0 }} dossiers</span>
</div>
</div>
<div class="lore-card card-new" (click)="openCreateModal()">
<div class="new-icon">
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
</div>
<h2>Nouveau Lore</h2>
<p class="card-description">Créez un nouvel univers</p>
</div>
</div>
<p class="tip">💡 Astuce : Utilisez les templates pour structurer votre univers de manière cohérente</p>
</div>
<app-lore-create
*ngIf="showCreateModal"
(close)="onModalClose()"
(created)="onLoreCreated($event)">
</app-lore-create>

View File

@@ -0,0 +1,102 @@
.lore-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 2rem;
min-height: 100%;
}
.lore-hero {
text-align: center;
margin-bottom: 3rem;
.hero-icon { display: block; margin-bottom: 1rem; color: #6c63ff; }
h1 { font-size: 2rem; font-weight: 700; color: white; margin-bottom: 0.5rem; }
.hero-subtitle { color: #6b7280; font-size: 0.95rem; }
}
.lore-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
width: 100%;
max-width: 900px;
}
.lore-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1.5rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover {
border-color: #6c63ff;
transform: translateY(-3px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.card-icon { font-size: 1.25rem; color: #6c63ff; }
.card-date { font-size: 0.75rem; color: #6b7280; }
h2 { color: white; font-size: 1.1rem; margin-bottom: 0.5rem; }
.card-description {
color: #6b7280;
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 1rem;
// Tronque à 3 lignes avec "…" : évite qu'une description longue étire
// la carte et par ricochet la carte "Nouveau Lore" alignée sur sa hauteur.
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.card-stats {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: #4b5563;
}
}
.card-new {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-style: dashed;
border-color: #374151;
text-align: center;
.new-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: #1f2937;
color: #6c63ff;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
}
}
.tip {
margin-top: 3rem;
font-size: 0.8rem;
color: #4b5563;
}

View File

@@ -0,0 +1,72 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
import { LoreService } from '../services/lore.service';
import { Lore } from '../services/lore.model';
import { LoreCreateComponent } from './lore-create/lore-create.component';
@Component({
selector: 'app-lore',
standalone: true,
imports: [CommonModule, LucideAngularModule, LoreCreateComponent],
templateUrl: './lore.component.html',
styleUrls: ['./lore.component.scss']
})
export class LoreComponent implements OnInit {
lores: Lore[] = [];
loading = true;
error = false;
readonly BookOpen = BookOpen;
readonly Folder = Folder;
readonly Plus = Plus;
showCreateModal = false;
constructor(
private loreService: LoreService,
private router: Router
) {}
ngOnInit(): void {
this.loadLores();
}
loadLores(): void {
this.loreService.getAllLores().subscribe({
next: (data) => {
this.lores = data;
this.loading = false;
},
error: () => {
this.error = true;
this.loading = false;
}
});
}
openCreateModal(): void {
this.showCreateModal = true;
}
onModalClose(): void {
this.showCreateModal = false;
}
onLoreCreated(data: { name: string; description: string }): void {
this.loreService.createLore(data).subscribe({
next: () => {
this.showCreateModal = false;
this.loadLores();
},
error: () => {
console.error('Erreur lors de la création du Lore');
}
});
}
navigateToDetail(id: string): void {
this.router.navigate(['/lore', id]);
}
}

View File

@@ -0,0 +1,88 @@
<div class="page">
<header class="page-header">
<h1>Créer une nouvelle Page</h1>
<p class="subtitle">Créez une page à partir d'un template existant</p>
</header>
<form [formGroup]="form" (ngSubmit)="submit()" class="page-form">
<!-- Titre -->
<div class="field">
<label>Titre de la page *</label>
<input type="text" formControlName="title" placeholder="Ex: Maître Eldrin, La Cité d'Argent..." />
</div>
<!-- Template -->
<div class="field">
<label>Template *</label>
<div class="templates-grid" *ngIf="templates.length; else emptyTemplates">
<button
type="button"
class="template-card"
*ngFor="let t of templates"
[class.selected]="selectedTemplateId === t.id"
(click)="selectTemplate(t)">
<div class="template-card-head">
<lucide-icon [img]="FileText" [size]="16"></lucide-icon>
<span class="template-name">{{ t.name }}</span>
</div>
<p class="template-description">{{ t.description || '—' }}</p>
</button>
</div>
<ng-template #emptyTemplates>
<p class="empty-hint">
Aucun template défini pour ce Lore.
<a [routerLink]="['/lore', loreId, 'templates', 'create']">Créer un template</a> d'abord.
</p>
</ng-template>
</div>
<!-- Dossier de destination -->
<div class="field">
<label>Dossier de destination *</label>
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
<option value="" disabled>Sélectionnez un dossier</option>
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
</select>
<p class="hint">La page sera créée dans ce dossier</p>
</div>
<!-- Aide contextuelle -->
<div class="info-box">
💡 Option 1 : <strong>Créer la page</strong> vide, puis remplir les champs manuellement.<br>
💡 Option 2 : <strong>Créer avec l'IA</strong> pour dialoguer avec un assistant qui pré-remplira les champs.
</div>
<!-- Erreur wizard (parsing &lt;values&gt; ou échec HTTP) -->
<div class="wizard-error" *ngIf="wizardError" role="alert">{{ wizardError }}</div>
<!-- Actions -->
<div class="actions-row">
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-ai" (click)="openWizard()" [disabled]="!canSubmit"
title="Ouvrir l'assistant IA pour pré-remplir les champs">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Créer avec l'IA
</button>
<button type="submit" class="btn-primary" [disabled]="!canSubmit">Créer la page</button>
</div>
</form>
</div>
<!-- Drawer chat IA en mode wizard -->
<app-ai-chat-drawer
[loreId]="loreId"
[isOpen]="chatOpen"
[welcomeMessage]="wizardWelcome"
[systemPromptAddon]="wizardSystemPrompt"
[quickSuggestions]="wizardSuggestions"
[primaryAction]="wizardPrimaryAction"
(close)="closeWizard()"
(assistantReply)="onWizardReply($event)"
(primaryActionClick)="applyWizardAndCreate()">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,186 @@
.page {
padding: 2rem 3rem;
max-width: 860px;
}
.page-header {
margin-bottom: 2rem;
h1 {
font-size: 1.6rem;
font-weight: 700;
color: white;
margin: 0 0 0.25rem;
}
.subtitle {
color: #9ca3af;
font-size: 0.9rem;
margin: 0;
}
}
.page-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
label {
font-size: 0.85rem;
font-weight: 500;
color: #d1d5db;
}
input, select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.75rem 0.9rem;
border-radius: 6px;
font-size: 0.9rem;
&:focus {
outline: none;
border-color: #6c63ff;
}
}
}
.hint {
font-size: 0.76rem;
color: #6b7280;
margin: 0;
}
.empty-hint {
color: #9ca3af;
font-size: 0.88rem;
a { color: #a5b4fc; text-decoration: underline; }
}
.templates-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.template-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.9rem 1rem;
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: #d1d5db;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
&:hover { border-color: #3a3a55; background: #20203a; }
&.selected {
border-color: #6c63ff;
background: #1e1c3a;
}
.template-card-head {
display: flex;
align-items: center;
gap: 0.5rem;
.template-name {
font-weight: 600;
color: white;
font-size: 0.95rem;
}
}
.template-description {
margin: 0;
font-size: 0.78rem;
color: #9ca3af;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.info-box {
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-left: 3px solid #6c63ff;
padding: 0.75rem 1rem;
border-radius: 6px;
color: #d1d5db;
font-size: 0.85rem;
line-height: 1.5;
}
.actions-row {
display: flex;
justify-content: flex-start;
gap: 0.75rem;
margin-top: 0.5rem;
}
.btn-primary, .btn-secondary {
padding: 0.65rem 1.2rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary {
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5a52e0; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-secondary {
background: #2a2a3d;
color: #d1d5db;
&:hover { background: #363650; }
}
.btn-ai {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.65rem 1.1rem;
background: transparent;
color: #a5b4fc;
border: 1px solid #6c63ff;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
&:hover:not(:disabled) { background: #1f1d3a; }
&:disabled { opacity: 0.4; cursor: not-allowed; border-color: #2a2a3d; color: #6b7280; }
}
.wizard-error {
background: #3f1f1f;
color: #fca5a5;
border: 1px solid #7f1d1d;
padding: 0.65rem 0.9rem;
border-radius: 6px;
font-size: 0.85rem;
}

View File

@@ -0,0 +1,253 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { LucideAngularModule, FileText, Sparkles } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model';
import { Template } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Écran de création d'une Page.
*
* Deux entrées possibles :
* - /lore/:loreId/pages/create → noeud choisi depuis le template
* - /lore/:loreId/nodes/:nodeId/pages/create → noeud pré-rempli depuis l'URL
*
* Le MVP est volontairement simple (maquette "création de page") : titre +
* choix de template (grille) + noeud de destination. L'édition détaillée des
* champs dynamiques du template se fait APRÈS création, via l'écran page-edit.
*/
@Component({
selector: 'app-page-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule, AiChatDrawerComponent],
templateUrl: './page-create.component.html',
styleUrls: ['./page-create.component.scss']
})
export class PageCreateComponent implements OnInit, OnDestroy {
readonly FileText = FileText;
readonly Sparkles = Sparkles;
form: FormGroup;
loreId = '';
/** Pré-rempli si la route contient :nodeId. */
preselectedNodeId: string | null = null;
nodes: LoreNode[] = [];
templates: Template[] = [];
/** Template actuellement sélectionné dans la grille. */
selectedTemplateId: string | null = null;
// --- Mode wizard IA (étape b6) -----------------------------------------
/** Drawer chat ouvert ? */
chatOpen = false;
/** Dernière réponse complète de l'assistant — on y cherchera le bloc <values>. */
private lastWizardReply: string | null = null;
/** Erreur de parsing du bloc <values> — affichée sous le drawer. */
wizardError: string | null = null;
/** Action primaire du wizard : applique les valeurs extraites et crée la page. */
readonly wizardPrimaryAction: ChatPrimaryAction = { label: 'Appliquer et créer la page' };
/** Suggestions rapides orientées "affiner le résultat" (mode wizard). */
readonly wizardSuggestions: string[] = [
'Rends la description plus courte',
'Ajoute un trait distinctif marquant',
'Donne un ton plus sombre'
];
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
title: ['', Validators.required],
nodeId: ['', Validators.required]
});
}
ngOnInit(): void {
this.pageTitleService.set('Nouvelle page');
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
this.preselectedNodeId = this.route.snapshot.paramMap.get('nodeId');
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService)
.subscribe(data => {
this.nodes = data.nodes;
this.templates = data.templates;
this.layoutService.show(buildLoreSidebarConfig(data));
// Si nodeId fourni par l'URL, on verrouille la valeur du formulaire.
if (this.preselectedNodeId) {
this.form.patchValue({ nodeId: this.preselectedNodeId });
}
});
}
selectTemplate(template: Template): void {
this.selectedTemplateId = template.id!;
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
if (!this.preselectedNodeId && template.defaultNodeId) {
this.form.patchValue({ nodeId: template.defaultNodeId });
}
}
get canSubmit(): boolean {
return this.form.valid && !!this.selectedTemplateId;
}
get selectedTemplate(): Template | null {
return this.templates.find(t => t.id === this.selectedTemplateId) ?? null;
}
submit(): void {
if (!this.canSubmit) return;
const raw = this.form.value;
this.pageService.create({
loreId: this.loreId,
nodeId: raw.nodeId,
templateId: this.selectedTemplateId!,
title: raw.title
}).subscribe({
// Après la création classique, la coquille est vide → on redirige
// vers l'écran d'édition pour que l'utilisateur remplisse les champs
// dynamiques du template.
next: created => this.router.navigate(['/lore', this.loreId, 'pages', created.id, 'edit']),
error: () => console.error('Erreur lors de la création de la page')
});
}
cancel(): void {
this.router.navigate(['/lore', this.loreId]);
}
// --- Mode wizard IA (étape b6) -----------------------------------------
openWizard(): void {
if (!this.canSubmit) return;
this.wizardError = null;
this.lastWizardReply = null;
this.chatOpen = true;
}
closeWizard(): void {
this.chatOpen = false;
}
/** Mémorise la dernière réponse de l'assistant — on y cherchera le bloc <values>. */
onWizardReply(reply: string): void {
this.lastWizardReply = reply;
}
/**
* Clic sur "Appliquer et créer la page" :
* 1. Extraire le bloc JSON <values>...</values> de la dernière réponse.
* 2. Créer la page avec titre + template + nodeId + values.
* 3. Naviguer vers l'édition pour que l'utilisateur finalise.
*/
applyWizardAndCreate(): void {
if (!this.canSubmit || !this.lastWizardReply) {
this.wizardError = "L'assistant n'a pas encore répondu. Décrivez d'abord votre idée.";
return;
}
const values = this.extractValuesBlock(this.lastWizardReply);
if (!values) {
this.wizardError = "Impossible d'extraire les valeurs. Demandez à l'assistant de proposer à nouveau.";
return;
}
this.wizardError = null;
const raw = this.form.value;
// Le backend POST /api/pages ne prend pas `values` — on crée d'abord la
// coquille, puis on PUT immédiatement avec les valeurs extraites.
// 2 roundtrips, mais zéro modification backend nécessaire.
this.pageService.create({
loreId: this.loreId,
nodeId: raw.nodeId,
templateId: this.selectedTemplateId!,
title: raw.title
}).subscribe({
next: (created) => {
const updated = { ...created, values };
this.pageService.update(created.id!, updated).subscribe({
next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id]),
error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.'
});
},
error: () => this.wizardError = 'Erreur lors de la création de la page.'
});
}
/** Prompt système injecté dans le backend pour le mode wizard. */
get wizardSystemPrompt(): string | null {
const tpl = this.selectedTemplate;
if (!tpl || !this.canSubmit) return null;
const title = this.form.value.title as string;
// Seuls les champs TEXT sont proposes a l'IA : l'IA ne genere pas d'images.
const textFields = (tpl.fields ?? []).filter(f => f.type === 'TEXT');
const fieldsList = textFields.length ? textFields.map(f => `"${f.name}"`).join(', ') : '(aucun champ)';
const exampleJson = textFields.length
? '{\n ' + textFields.map(f => `"${f.name}": "valeur proposée"`).join(',\n ') + '\n}'
: '{}';
return `MODE WIZARD — CRÉATION DE PAGE
L'utilisateur crée une nouvelle page intitulée "${title}" à partir du template "${tpl.name}".
Les champs à proposer sont : ${fieldsList}.
Règles de cohérence :
- Tu PEUX inventer des éléments originaux (personnages, lieux, objets, intrigues) — c'est ton rôle.
- Tu ne peux PAS faire référence à un élément comme s'il existait déjà dans l'univers, sauf s'il apparaît EXACTEMENT dans la carte du Lore fournie plus haut.
- Si l'utilisateur évoque un élément absent de la carte, suggère de le créer plutôt que d'inventer des détails fictifs à son sujet.
Format de réponse OBLIGATOIRE :
Après avoir dialogué (1-3 phrases), termine CHAQUE réponse par un bloc JSON entre balises <values>, sans rien ajouter après :
<values>
${exampleJson}
</values>
Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués. Laisse "" si tu manques d'info pour un champ.`;
}
/** Welcome message contextualisé au template choisi. */
get wizardWelcome(): string {
const tpl = this.selectedTemplate;
if (!tpl) return 'Décrivez ce que vous souhaitez créer.';
return `Super, on va créer une page "${tpl.name}" ! Décrivez-la-moi en quelques mots — contexte, rôle, traits marquants — et je proposerai des valeurs pour chaque champ.`;
}
/**
* Extrait le bloc <values>{...}</values> de la réponse assistant et parse en objet.
* Retourne null si absent ou JSON invalide.
*/
private extractValuesBlock(reply: string): Record<string, string> | null {
const match = reply.match(/<values>\s*([\s\S]*?)\s*<\/values>/i);
if (!match) return null;
try {
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
// On coerce toute valeur non-string en string (l'IA peut parfois produire des nombres).
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed)) {
result[k] = v == null ? '' : String(v);
}
return result;
} catch {
return null;
}
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,123 @@
<div class="page" *ngIf="page">
<app-breadcrumb [items]="breadcrumbItems"></app-breadcrumb>
<header class="page-header">
<div>
<h1>{{ page.title }}</h1>
<p class="subtitle">{{ template?.name || 'Page' }}</p>
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[disabled]="aiLoading"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA (chat ou remplissage automatique)">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
{{ aiLoading ? 'Génération…' : 'Assistant IA' }}
</button>
<button type="button" class="btn-secondary" [routerLink]="['/lore', loreId, 'pages', pageId]">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
Sauvegarder
</button>
</div>
</header>
<div *ngIf="aiError" class="ai-error-banner" role="alert">
<span>{{ aiError }}</span>
<button type="button" class="ai-error-dismiss" (click)="aiError = null" aria-label="Fermer">×</button>
</div>
<form class="edit-form">
<!-- Identité ----------------------------------------------------- -->
<div class="field">
<label>Nom</label>
<input type="text" [(ngModel)]="title" name="title" required />
</div>
<div class="field">
<label>Dossier</label>
<select [(ngModel)]="nodeId" name="nodeId">
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
</select>
<p class="hint">Déplacez cette page dans un autre dossier</p>
</div>
<!-- Champs dynamiques du template -------------------------------- -->
<ng-container *ngIf="template?.fields?.length">
<h2 class="section-title">Champs</h2>
<ng-container *ngFor="let field of template!.fields">
<!-- Champ TEXT : textarea editable -->
<div class="field" *ngIf="field.type === 'TEXT'">
<label>{{ field.name }}</label>
<textarea
[(ngModel)]="values[field.name]"
[name]="'value_' + field.name"
rows="4"
[placeholder]="'Valeur pour ' + field.name + '...'">
</textarea>
</div>
<!-- Champ IMAGE : galerie editable. -->
<div class="field" *ngIf="field.type === 'IMAGE'">
<label>{{ field.name }}</label>
<app-image-gallery
[imageIds]="imageValues[field.name] || []"
[editable]="true"
(imageIdsChange)="imageValues[field.name] = $event">
</app-image-gallery>
</div>
</ng-container>
</ng-container>
<!-- Tags --------------------------------------------------------- -->
<h2 class="section-title">Tags</h2>
<div class="field">
<app-chips-input
[value]="tags"
(valueChange)="tags = $event"
placeholder="Ajouter un tag (Entrée pour valider)...">
</app-chips-input>
<p class="hint">Mots-clés libres pour classer et retrouver cette page</p>
</div>
<!-- Liens vers d'autres pages ----------------------------------- -->
<h2 class="section-title">Pages liées</h2>
<div class="field">
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="allPages"
[excludePageId]="pageId"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<p class="hint">Cliquez sur un lien pour ouvrir la page associée</p>
</div>
<!-- Notes privées ----------------------------------------------- -->
<h2 class="section-title">Notes privées</h2>
<div class="field">
<textarea
[(ngModel)]="notes"
name="notes"
rows="4"
placeholder="Notes personnelles (non exportées vers FoundryVTT)">
</textarea>
<p class="hint">Visibles uniquement par vous. Utiles pour préparer vos sessions.</p>
</div>
</form>
</div>
<!-- Drawer chat IA (hors du .page pour pouvoir couvrir le viewport côté droit) -->
<app-ai-chat-drawer
[loreId]="loreId"
[pageId]="pageId"
[isOpen]="chatOpen"
[quickSuggestions]="chatQuickSuggestions"
[primaryAction]="chatPrimaryAction"
(close)="chatOpen = false"
(primaryActionClick)="onChatFillRequested()">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,153 @@
.page {
padding: 2rem 3rem;
max-width: 1000px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
h1 {
font-size: 1.6rem;
font-weight: 700;
color: white;
margin: 0 0 0.25rem;
}
.subtitle {
color: #9ca3af;
font-size: 0.85rem;
margin: 0;
}
.header-actions {
display: flex;
gap: 0.6rem;
}
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: white;
margin: 1.5rem 0 0.25rem;
border-top: 1px solid #1e1e3a;
padding-top: 1rem;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.4rem;
label {
font-size: 0.82rem;
font-weight: 500;
color: #d1d5db;
}
input, textarea, select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.7rem 0.9rem;
border-radius: 6px;
font-size: 0.9rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #6c63ff;
}
}
textarea { resize: vertical; }
}
.hint {
font-size: 0.76rem;
color: #6b7280;
margin: 0;
}
.btn-primary, .btn-secondary, .btn-danger, .btn-ai {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.1rem;
border: none;
border-radius: 6px;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary {
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5a52e0; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-secondary {
background: #2a2a3d;
color: #d1d5db;
&:hover { background: #363650; }
}
.btn-danger {
background: #3f1f1f;
color: #fca5a5;
&:hover { background: #5a2a2a; }
}
.btn-ai {
background: transparent;
color: #a5b4fc;
border: 1px solid #2a2a3d;
&:disabled { opacity: 0.5; cursor: not-allowed; }
&:hover:not(:disabled) { background: #1f2937; }
&.active {
background: #1f2937;
border-color: #6c63ff;
color: white;
}
}
.ai-error-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: #3f1f1f;
color: #fca5a5;
border: 1px solid #7f1d1d;
border-radius: 6px;
padding: 0.7rem 1rem;
margin-bottom: 1.25rem;
font-size: 0.88rem;
.ai-error-dismiss {
background: transparent;
border: none;
color: #fca5a5;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
padding: 0 0.25rem;
&:hover { color: white; }
}
}

View File

@@ -0,0 +1,271 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, Sparkles } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model';
import { Template } from '../../services/template.model';
import { Page } from '../../services/page.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { ChipsInputComponent } from '../../shared/chips-input/chips-input.component';
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { Lore } from '../../services/lore.model';
/**
* Écran d'édition d'une Page.
*
* Fonctionnalités actuelles (Phase 5A + 5B) :
* - Titre (modifiable) + Dossier (déplaçable)
* - Champs dynamiques du Template (un textarea par champ, valeurs stockées dans `values`)
* - Tags (chips) — Phase 5B
* - Liens vers d'autres pages (autocomplete) — Phase 5B
* - Notes privées MJ
*
* À venir (Phase 5D) :
* - Bouton "Assistant IA" branché (Phase 3 Python)
*/
@Component({
selector: 'app-page-edit',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent],
templateUrl: './page-edit.component.html',
styleUrls: ['./page-edit.component.scss']
})
export class PageEditComponent implements OnInit, OnDestroy {
readonly Sparkles = Sparkles;
loreId = '';
pageId = '';
lore: Lore | null = null;
page: Page | null = null;
template: Template | null = null;
nodes: LoreNode[] = [];
/** Toutes les pages du lore — nécessaire au lore-link-picker pour l'autocomplete. */
allPages: Page[] = [];
/** Modèle du formulaire (bindé via ngModel). */
title = '';
nodeId = '';
notes = '';
/** Valeurs des champs dynamiques TEXT, indexées par fieldName. */
values: Record<string, string> = {};
/**
* Valeurs des champs dynamiques IMAGE : pour chaque nom de champ IMAGE,
* la liste ordonnee des IDs d'images uploadees.
*/
imageValues: Record<string, string[]> = {};
/** Étiquettes libres (Phase 5B). */
tags: string[] = [];
/** IDs des pages liées (Phase 5B). */
relatedPageIds: string[] = [];
/** Phase 5D — état de l'Assistant IA (one-shot). */
aiLoading = false;
aiError: string | null = null;
/** Phase b5 — drawer chat IA (conversationnel). */
chatOpen = false;
/** Action primaire dans le chat : déclenche le one-shot b4 (remplissage automatique). */
readonly chatPrimaryAction: ChatPrimaryAction = { label: 'Remplir automatiquement tous les champs' };
/** Suggestions rapides hardcodées (MVP). */
readonly chatQuickSuggestions: string[] = [
"Étoffe l'histoire de cette page",
'Suggère des liens avec d\'autres pages du Lore',
'Propose une intrigue secondaire'
];
constructor(
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
// S'abonner à paramMap plutôt que de lire snapshot une fois : sinon, quand on
// navigue d'une page à une autre (ex. via les chips du lore-link-picker),
// Angular réutilise le composant et ngOnInit ne se relance pas → l'écran
// resterait figé sur l'ancienne page.
this.route.paramMap.subscribe(pm => {
const newPageId = pm.get('pageId')!;
if (newPageId && newPageId !== this.pageId) {
this.pageId = newPageId;
this.load();
}
});
}
private load(): void {
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
page: this.pageService.getById(this.pageId)
}).subscribe(({ sidebar, page }) => {
this.lore = sidebar.lore;
this.nodes = sidebar.nodes;
this.allPages = sidebar.pages;
this.layoutService.show(buildLoreSidebarConfig(sidebar));
this.hydrate(page, sidebar.templates);
});
}
/**
* Construit le fil d'Ariane : Lore > [dossiers parents...] > Dossier courant > Page.
* Les items sont cliquables sauf le dernier (position courante).
* On remonte la hiérarchie via `parentId` jusqu'à la racine, puis on inverse.
*/
get breadcrumbItems(): BreadcrumbItem[] {
if (!this.lore || !this.page) return [];
const items: BreadcrumbItem[] = [
{ label: this.lore.name, route: ['/lore', this.loreId] }
];
// Chemin des dossiers (racine → dossier courant) via remontée parentId.
const folderChain: LoreNode[] = [];
let currentNode = this.nodes.find(n => n.id === this.nodeId);
while (currentNode) {
folderChain.unshift(currentNode);
currentNode = currentNode.parentId
? this.nodes.find(n => n.id === currentNode!.parentId)
: undefined;
}
for (const node of folderChain) {
items.push({
label: node.name,
route: ['/lore', this.loreId, 'folders', node.id, 'edit']
});
}
// Position courante : la page (non-cliquable).
items.push({ label: this.title || this.page.title });
return items;
}
private hydrate(page: Page, templates: Template[]): void {
this.page = page;
this.template = templates.find(t => t.id === page.templateId) ?? null;
this.title = page.title;
this.nodeId = page.nodeId;
this.notes = page.notes ?? '';
// On initialise une entrée pour chaque field TEXT du template, même vide,
// pour que le formulaire ait toujours les champs attendus.
// Les champs IMAGE ne sont pas geres dans `values` (ils auront leur propre
// structure `imageValues: Map<String, List<String>>` a l'etape 5).
const base: Record<string, string> = {};
const imageBase: Record<string, string[]> = {};
for (const f of this.template?.fields ?? []) {
if (f.type === 'TEXT') {
base[f.name] = page.values?.[f.name] ?? '';
} else if (f.type === 'IMAGE') {
// Initialise la galerie d'images pour ce champ (vide si jamais rempli).
imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])];
}
}
this.values = base;
this.imageValues = imageBase;
this.tags = [...(page.tags ?? [])];
this.relatedPageIds = [...(page.relatedPageIds ?? [])];
this.pageTitleService.set(page.title);
}
save(): void {
if (!this.page || !this.title.trim()) return;
const updated: Page = {
...this.page,
title: this.title,
nodeId: this.nodeId,
notes: this.notes,
values: this.values,
imageValues: this.imageValues,
tags: this.tags,
relatedPageIds: this.relatedPageIds
};
this.pageService.update(this.pageId, updated).subscribe({
next: () => this.router.navigate(['/lore', this.loreId, 'pages', this.pageId]),
error: () => console.error('Erreur lors de la sauvegarde de la page')
});
}
// --- Chat IA conversationnel (Phase b5) --------------------------------
toggleChat(): void {
this.chatOpen = !this.chatOpen;
}
/** Appelé depuis le drawer quand l'utilisateur clique sur l'action primaire. */
onChatFillRequested(): void {
this.chatOpen = false; // on ferme le drawer : le résultat apparaîtra dans les textareas
this.runAssistantAI();
}
/**
* Assistant IA (Phase 5D) — demande au Brain des suggestions de valeurs
* pour les champs dynamiques du template.
*
* Merge soft : on n'écrase pas une valeur déjà saisie par l'utilisateur
* si la suggestion est vide. L'utilisateur garde le contrôle final avant
* de cliquer "Sauvegarder".
*/
runAssistantAI(): void {
if (this.aiLoading || !this.template?.fields?.length) return;
this.aiLoading = true;
this.aiError = null;
this.pageService.generateValues(this.pageId).subscribe({
next: (suggestions) => {
this.mergeSuggestions(suggestions);
this.aiLoading = false;
},
error: (err) => {
this.aiLoading = false;
this.aiError = err?.status === 502
? "L'assistant IA est injoignable. V\u00e9rifiez que le service Brain tourne."
: "\u00c9chec de la g\u00e9n\u00e9ration IA. R\u00e9essayez dans un instant.";
}
});
}
/**
* Fusionne les suggestions dans les valeurs courantes.
* Merge soft :
* - Suggestion non-vide → on applique (l'utilisateur a demandé la génération).
* - Suggestion vide → on NE touche PAS à la valeur courante (l'IA n'a rien à proposer pour ce champ).
*/
private mergeSuggestions(suggestions: Record<string, string>): void {
// L'IA ne genere que des valeurs texte : on ignore les champs IMAGE.
for (const field of this.template?.fields ?? []) {
if (field.type !== 'TEXT') continue;
const suggestion = suggestions[field.name];
if (suggestion && suggestion.trim()) {
this.values[field.name] = suggestion;
}
}
}
delete(): void {
if (!this.page) return;
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
this.pageService.delete(this.pageId).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la suppression de la page')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,65 @@
<div class="view-page" *ngIf="page">
<app-breadcrumb [items]="breadcrumbItems"></app-breadcrumb>
<header class="view-header">
<div>
<h1>{{ page.title }}</h1>
<p class="view-subtitle">{{ template?.name || 'Page' }}</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
</div>
</header>
<!-- Champs dynamiques du template (seuls les champs TEXT sont rendus ici ;
le support complet des champs IMAGE arrive a l'etape 5). -->
<ng-container *ngIf="template?.fields?.length">
<ng-container *ngFor="let field of template!.fields">
<section class="view-section" *ngIf="field.type === 'TEXT'">
<h2 class="view-section-title">{{ field.name }}</h2>
<p class="view-section-body" *ngIf="valueOf(field.name); else emptyField">{{ valueOf(field.name) }}</p>
<ng-template #emptyField>
<p class="view-section-empty">Non renseigné</p>
</ng-template>
</section>
<section class="view-section" *ngIf="field.type === 'IMAGE'">
<h2 class="view-section-title">{{ field.name }}</h2>
<app-image-gallery [imageIds]="imageIdsOf(field.name)"></app-image-gallery>
</section>
</ng-container>
</ng-container>
<!-- Tags -->
<section class="view-section" *ngIf="(page.tags?.length ?? 0) > 0">
<h2 class="view-section-title">Tags</h2>
<div class="view-chips">
<span class="view-chip view-chip--tag" *ngFor="let tag of page.tags">{{ tag }}</span>
</div>
</section>
<!-- Pages liées -->
<section class="view-section" *ngIf="(page.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title">Pages liées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of page.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
<!-- Notes privées MJ -->
<section class="view-section view-section--private" *ngIf="page.notes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes privées
</h2>
<p class="view-section-body">{{ page.notes }}</p>
</section>
</div>

View File

@@ -0,0 +1,4 @@
// Styles spécifiques à page-view.
// Le gros du style "fiche de jeu" vient du partial global `styles/_view.scss`.
// Aucun override nécessaire pour l'instant — ce fichier existe pour rester
// cohérent avec la structure des autres composants (ts/html/scss).

View File

@@ -0,0 +1,127 @@
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, Pencil } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Lore, LoreNode } from '../../services/lore.model';
import { Template } from '../../services/template.model';
import { Page } from '../../services/page.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
/**
* Écran de consultation d'une Page (mode lecture seule).
*
* Responsabilité : afficher une belle fiche, sans formulaire ni scrollbar interne.
* Chaque champ du template est rendu en bloc titré dont le corps s'étend
* verticalement selon le contenu (via CSS `white-space: pre-wrap`).
*
* Route : /lore/:loreId/pages/:pageId
* Pour modifier → bouton "Modifier" qui navigue vers /lore/:loreId/pages/:pageId/edit.
*/
@Component({
selector: 'app-page-view',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule, BreadcrumbComponent, ImageGalleryComponent],
templateUrl: './page-view.component.html',
styleUrls: ['./page-view.component.scss']
})
export class PageViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
loreId = '';
pageId = '';
lore: Lore | null = null;
page: Page | null = null;
template: Template | null = null;
nodes: LoreNode[] = [];
allPages: Page[] = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
// Même pattern que page-edit : on s'abonne à paramMap pour gérer la
// navigation d'une page à l'autre (Angular réutilise le composant).
this.route.paramMap.subscribe(pm => {
const newPageId = pm.get('pageId')!;
if (newPageId && newPageId !== this.pageId) {
this.pageId = newPageId;
this.load();
}
});
}
private load(): void {
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
page: this.pageService.getById(this.pageId)
}).subscribe(({ sidebar, page }) => {
this.lore = sidebar.lore;
this.nodes = sidebar.nodes;
this.allPages = sidebar.pages;
this.template = sidebar.templates.find(t => t.id === page.templateId) ?? null;
this.page = page;
this.layoutService.show(buildLoreSidebarConfig(sidebar));
this.pageTitleService.set(page.title);
});
}
/** Fil d'Ariane — même logique que page-edit (remontée via parentId). */
get breadcrumbItems(): BreadcrumbItem[] {
if (!this.lore || !this.page) return [];
const items: BreadcrumbItem[] = [
{ label: this.lore.name, route: ['/lore', this.loreId] }
];
const folderChain: LoreNode[] = [];
let currentNode = this.nodes.find(n => n.id === this.page!.nodeId);
while (currentNode) {
folderChain.unshift(currentNode);
currentNode = currentNode.parentId
? this.nodes.find(n => n.id === currentNode!.parentId)
: undefined;
}
for (const node of folderChain) {
items.push({ label: node.name, route: ['/lore', this.loreId, 'folders', node.id, 'edit'] });
}
items.push({ label: this.page.title });
return items;
}
/** Récupère la valeur d'un champ dynamique TEXT du template. */
valueOf(fieldName: string): string {
return this.page?.values?.[fieldName] ?? '';
}
/** IDs d'images pour un champ IMAGE (liste vide si aucune). */
imageIdsOf(fieldName: string): string[] {
return this.page?.imageValues?.[fieldName] ?? [];
}
/** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */
titleOfRelated(pageId: string): string {
return this.allPages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}
editMode(): void {
this.router.navigate(['/lore', this.loreId, 'pages', this.pageId, 'edit']);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,90 @@
<div class="page">
<header class="page-header">
<h1>Créer un nouveau Template</h1>
<p class="subtitle">Définissez un gabarit personnalisé pour créer des pages cohérentes</p>
</header>
<form [formGroup]="form" (ngSubmit)="submit()" class="template-form">
<!-- Colonne gauche ---------------------------------------------- -->
<div class="col-left">
<div class="field">
<label>Nom du template *</label>
<input type="text" formControlName="name" placeholder="Ex: Auberge, Artefact, Monstre..." />
</div>
<div class="field">
<label>Description</label>
<textarea formControlName="description" rows="4" placeholder="À quoi sert ce template ?"></textarea>
</div>
<div class="field">
<label>Dossier par défaut *</label>
<select formControlName="defaultNodeId">
<option value="" disabled>Sélectionnez un dossier</option>
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
</select>
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p>
</div>
</div>
<!-- Colonne droite --------------------------------------------- -->
<div class="col-right">
<label class="section-label">Champs du template *</label>
<ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index">
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
{{ f.name }}
</span>
<button type="button"
class="btn-icon btn-type-toggle"
(click)="toggleFieldType(i)"
[attr.aria-label]="'Basculer vers ' + (f.type === 'TEXT' ? 'Image' : 'Texte')"
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
</button>
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
</ul>
<div class="field-row add-row">
<input
type="text"
[(ngModel)]="newFieldName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du champ..."
(keydown.enter)="$event.preventDefault(); addField()" />
<select
class="type-select"
[(ngModel)]="newFieldType"
[ngModelOptions]="{ standalone: true }"
aria-label="Type du champ">
<option value="TEXT">Texte</option>
<option value="IMAGE">Image</option>
</select>
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button>
</div>
<p class="hint">Les champs Texte sont editables librement et utilisables par l'IA. Les champs Image hebergent une galerie d'illustrations.</p>
</div>
<!-- Actions ---------------------------------------------------- -->
<div class="actions-row">
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="submit" class="btn-primary" [disabled]="form.invalid">Créer le template</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,213 @@
.page {
padding: 2rem 3rem;
max-width: 1000px;
}
.page-header {
margin-bottom: 2rem;
h1 {
font-size: 1.6rem;
font-weight: 700;
color: white;
margin: 0 0 0.25rem;
}
.subtitle {
color: #9ca3af;
font-size: 0.9rem;
margin: 0;
}
}
.template-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem 2.5rem;
.col-left, .col-right {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.actions-row {
grid-column: 1 / -1;
display: flex;
justify-content: flex-start;
gap: 0.75rem;
margin-top: 0.5rem;
}
}
.field {
display: flex;
flex-direction: column;
gap: 0.4rem;
label {
font-size: 0.82rem;
font-weight: 500;
color: #d1d5db;
}
input, textarea, select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.7rem 0.9rem;
border-radius: 6px;
font-size: 0.9rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #6c63ff;
}
}
textarea { resize: vertical; }
}
.hint {
font-size: 0.76rem;
color: #6b7280;
margin: 0;
}
.section-label {
font-size: 0.82rem;
font-weight: 500;
color: #d1d5db;
margin-bottom: 0.4rem;
}
.fields-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-row {
display: flex;
align-items: center;
gap: 0.5rem;
.field-chip {
flex: 1;
display: inline-flex;
align-items: center;
gap: 0.45rem;
background: #2a5f3f;
color: #d1fae5;
padding: 0.6rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
// Couleur discriminante pour les champs IMAGE (palette indigo).
&.field-chip-image {
background: #312b5c;
color: #c7b8ff;
}
}
.btn-type-toggle {
width: auto;
padding: 0 0.7rem;
background: #2a2a3d;
color: #d1d5db;
font-size: 0.72rem;
letter-spacing: 0.02em;
&:hover { background: #363650; color: white; }
}
.type-select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0 0.6rem;
height: 36px;
border-radius: 6px;
font-size: 0.82rem;
cursor: pointer;
&:focus { outline: none; border-color: #6c63ff; }
}
input {
flex: 1;
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.6rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
&:focus {
outline: none;
border-color: #6c63ff;
}
}
&.add-row { margin-top: 0.5rem; }
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: #3f1f1f;
color: #fca5a5;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
&:hover { background: #5a2a2a; }
}
.btn-add {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: #6c63ff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
&:hover { background: #5a52e0; }
}
.btn-primary, .btn-secondary {
padding: 0.65rem 1.2rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary {
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5a52e0; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-secondary {
background: #2a2a3d;
color: #d1d5db;
&:hover { background: #363650; }
}

View File

@@ -0,0 +1,118 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { LoreNode } from '../../services/lore.model';
import { FieldType, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/**
* Écran de création d'un Template (gabarit de Page).
* - Champs principaux : nom, description, noeud par défaut.
* - Liste dynamique de "champs du template" (ex: "Nom", "Description", "Personnalité").
* Le user peut ajouter/retirer n'importe lequel — tous sont égaux.
*/
@Component({
selector: 'app-template-create',
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './template-create.component.html',
styleUrls: ['./template-create.component.scss']
})
export class TemplateCreateComponent implements OnInit, OnDestroy {
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
form: FormGroup;
loreId = '';
nodes: LoreNode[] = [];
/**
* Champs dynamiques actuellement definis. Chaque champ a un type discriminant
* (TEXT ou IMAGE) qui pilote son rendu sur les pages.
*/
fields: TemplateField[] = [
{ name: 'Nom', type: 'TEXT' },
{ name: 'Description', type: 'TEXT' }
];
/** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */
newFieldName = '';
/** Type choisi pour le prochain champ a ajouter. */
newFieldType: FieldType = 'TEXT';
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
defaultNodeId: ['', Validators.required]
});
}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService).subscribe(data => {
this.nodes = data.nodes;
this.layoutService.show(buildLoreSidebarConfig(data));
});
}
addField(): void {
const name = this.newFieldName.trim();
if (!name) return;
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
if (this.fields.some(f => f.name === name)) return;
this.fields = [...this.fields, { name, type: this.newFieldType }];
this.newFieldName = '';
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
// plusieurs champs du meme type.
}
removeField(index: number): void {
this.fields = this.fields.filter((_, i) => i !== index);
}
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */
toggleFieldType(index: number): void {
const field = this.fields[index];
if (!field) return;
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
}
submit(): void {
if (this.form.invalid) return;
const raw = this.form.value;
this.templateService.create({
loreId: this.loreId,
name: raw.name,
description: raw.description,
defaultNodeId: raw.defaultNodeId,
fields: this.fields
}).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la création du template')
});
}
cancel(): void {
this.router.navigate(['/lore', this.loreId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,89 @@
<div class="page" *ngIf="template">
<header class="page-header">
<div>
<h1>{{ template.name }}</h1>
<p class="subtitle">Template</p>
</div>
<div class="header-actions">
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
<button type="button" class="btn-primary" (click)="save()" [disabled]="form.invalid">Sauvegarder</button>
</div>
</header>
<form [formGroup]="form" class="template-form">
<!-- Colonne gauche ---------------------------------------------- -->
<div class="col-left">
<div class="field">
<label>Nom</label>
<input type="text" formControlName="name" />
</div>
<div class="field">
<label>Dossier par défaut</label>
<select formControlName="defaultNodeId">
<option value="">-- Aucun --</option>
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
</select>
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier par défaut</p>
</div>
<div class="field">
<label>Description</label>
<textarea formControlName="description" rows="6"></textarea>
</div>
</div>
<!-- Colonne droite --------------------------------------------- -->
<div class="col-right">
<label class="section-label">Champs du template</label>
<ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index">
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
{{ f.name }}
</span>
<button type="button"
class="btn-icon-ghost btn-type-toggle"
(click)="toggleFieldType(i)"
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
</button>
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</li>
</ul>
<div class="field-row add-row">
<input
type="text"
[(ngModel)]="newFieldName"
[ngModelOptions]="{ standalone: true }"
placeholder="+ Ajouter un champ"
(keydown.enter)="$event.preventDefault(); addField()" />
<select
class="type-select"
[(ngModel)]="newFieldType"
[ngModelOptions]="{ standalone: true }"
aria-label="Type du champ">
<option value="TEXT">Texte</option>
<option value="IMAGE">Image</option>
</select>
<button type="button" class="btn-add" (click)="addField()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button>
</div>
<p class="hint">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p>
</div>
</form>
</div>

View File

@@ -0,0 +1,235 @@
.page {
padding: 2rem 3rem;
max-width: 1000px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
h1 {
font-size: 1.6rem;
font-weight: 700;
color: white;
margin: 0 0 0.25rem;
}
.subtitle {
color: #9ca3af;
font-size: 0.85rem;
margin: 0;
}
.header-actions {
display: flex;
gap: 0.6rem;
}
}
.template-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem 2.5rem;
.col-left, .col-right {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
}
.field {
display: flex;
flex-direction: column;
gap: 0.4rem;
label {
font-size: 0.82rem;
font-weight: 500;
color: #d1d5db;
}
input, textarea, select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.7rem 0.9rem;
border-radius: 6px;
font-size: 0.9rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #6c63ff;
}
}
textarea { resize: vertical; }
}
.hint {
font-size: 0.76rem;
color: #6b7280;
margin: 0;
}
.section-label {
font-size: 0.82rem;
font-weight: 500;
color: #d1d5db;
margin-bottom: 0.4rem;
}
.fields-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-row {
display: flex;
align-items: center;
gap: 0.5rem;
.field-chip {
flex: 1;
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.6rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
display: inline-flex;
align-items: center;
gap: 0.45rem;
// Discriminant visuel pour les champs IMAGE (palette indigo).
&.field-chip-image {
background: #1f1b3a;
border-color: #3d3566;
color: #c7b8ff;
}
}
.btn-type-toggle {
width: auto;
padding: 0 0.7rem;
font-size: 0.72rem;
letter-spacing: 0.02em;
color: #9ca3af;
&:hover { color: #a5b4fc; background: #1f1b3a; }
}
.type-select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0 0.6rem;
height: 36px;
border-radius: 6px;
font-size: 0.82rem;
cursor: pointer;
&:focus { outline: none; border-color: #6c63ff; }
}
input {
flex: 1;
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.6rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
&:focus {
outline: none;
border-color: #6c63ff;
}
&::placeholder { color: #6b7280; }
}
&.add-row {
margin-top: 0.25rem;
border: 1px dashed #2a2a3d;
border-radius: 6px;
padding: 0;
input {
border: none;
background: transparent;
&:focus { border: none; }
}
}
}
.btn-icon-ghost {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
color: #6b7280;
border: none;
border-radius: 6px;
cursor: pointer;
transition: color 0.15s, background 0.15s;
&:hover { color: #fca5a5; background: #2a1f1f; }
}
.btn-add {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-right: 0.4rem;
background: transparent;
color: #6c63ff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
&:hover { background: #2a2a3d; }
}
.btn-primary, .btn-secondary, .btn-danger {
padding: 0.6rem 1.1rem;
border: none;
border-radius: 6px;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary {
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5a52e0; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-secondary {
background: #2a2a3d;
color: #d1d5db;
&:hover { background: #363650; }
}
.btn-danger {
background: #3f1f1f;
color: #fca5a5;
&:hover { background: #5a2a2a; }
}

View File

@@ -0,0 +1,136 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model';
import { FieldType, Template, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/**
* Écran d'édition d'un Template existant.
* Mêmes champs que la création + bouton Supprimer.
*/
@Component({
selector: 'app-template-edit',
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './template-edit.component.html',
styleUrls: ['./template-edit.component.scss']
})
export class TemplateEditComponent implements OnInit, OnDestroy {
readonly Plus = Plus;
readonly X = X;
readonly Trash2 = Trash2;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
form: FormGroup;
loreId = '';
templateId = '';
template: Template | null = null;
nodes: LoreNode[] = [];
fields: TemplateField[] = [];
newFieldName = '';
newFieldType: FieldType = 'TEXT';
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
defaultNodeId: ['']
});
}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
this.templateId = this.route.snapshot.paramMap.get('templateId')!;
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
template: this.templateService.getById(this.templateId)
}).subscribe(({ sidebar, template }) => {
this.nodes = sidebar.nodes;
this.layoutService.show(buildLoreSidebarConfig(sidebar));
this.hydrate(template);
});
}
private hydrate(template: Template): void {
this.template = template;
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
this.fields = (template.fields ?? []).map(f => ({
name: f.name,
type: f.type === 'IMAGE' ? 'IMAGE' : 'TEXT'
}));
this.form.patchValue({
name: template.name,
description: template.description,
defaultNodeId: template.defaultNodeId ?? ''
});
this.pageTitleService.set(template.name);
}
addField(): void {
const name = this.newFieldName.trim();
if (!name) return;
if (this.fields.some(f => f.name === name)) return;
this.fields = [...this.fields, { name, type: this.newFieldType }];
this.newFieldName = '';
}
removeField(index: number): void {
this.fields = this.fields.filter((_, i) => i !== index);
}
/** Bascule le type d'un champ (TEXT <-> IMAGE). */
toggleFieldType(index: number): void {
const field = this.fields[index];
if (!field) return;
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
}
save(): void {
if (this.form.invalid || !this.template) return;
const raw = this.form.value;
this.templateService.update(this.templateId, {
...this.template,
name: raw.name,
description: raw.description,
defaultNodeId: raw.defaultNodeId || null,
fields: this.fields
}).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la sauvegarde du template')
});
}
delete(): void {
if (!confirm(`Supprimer le template "${this.template?.name}" ?`)) return;
this.templateService.delete(this.templateId).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la suppression du template')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,199 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
/**
* Un message d'une conversation IA (vue front).
* Aligné sur le DTO ChatMessageDTO côté Java.
*/
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
/**
* Événements émis par le flux SSE durant un chat streamé.
* - token : un fragment de texte vient d'arriver (à concaténer dans la bulle).
* - done : le stream s'est terminé proprement (l'observable va compléter).
* - error : une erreur s'est produite côté serveur (l'observable va erreur-compléter).
*/
export type ChatStreamEvent =
| { type: 'token'; value: string }
| { type: 'done' }
| { type: 'error'; message: string };
/**
* Service qui encapsule l'appel SSE au backend Java (POST /api/ai/chat/stream).
*
* On n'utilise pas EventSource (API navigateur natif) car elle ne supporte
* que GET sans body. On fait donc un fetch() avec un ReadableStream qu'on
* décode ligne par ligne pour extraire les événements SSE.
*/
/** Type d'entité narrative focus pour le chat Campagne. */
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene';
@Injectable({ providedIn: 'root' })
export class AiChatService {
private readonly loreEndpoint = 'http://localhost:8080/api/ai/chat/stream';
private readonly campaignEndpoint = 'http://localhost:8080/api/ai/chat/stream-campaign';
/**
* Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore).
* L'Observable :
* - émet `{type: 'token', value}` à chaque fragment reçu ;
* - se complete quand `event: done` arrive ;
* - erreur-complete (via `throwError`) quand `event: error` arrive ou qu'une erreur réseau survient.
*
* Annuler la subscription annule proprement le fetch (AbortController).
*/
streamChat(
loreId: string,
messages: ChatMessage[],
pageId?: string | null
): Observable<ChatStreamEvent> {
const body: Record<string, unknown> = { loreId, messages };
if (pageId) body['pageId'] = pageId;
return this.streamSse(this.loreEndpoint, body);
}
/**
* Streame la réponse de l'IA pour un chat ancré sur une Campagne.
* Le backend charge automatiquement la carte narrative (arcs/chapitres/scènes)
* et, si la campagne est liée à un Lore, sa carte structurelle également.
*
* `entityType` + `entityId` sont optionnels : si fournis, focalisent l'IA
* sur l'arc / chapitre / scène en cours d'édition.
*/
streamChatForCampaign(
campaignId: string,
messages: ChatMessage[],
entityType?: NarrativeEntityType | null,
entityId?: string | null
): Observable<ChatStreamEvent> {
const body: Record<string, unknown> = { campaignId, messages };
if (entityType && entityId) {
body['entityType'] = entityType;
body['entityId'] = entityId;
}
return this.streamSse(this.campaignEndpoint, body);
}
/** Plumbing SSE mutualisé entre les 2 endpoints (Lore et Campaign). */
private streamSse(endpoint: string, body: Record<string, unknown>): Observable<ChatStreamEvent> {
return new Observable<ChatStreamEvent>((subscriber) => {
const controller = new AbortController();
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify(body),
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consumeSseStream(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return; // annulation volontaire, silencieuse
subscriber.error(err);
});
return () => controller.abort();
});
}
/**
* Consomme un ReadableStream SSE ligne par ligne.
* Format attendu (un événement = un bloc séparé par `\n\n`) :
* event: done (optionnel, défaut = 'message')
* data: {...} (une ou plusieurs lignes, concaténées avec '\n')
* <ligne vide> (séparateur d'événements)
*/
private async consumeSseStream(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: ChatStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
// Événement SSE en cours de construction (accumulé entre lignes vides).
let currentEvent: string | null = null;
let currentData = '';
const dispatchCurrentEvent = () => {
const eventName = currentEvent ?? 'message';
if (eventName === 'error') {
const message = this.safeParseMessage(currentData);
subscriber.error(new Error(message));
} else if (eventName === 'done') {
subscriber.next({ type: 'done' });
subscriber.complete();
} else {
// Événement 'message' (défaut) : JSON {"token": "..."}
const token = this.safeParseToken(currentData);
if (token) subscriber.next({ type: 'token', value: token });
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer.
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
buffer = buffer.slice(newlineIdx + 1);
if (line === '') {
// Ligne vide = fin d'un événement SSE : on dispatch ce qu'on a accumulé.
if (currentEvent !== null || currentData !== '') {
dispatchCurrentEvent();
}
continue;
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
// Autres champs SSE (id:, retry:) ignorés pour le MVP.
}
}
// Fin de stream côté réseau sans event: done explicite → on complete quand même.
if (currentEvent !== null || currentData !== '') dispatchCurrentEvent();
subscriber.complete();
} catch (err) {
subscriber.error(err);
}
}
private safeParseToken(json: string): string | null {
try {
const obj = JSON.parse(json) as { token?: string };
return typeof obj.token === 'string' ? obj.token : null;
} catch {
return null;
}
}
private safeParseMessage(json: string): string {
try {
const obj = JSON.parse(json) as { message?: string };
return obj.message ?? 'Erreur inconnue côté serveur.';
} catch {
return json || 'Erreur inconnue côté serveur.';
}
}
}

View File

@@ -0,0 +1,142 @@
// Interface TypeScript pour CampaignDTO (correspond au DTO Java)
export interface Campaign {
id?: string;
name: string;
description: string;
playerCount?: number;
arcCount?: number;
chapterCount?: number;
/** ID du Lore associé (weak reference cross-context). `null` = pas d'univers lié. */
loreId?: string | null;
}
// Interface pour la création de Campaign (sans id)
export interface CampaignCreate {
name: string;
description: string;
playerCount: number;
loreId?: string | null;
}
export interface Arc {
id?: string;
name: string;
description?: string; // = Synopsis dans l'UI
campaignId: string;
order?: number;
chapterCount?: number;
// Champs narratifs enrichis
themes?: string;
stakes?: string;
gmNotes?: string;
rewards?: string;
resolution?: string;
/** IDs des pages du Lore liées à cet arc (weak cross-context refs). */
relatedPageIds?: string[];
/** IDs des images (Shared Kernel) illustrant cet arc. */
illustrationImageIds?: string[];
}
// Payload pour la création d'un Arc (pas d'id)
export interface ArcCreate {
name: string;
description?: string;
campaignId: string;
order: number;
themes?: string;
stakes?: string;
gmNotes?: string;
rewards?: string;
resolution?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
export interface Chapter {
id?: string;
name: string;
description?: string;
arcId: string;
order?: number;
// Champs narratifs enrichis
gmNotes?: string;
playerObjectives?: string;
narrativeStakes?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
export interface ChapterCreate {
name: string;
description?: string;
arcId: string;
order: number;
gmNotes?: string;
playerObjectives?: string;
narrativeStakes?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
/**
* Branche narrative : sortie possible d'une scène vers une autre du même chapitre.
* Pendant TS du Value Object Java SceneBranch.
*/
export interface SceneBranch {
label: string;
targetSceneId: string;
condition?: string;
}
export interface Scene {
id?: string;
name: string;
description?: string; // = Description courte dans l'UI
chapterId: string;
order?: number;
// Champs narratifs enrichis
location?: string;
timing?: string;
atmosphere?: string;
playerNarration?: string;
gmSecretNotes?: string;
choicesConsequences?: string;
combatDifficulty?: string;
enemies?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
/** Sorties narratives (graphe intra-chapitre). */
branches?: SceneBranch[];
}
export interface SceneCreate {
name: string;
description?: string;
chapterId: string;
order: number;
location?: string;
timing?: string;
atmosphere?: string;
playerNarration?: string;
gmSecretNotes?: string;
choicesConsequences?: string;
combatDifficulty?: string;
enemies?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
branches?: SceneBranch[];
}

View File

@@ -0,0 +1,105 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate } from './campaign.model';
/**
* Service HTTP pour la gestion des Campagnes.
* Port de sortie vers le Backend Java (Architecture Hexagonale).
*/
@Injectable({
providedIn: 'root'
})
export class CampaignService {
private apiUrl = 'http://localhost:8080/api/campaigns';
constructor(private http: HttpClient) {}
getAllCampaigns(): Observable<Campaign[]> {
return this.http.get<Campaign[]>(this.apiUrl);
}
getCampaignById(id: string): Observable<Campaign> {
return this.http.get<Campaign>(`${this.apiUrl}/${id}`);
}
createCampaign(campaign: CampaignCreate): Observable<Campaign> {
return this.http.post<Campaign>(this.apiUrl, campaign);
}
updateCampaign(id: string, campaign: CampaignCreate): Observable<Campaign> {
return this.http.put<Campaign>(`${this.apiUrl}/${id}`, campaign);
}
deleteCampaign(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
// ========== ARC ==========
getArcs(campaignId: string): Observable<Arc[]> {
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
}
getArcById(id: string): Observable<Arc> {
return this.http.get<Arc>(`http://localhost:8080/api/arcs/${id}`);
}
createArc(payload: ArcCreate): Observable<Arc> {
return this.http.post<Arc>('http://localhost:8080/api/arcs', payload);
}
updateArc(id: string, payload: ArcCreate): Observable<Arc> {
return this.http.put<Arc>(`http://localhost:8080/api/arcs/${id}`, payload);
}
deleteArc(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/arcs/${id}`);
}
// ========== CHAPTER ==========
getChapters(arcId: string): Observable<Chapter[]> {
return this.http.get<Chapter[]>(`http://localhost:8080/api/chapters/arc/${arcId}`);
}
getChapterById(id: string): Observable<Chapter> {
return this.http.get<Chapter>(`http://localhost:8080/api/chapters/${id}`);
}
createChapter(payload: ChapterCreate): Observable<Chapter> {
return this.http.post<Chapter>('http://localhost:8080/api/chapters', payload);
}
updateChapter(id: string, payload: ChapterCreate): Observable<Chapter> {
return this.http.put<Chapter>(`http://localhost:8080/api/chapters/${id}`, payload);
}
deleteChapter(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/chapters/${id}`);
}
// ========== SCENE ==========
getScenes(chapterId: string): Observable<Scene[]> {
return this.http.get<Scene[]>(`http://localhost:8080/api/scenes/chapter/${chapterId}`);
}
getSceneById(id: string): Observable<Scene> {
return this.http.get<Scene>(`http://localhost:8080/api/scenes/${id}`);
}
createScene(payload: SceneCreate): Observable<Scene> {
return this.http.post<Scene>('http://localhost:8080/api/scenes', payload);
}
updateScene(id: string, payload: SceneCreate): Observable<Scene> {
return this.http.put<Scene>(`http://localhost:8080/api/scenes/${id}`, payload);
}
deleteScene(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/scenes/${id}`);
}
search(q: string): Observable<Campaign[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Campaign[]>(`${this.apiUrl}/search`, { params });
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
/**
* État global de la command palette (modale de recherche).
* Ouverte via bouton sidebar, raccourci Ctrl+K / Cmd+K, ou API programmatique.
*/
@Injectable({ providedIn: 'root' })
export class GlobalSearchService {
private readonly _open$ = new BehaviorSubject<boolean>(false);
readonly open$ = this._open$.asObservable();
open(): void { this._open$.next(true); }
close(): void { this._open$.next(false); }
toggle(): void { this._open$.next(!this._open$.value); }
}

View File

@@ -0,0 +1,15 @@
// Interface TypeScript pour ImageDTO (Backend Java).
// Miroir de com.loremind.infrastructure.web.dto.images.ImageDTO.
export interface Image {
id: string;
filename: string;
contentType: string;
sizeBytes: number;
/**
* URL relative du binaire, ex: "/api/images/42/content".
* Le front prefixe avec ApiBase pour construire l'URL absolue.
*/
url: string;
uploadedAt: string;
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Image } from './image.model';
/**
* Service HTTP pour le Shared Kernel images.
* Port de sortie vers le backend Java (/api/images).
*/
@Injectable({ providedIn: 'root' })
export class ImageService {
/** Base absolue du backend — utile pour construire des URLs complètes (<img src>). */
readonly apiBase = 'http://localhost:8080';
private apiUrl = `${this.apiBase}/api/images`;
constructor(private http: HttpClient) {}
/**
* Upload d'un fichier via multipart/form-data.
* Le backend valide le MIME et la taille (10 Mo max).
*/
upload(file: File): Observable<Image> {
const form = new FormData();
form.append('file', file);
return this.http.post<Image>(this.apiUrl, form);
}
getById(id: string): Observable<Image> {
return this.http.get<Image>(`${this.apiUrl}/${id}`);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/**
* Construit l'URL absolue du binaire d'une image.
* Utilise par les balises <img src> dans les composants.
*/
contentUrl(id: string): string {
return `${this.apiUrl}/${id}/content`;
}
}

View File

@@ -0,0 +1,101 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface TreeItem {
id: string;
label: string;
children?: TreeItem[];
route?: string; // si défini, cliquer navigue au lieu de toggler
isAction?: boolean; // style "action" (ex: "+ Nouveau chapitre")
/** Clé d'icône optionnelle (ex: "users"). Résolue par le composant via `resolveIcon`. */
iconKey?: string;
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
meta?: string;
}
export interface GlobalItem {
id: string;
name: string;
route: string;
}
export interface SidebarAction {
id: string;
label: string;
variant: 'primary' | 'secondary' | 'success';
route?: string;
}
/**
* Item affiché dans un panneau secondaire (ex: liste des Templates).
* Plus simple qu'un TreeItem : pas de récursion, juste label + meta + route.
*/
export interface BottomPanelItem {
id: string;
label: string;
meta?: string; // petit badge à droite (ex: "8 champs")
route?: string;
isAction?: boolean; // style "action" (ex: "+ Nouveau template")
}
/**
* Panneau secondaire collapsible en bas de la sidebar (sous l'arbre).
* Utilisé notamment pour le panneau "Templates" côté Lore.
*/
export interface BottomPanel {
id: string; // identifiant pour mémoriser l'état ouvert/fermé
title: string;
items: BottomPanelItem[];
initiallyOpen?: boolean;
}
export interface SecondarySidebarConfig {
title: string;
items: TreeItem[];
createActions: SidebarAction[];
globalItems: GlobalItem[];
globalBackLabel: string;
globalBackRoute: string;
bottomPanel?: BottomPanel; // optionnel : présent côté Lore (Templates)
/** @deprecated Remplacé par bottomPanel. Gardé pour compat des callers campagne. */
footerLabel?: string;
}
/**
* Service de layout — contrôle l'affichage de la secondary sidebar
* et les données contextuelles de la sidebar globale.
*
* L'état d'expansion des items de l'arbre est maintenu au niveau du service
* (et non dans le composant secondary-sidebar), pour survivre aux
* destructions/recréations du composant lors des navigations (le *ngIf dans
* app.component.html détruit la sidebar à chaque `hide()`).
*/
@Injectable({ providedIn: 'root' })
export class LayoutService {
private config$ = new BehaviorSubject<SecondarySidebarConfig | null>(null);
private readonly expanded = new Set<string>();
readonly secondarySidebar$ = this.config$.asObservable();
show(config: SecondarySidebarConfig): void {
this.config$.next(config);
}
hide(): void {
this.config$.next(null);
}
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);
}
setExpanded(id: string, state: boolean): void {
if (state) this.expanded.add(id);
else this.expanded.delete(id);
}
}

View File

@@ -0,0 +1,37 @@
// Interface TypeScript pour LoreDTO (correspond au DTO Java)
export interface Lore {
id?: string;
name: string;
description: string;
nodeCount?: number;
pageCount?: number;
}
// Interface pour la création de Lore (sans id)
export interface LoreCreate {
name: string;
description: string;
}
export interface LoreNode {
id?: string;
name: string;
/** Clé d'icône lucide-angular (ex: "users", "map-pin"). */
icon?: string | null;
/** ID du dossier parent (null = racine). */
parentId?: string | null;
loreId: string;
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
type?: string;
description?: string;
address?: string;
}
export interface LoreNodeCreate {
name: string;
icon: string;
description: string;
address: string;
parentId?: string | null;
loreId: string;
}

View File

@@ -0,0 +1,69 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
/**
* Service HTTP pour la gestion des Lores.
* Port de sortie vers le Backend Java (Architecture Hexagonale).
*/
@Injectable({
providedIn: 'root'
})
export class LoreService {
private apiUrl = 'http://localhost:8080/api/lores';
private nodesUrl = 'http://localhost:8080/api/lore-nodes';
constructor(private http: HttpClient) {}
getAllLores(): Observable<Lore[]> {
return this.http.get<Lore[]>(this.apiUrl);
}
getLoreById(id: string): Observable<Lore> {
return this.http.get<Lore>(`${this.apiUrl}/${id}`);
}
createLore(lore: LoreCreate): Observable<Lore> {
return this.http.post<Lore>(this.apiUrl, lore);
}
updateLore(id: string, lore: LoreCreate): Observable<Lore> {
return this.http.put<Lore>(`${this.apiUrl}/${id}`, lore);
}
deleteLore(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
getLoreNodes(loreId: string): Observable<LoreNode[]> {
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
}
getLoreNodeById(id: string): Observable<LoreNode> {
return this.http.get<LoreNode>(`${this.nodesUrl}/${id}`);
}
createLoreNode(node: LoreNodeCreate): Observable<LoreNode> {
return this.http.post<LoreNode>(this.nodesUrl, node);
}
/** PUT complet — envoie l'objet entier au backend (qui attend un LoreNodeDTO). */
updateLoreNode(id: string, node: LoreNode): Observable<LoreNode> {
return this.http.put<LoreNode>(`${this.nodesUrl}/${id}`, node);
}
deleteLoreNode(id: string): Observable<void> {
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
}
searchLores(q: string): Observable<Lore[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Lore[]>(`${this.apiUrl}/search`, { params });
}
searchLoreNodes(q: string): Observable<LoreNode[]> {
const params = new HttpParams().set('q', q);
return this.http.get<LoreNode[]>(`${this.nodesUrl}/search`, { params });
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
/**
* Service centralisé pour le titre de l'onglet navigateur.
* Uniformise le format "LoreMind - <sujet>" partout dans l'app.
*
* Pourquoi un wrapper et pas Title directement ? Évite de dupliquer le préfixe
* "LoreMind - " dans chaque écran — si on veut changer le format un jour, un
* seul endroit à toucher.
*/
@Injectable({ providedIn: 'root' })
export class PageTitleService {
constructor(private title: Title) {}
/**
* Définit le titre de l'onglet au format "LoreMind - <subject>".
* Passer `null` (ou vide) remet juste "LoreMind" — utile pour les écrans
* listing qui n'ont pas de sujet spécifique.
*/
set(subject: string | null | undefined): void {
const s = subject?.trim();
this.title.setTitle(s ? `LoreMind - ${s}` : 'LoreMind');
}
}

View File

@@ -0,0 +1,26 @@
// Interfaces TypeScript pour PageDTO (Backend Java).
export interface Page {
id?: string;
loreId: string;
nodeId: string;
templateId?: string | null;
title: string;
values?: Record<string, string>;
/**
* Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images
* uploadees (Shared Kernel images). Structure separee de `values`.
*/
imageValues?: Record<string, string[]>;
notes?: string | null;
tags?: string[];
relatedPageIds?: string[];
}
/** Payload de création : seuls les champs structurels sont envoyés. */
export interface PageCreate {
loreId: string;
nodeId: string;
templateId: string;
title: string;
}

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Page, PageCreate } from './page.model';
/**
* Service HTTP pour la gestion des Pages.
* Port de sortie du Frontend vers le Backend Java (/api/pages).
*/
@Injectable({ providedIn: 'root' })
export class PageService {
private apiUrl = 'http://localhost:8080/api/pages';
constructor(private http: HttpClient) {}
/** Toutes les pages d'un Lore (flat, pour répartir ensuite par nodeId). */
getByLoreId(loreId: string): Observable<Page[]> {
const params = new HttpParams().set('loreId', loreId);
return this.http.get<Page[]>(this.apiUrl, { params });
}
/** Toutes les pages d'un noeud donné. */
getByNodeId(nodeId: string): Observable<Page[]> {
const params = new HttpParams().set('nodeId', nodeId);
return this.http.get<Page[]>(this.apiUrl, { params });
}
getById(id: string): Observable<Page> {
return this.http.get<Page>(`${this.apiUrl}/${id}`);
}
create(payload: PageCreate): Observable<Page> {
return this.http.post<Page>(this.apiUrl, payload);
}
update(id: string, page: Page): Observable<Page> {
return this.http.put<Page>(`${this.apiUrl}/${id}`, page);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
search(q: string): Observable<Page[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });
}
/**
* Demande à l'IA (Brain Python, via le Core Java) des suggestions de valeurs
* pour les champs dynamiques de la page. Ne modifie PAS la page en base —
* l'appelant est responsable de fusionner les valeurs et de sauvegarder.
*
* Peut prendre plusieurs dizaines de secondes selon le modèle LLM.
*/
generateValues(pageId: string): Observable<Record<string, string>> {
return this.http
.post<{ values: Record<string, string> }>(`${this.apiUrl}/${pageId}/generate`, {})
.pipe(map(res => res.values ?? {}));
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* Reflet de SettingsDTO cote Brain / SettingsController cote Core.
* `onemin_api_key_set` indique si une cle est configuree, sans la reveler.
*/
export interface AppSettings {
llm_provider: 'ollama' | 'onemin';
ollama_base_url: string;
llm_model: string;
onemin_model: string;
onemin_api_key_set: boolean;
}
/**
* Patch partiel — seuls les champs a modifier sont presents.
* `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien.
*/
export interface AppSettingsUpdate {
llm_provider?: 'ollama' | 'onemin';
ollama_base_url?: string;
llm_model?: string;
onemin_model?: string;
onemin_api_key?: string;
}
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly apiUrl = 'http://localhost:8080/api/settings';
// HTTP Basic : le browser gere le prompt natif de credentials au premier 401.
// withCredentials=true pour que les creds soient renvoyees sur les appels
// suivants en cross-origin (dev Angular sur :4200 -> core sur :8080).
private readonly authOptions = { withCredentials: true };
constructor(private http: HttpClient) {}
getSettings(): Observable<AppSettings> {
return this.http.get<AppSettings>(this.apiUrl, this.authOptions);
}
updateSettings(patch: AppSettingsUpdate): Observable<AppSettings> {
return this.http.put<AppSettings>(this.apiUrl, patch, this.authOptions);
}
listOllamaModels(): Observable<{ models: string[] }> {
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
}
listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> {
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
}
}
/** Un groupe de modeles 1min.ai regroupes par fournisseur (Anthropic, OpenAI, ...). */
export interface OneMinModelGroup {
provider: string;
models: string[];
}

View File

@@ -0,0 +1,36 @@
// Interfaces TypeScript pour TemplateDTO (Backend Java).
/**
* Type d'un champ de Template. Miroir de com.loremind.domain.lorecontext.FieldType.
* - 'TEXT' : champ textuel libre (rendu en textarea)
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
*/
export type FieldType = 'TEXT' | 'IMAGE';
/**
* Champ d'un Template : nom + type discriminant.
* Miroir de TemplateFieldDTO (backend).
*/
export interface TemplateField {
name: string;
type: FieldType;
}
export interface Template {
id?: string;
loreId: string;
name: string;
description: string;
defaultNodeId?: string | null;
fields: TemplateField[];
fieldCount?: number;
}
/** Payload de création : id absent, fieldCount absent (calculé côté serveur). */
export interface TemplateCreate {
loreId: string;
name: string;
description: string;
defaultNodeId?: string | null;
fields: TemplateField[];
}

View File

@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Template, TemplateCreate } from './template.model';
/**
* Service HTTP pour la gestion des Templates.
* Port de sortie du Frontend vers le Backend Java (/api/templates).
*/
@Injectable({ providedIn: 'root' })
export class TemplateService {
private apiUrl = 'http://localhost:8080/api/templates';
constructor(private http: HttpClient) {}
/** Tous les templates d'un Lore (alimente le panneau sidebar). */
getByLoreId(loreId: string): Observable<Template[]> {
const params = new HttpParams().set('loreId', loreId);
return this.http.get<Template[]>(this.apiUrl, { params });
}
getById(id: string): Observable<Template> {
return this.http.get<Template>(`${this.apiUrl}/${id}`);
}
create(payload: TemplateCreate): Observable<Template> {
return this.http.post<Template>(this.apiUrl, payload);
}
update(id: string, template: Template): Observable<Template> {
return this.http.put<Template>(`${this.apiUrl}/${id}`, template);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
search(q: string): Observable<Template[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Template[]>(`${this.apiUrl}/search`, { params });
}
}

View File

@@ -0,0 +1,103 @@
<div class="settings-page">
<header class="page-header">
<button class="btn-back" (click)="goBack()">
<lucide-icon [img]="ArrowLeft" [size]="16"></lucide-icon>
<span>Retour</span>
</button>
<h1>Parametres</h1>
</header>
<div *ngIf="errorMessage" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ errorMessage }}</span>
</div>
<div *ngIf="successMessage" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ successMessage }}</span>
</div>
<section class="card" *ngIf="settings">
<h2>Moteur IA</h2>
<p class="hint">Choix du fournisseur de modele de langage utilise par le chat et la generation de pages.</p>
<div class="form-row">
<label>Fournisseur</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="provider" value="ollama" [(ngModel)]="settings.llm_provider">
<span>Ollama (local)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span>
</label>
</div>
</div>
</section>
<!-- Bloc Ollama -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'">
<h2>Configuration Ollama</h2>
<div class="form-row">
<label for="ollama-url">URL du serveur Ollama</label>
<input id="ollama-url" type="text" [(ngModel)]="settings.ollama_base_url" placeholder="http://localhost:11434">
</div>
<div class="form-row">
<label for="ollama-model">Modele</label>
<div class="inline-select">
<select id="ollama-model" [(ngModel)]="settings.llm_model">
<option *ngIf="ollamaModels.length === 0" [value]="settings.llm_model">{{ settings.llm_model }}</option>
<option *ngFor="let m of ollamaModels" [value]="m">{{ m }}</option>
</select>
<button type="button" class="btn-secondary" (click)="refreshModels()" [disabled]="loadingModels">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
</button>
</div>
<p class="hint" *ngIf="ollamaModels.length === 0">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
</div>
</section>
<!-- Bloc 1min.ai -->
<section class="card" *ngIf="settings && settings.llm_provider === 'onemin'">
<h2>Configuration 1min.ai</h2>
<div class="form-row">
<label for="onemin-key">Cle API</label>
<input
id="onemin-key"
type="password"
[(ngModel)]="oneminApiKeyInput"
[placeholder]="settings.onemin_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API'">
<label class="checkbox" *ngIf="settings.onemin_api_key_set">
<input type="checkbox" [(ngModel)]="clearApiKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="onemin-provider">Fournisseur</label>
<select id="onemin-provider" [(ngModel)]="oneminProvider" (ngModelChange)="onProviderChange()">
<option *ngFor="let g of oneminGroups" [value]="g.provider">{{ g.provider }}</option>
</select>
</div>
<div class="form-row">
<label for="onemin-model">Modele</label>
<select id="onemin-model" [(ngModel)]="settings.onemin_model">
<option *ngFor="let m of currentProviderModels" [value]="m">{{ m }}</option>
</select>
</div>
</section>
<div class="actions" *ngIf="settings">
<button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,138 @@
.settings-page {
padding: 32px 48px;
max-width: 820px;
margin: 0 auto;
color: var(--color-text, #e8e8e8);
}
.page-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
}
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: transparent;
color: inherit;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
&:hover { background: rgba(255, 255, 255, 0.05); }
}
.card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 24px 28px;
margin-bottom: 20px;
h2 {
margin: 0 0 8px;
font-size: 1.15rem;
font-weight: 600;
}
}
.hint {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
margin: 4px 0 16px;
}
.form-row {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
label { font-size: 0.9rem; font-weight: 500; }
input[type="text"],
input[type="password"],
select {
padding: 9px 12px;
background: rgba(0, 0, 0, 0.25);
color: inherit;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: var(--color-accent, #7a5cff);
}
}
}
.radio-group { display: flex; gap: 24px; }
.radio, .checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.95rem;
}
.inline-select {
display: flex;
gap: 8px;
align-items: center;
select { flex: 1; }
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
border: 1px solid transparent;
}
.btn-primary {
background: var(--color-accent, #7a5cff);
color: #fff;
&:hover:not(:disabled) { filter: brightness(1.1); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
.btn-secondary {
background: transparent;
color: inherit;
border-color: rgba(255, 255, 255, 0.15);
&:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); }
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 24px;
}
.alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 0.9rem;
}
.alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; }
.alert-success { background: rgba(80, 200, 120, 0.15); color: #a2e8b6; }

View File

@@ -0,0 +1,153 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup } from '../services/settings.service';
/**
* Ecran de parametrage du LLM utilise par le Brain.
*
* Deux providers au choix :
* - Ollama (local) : on liste dynamiquement les modeles installes.
* - 1min.ai (cloud) : on fournit une cle API + on choisit dans un catalogue fixe.
*
* Les modifications sont persistees cote Brain dans data/settings.json
* (fichier local, usage mono-utilisateur) et appliquees a la prochaine
* requete chat / generate — pas besoin de redemarrer.
*/
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss']
})
export class SettingsComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly RefreshCw = RefreshCw;
readonly Save = Save;
readonly Check = Check;
readonly AlertCircle = AlertCircle;
settings: AppSettings | null = null;
ollamaModels: string[] = [];
oneminGroups: OneMinModelGroup[] = [];
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
oneminProvider: string = '';
loadingModels = false;
saving = false;
errorMessage = '';
successMessage = '';
/** Cle 1min.ai saisie — vide = on ne touche pas a la cle persistee. */
oneminApiKeyInput = '';
/** True si l'utilisateur a coche "effacer la cle". */
clearApiKey = false;
constructor(
private settingsService: SettingsService,
private router: Router
) {}
ngOnInit(): void {
this.loadSettings();
}
loadSettings(): void {
this.settingsService.getSettings().subscribe({
next: (s) => {
this.settings = { ...s };
this.refreshModels();
},
error: (err) => this.errorMessage = this.extractError(err, 'Impossible de charger les parametres.')
});
}
refreshModels(): void {
if (!this.settings) return;
this.loadingModels = true;
this.settingsService.listOllamaModels().subscribe({
next: (r) => this.ollamaModels = r.models,
error: () => this.ollamaModels = [],
complete: () => this.loadingModels = false
});
this.settingsService.listOneMinModels().subscribe({
next: (r) => {
this.oneminGroups = r.groups;
this.syncOneminProviderFromModel();
},
error: () => this.oneminGroups = []
});
}
/** Deduit le fournisseur a partir du modele actuellement configure. */
private syncOneminProviderFromModel(): void {
if (!this.settings) return;
const currentModel = this.settings.onemin_model;
const found = this.oneminGroups.find(g => g.models.includes(currentModel));
this.oneminProvider = found ? found.provider : (this.oneminGroups[0]?.provider ?? '');
}
/** Retourne la liste des modeles du fournisseur selectionne. */
get currentProviderModels(): string[] {
const group = this.oneminGroups.find(g => g.provider === this.oneminProvider);
return group ? group.models : [];
}
/** Quand on change de fournisseur, bascule automatiquement sur son premier modele. */
onProviderChange(): void {
if (!this.settings) return;
const models = this.currentProviderModels;
if (models.length > 0 && !models.includes(this.settings.onemin_model)) {
this.settings.onemin_model = models[0];
}
}
save(): void {
if (!this.settings) return;
this.saving = true;
this.errorMessage = '';
this.successMessage = '';
const patch: AppSettingsUpdate = {
llm_provider: this.settings.llm_provider,
ollama_base_url: this.settings.ollama_base_url,
llm_model: this.settings.llm_model,
onemin_model: this.settings.onemin_model
};
if (this.clearApiKey) {
patch.onemin_api_key = '';
} else if (this.oneminApiKeyInput.trim()) {
patch.onemin_api_key = this.oneminApiKeyInput.trim();
}
this.settingsService.updateSettings(patch).subscribe({
next: (s) => {
this.settings = { ...s };
this.oneminApiKeyInput = '';
this.clearApiKey = false;
this.successMessage = 'Parametres sauvegardes.';
this.saving = false;
},
error: (err) => {
this.errorMessage = this.extractError(err, 'Echec de la sauvegarde.');
this.saving = false;
}
});
}
goBack(): void {
this.router.navigate(['/lore']);
}
private extractError(err: any, fallback: string): string {
if (err?.error?.detail) return String(err.error.detail);
if (err?.message) return err.message;
return fallback;
}
}

View File

@@ -0,0 +1,81 @@
<aside class="drawer" [class.drawer-open]="isOpen" aria-label="Assistant IA">
<header class="drawer-header">
<h2>Assistant IA</h2>
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</header>
<div #messagesContainer class="messages">
<!-- Message d'accueil (non-stocké dans `messages`, toujours visible tant que la conversation est vide). -->
<div class="msg msg-assistant" *ngIf="messages.length === 0 && !currentAssistantText">
{{ welcomeMessage }}
</div>
<!-- Historique -->
<ng-container *ngFor="let m of messages">
<div class="msg" [class.msg-user]="m.role === 'user'" [class.msg-assistant]="m.role === 'assistant'">
{{ m.content }}
</div>
</ng-container>
<!-- Bulle en cours de streaming -->
<div class="msg msg-assistant msg-streaming" *ngIf="currentAssistantText">
{{ currentAssistantText }}<span class="caret"></span>
</div>
<!-- Indicateur pendant la phase "en train de réfléchir" (avant le premier token) -->
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite">
<span></span><span></span><span></span>
</div>
<!-- Erreur locale au drawer -->
<div class="msg msg-error" *ngIf="errorMessage" role="alert">
{{ errorMessage }}
</div>
</div>
<!-- Action primaire (optionnelle) : ne passe PAS par le chat -->
<div class="primary-action" *ngIf="primaryAction">
<button
type="button"
class="primary-btn"
(click)="onPrimaryAction()"
[disabled]="isStreaming">
<lucide-icon [img]="Wand2" [size]="14"></lucide-icon>
{{ primaryAction.label }}
</button>
</div>
<!-- Suggestions rapides -->
<div class="quick-suggestions" *ngIf="quickSuggestions.length">
<p class="quick-label">Suggestions rapides :</p>
<div class="quick-list">
<button
type="button"
class="quick-btn"
*ngFor="let s of quickSuggestions"
(click)="useQuickSuggestion(s)"
[disabled]="isStreaming">
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon>
{{ s }}
</button>
</div>
</div>
<!-- Zone de saisie -->
<form class="input-row" (ngSubmit)="send()">
<input
type="text"
[(ngModel)]="input"
name="chatInput"
placeholder="Posez une question..."
[disabled]="isStreaming"
autocomplete="off" />
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</form>
</aside>

View File

@@ -0,0 +1,261 @@
:host {
// Le drawer lui-même gère son positionnement fixed. Rien à prévoir côté host.
display: contents;
}
.drawer {
position: fixed;
top: 0;
right: 0;
width: 380px;
height: 100vh;
background: #0f0f1a;
border-left: 1px solid #1e1e3a;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 1000;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
}
.drawer-open {
transform: translateX(0);
}
// --- Header --------------------------------------------------------------
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #1e1e3a;
flex-shrink: 0;
h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: white;
}
.close-btn {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
display: flex;
&:hover {
color: white;
background: #1e1e3a;
}
}
}
// --- Zone messages -------------------------------------------------------
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.msg {
max-width: 85%;
padding: 0.6rem 0.85rem;
border-radius: 10px;
font-size: 0.88rem;
line-height: 1.45;
white-space: pre-wrap;
word-wrap: break-word;
}
.msg-assistant {
align-self: flex-start;
background: #1a1a2e;
color: #e5e7eb;
border: 1px solid #2a2a3d;
}
.msg-user {
align-self: flex-end;
background: #6c63ff;
color: white;
}
.msg-streaming {
// Le caret clignotant à la fin donne la sensation de "l'IA est en train d'écrire"
.caret {
display: inline-block;
width: 6px;
height: 1em;
background: #a5b4fc;
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 1s step-end infinite;
}
}
.msg-error {
align-self: stretch;
background: #3f1f1f;
color: #fca5a5;
border: 1px solid #7f1d1d;
font-size: 0.85rem;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
// --- Indicateur "typing" -------------------------------------------------
.typing-indicator {
display: flex;
gap: 0.3rem;
padding: 0.6rem 0.85rem;
align-self: flex-start;
span {
width: 6px;
height: 6px;
background: #6c63ff;
border-radius: 50%;
animation: bounce 1.2s infinite ease-in-out both;
}
span:nth-child(2) { animation-delay: 0.15s; }
span:nth-child(3) { animation-delay: 0.3s; }
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
// --- Action primaire -----------------------------------------------------
.primary-action {
padding: 0.75rem 1.25rem 0;
flex-shrink: 0;
.primary-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
width: 100%;
padding: 0.65rem 0.9rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 6px;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
&:hover:not(:disabled) { background: #5a52e0; }
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// --- Suggestions rapides -------------------------------------------------
.quick-suggestions {
padding: 0.75rem 1.25rem 0;
border-top: 1px solid #1e1e3a;
flex-shrink: 0;
.quick-label {
font-size: 0.75rem;
color: #9ca3af;
margin: 0 0 0.5rem;
}
.quick-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.quick-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 4px;
color: #d1d5db;
font-size: 0.78rem;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) {
background: #2a2a3d;
color: white;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// --- Zone de saisie ------------------------------------------------------
.input-row {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1.25rem 1rem;
flex-shrink: 0;
input {
flex: 1;
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.55rem 0.75rem;
border-radius: 6px;
font-size: 0.88rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #6c63ff;
}
&:disabled {
opacity: 0.5;
}
}
.send-btn {
background: #6c63ff;
color: white;
border: none;
border-radius: 6px;
width: 38px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
&:hover:not(:disabled) { background: #5a52e0; }
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}

View File

@@ -0,0 +1,199 @@
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X, Send, Sparkles, Lightbulb, Wand2 } from 'lucide-angular';
import { Subscription } from 'rxjs';
import { AiChatService, ChatMessage, NarrativeEntityType } from '../../services/ai-chat.service';
/**
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
* Utilisée pour les actions "spéciales" qui NE passent PAS par le chat
* (ex: "Remplir automatiquement tous les champs" → déclenche le one-shot b4).
*/
export interface ChatPrimaryAction {
label: string;
}
/**
* Drawer de chat IA réutilisable — panneau fixe à droite de l'écran.
*
* Usage minimal :
* <app-ai-chat-drawer
* [loreId]="loreId"
* [isOpen]="chatOpen"
* [quickSuggestions]="['Développe l'histoire', ...]"
* (close)="chatOpen = false">
* </app-ai-chat-drawer>
*
* Contrainte de design : conversation éphémère (on perd tout à la fermeture
* ou à la destruction du composant — choix MVP assumé).
*/
@Component({
selector: 'app-ai-chat-drawer',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './ai-chat-drawer.component.html',
styleUrls: ['./ai-chat-drawer.component.scss']
})
export class AiChatDrawerComponent implements OnDestroy {
readonly X = X;
readonly Send = Send;
readonly Sparkles = Sparkles;
readonly Lightbulb = Lightbulb;
readonly Wand2 = Wand2;
/**
* Mode Lore : fournir `loreId` (et optionnellement `pageId`).
* Mode Campagne : fournir `campaignId` (et optionnellement `entityType`+`entityId`).
* Les deux modes sont exclusifs — si `campaignId` est non-vide, on route
* vers l'endpoint Campagne, sinon vers l'endpoint Lore.
*/
@Input() loreId = '';
/**
* Optionnel : ID d'une page précise en cours d'édition. Si fourni, le
* backend focalise l'IA sur cette page (template, champs, valeurs) via
* un bloc "PAGE EN COURS" dans le system prompt. Sans cet ID, le chat
* reste générique au Lore.
*/
@Input() pageId: string | null = null;
/** ID de la Campagne — active le mode chat Campagne si non-vide. */
@Input() campaignId: string | null = null;
/** Optionnel : "arc"|"chapter"|"scene" — focalise l'IA sur une entité narrative. */
@Input() entityType: NarrativeEntityType | null = null;
/** Optionnel : ID de l'entité narrative en cours d'édition. */
@Input() entityId: string | null = null;
@Input() isOpen = false;
/** Texte accueil affiché au premier ouverture (avant tout échange). */
@Input() welcomeMessage = 'Bonjour ! Je peux vous aider à développer cette page. Que souhaitez-vous créer ?';
/** Suggestions rapides cliquables en bas (hardcodées par le parent, MVP). */
@Input() quickSuggestions: string[] = [];
/** Action primaire optionnelle (ex: "Remplir automatiquement") — ne passe PAS par le chat. */
@Input() primaryAction: ChatPrimaryAction | null = null;
/**
* Instructions système supplémentaires injectées en tête de la conversation
* envoyée au backend, INVISIBLES côté UI. Usage : mode wizard, où on veut
* contextualiser l'IA (template cible, format JSON attendu) sans polluer
* l'historique visuel.
*/
@Input() systemPromptAddon: string | null = null;
@Output() close = new EventEmitter<void>();
/** Émis au clic sur l'action primaire — le parent gère entièrement (one-shot, etc.). */
@Output() primaryActionClick = new EventEmitter<void>();
/** Émis à chaque fin de réponse assistant — utile pour parser côté parent (ex: bloc <values> du wizard). */
@Output() assistantReply = new EventEmitter<string>();
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
/** Conversation en cours (user + assistant). Le welcome n'est pas dedans — rendu séparément. */
messages: ChatMessage[] = [];
/** Texte en cours de streaming (écrit token par token, pas encore poussé dans `messages`). */
currentAssistantText = '';
/** Champ de saisie. */
input = '';
/** Stream en cours ? Désactive le bouton envoyer + les suggestions rapides. */
isStreaming = false;
/** Dernier message d'erreur (affiché dans une bannière locale au drawer). */
errorMessage: string | null = null;
private streamSub: Subscription | null = null;
constructor(private readonly chatService: AiChatService) {}
// --- Handlers UI --------------------------------------------------------
onClose(): void {
this.abortStream();
this.close.emit();
}
/** Envoi explicite depuis le formulaire (Entrée ou bouton envoyer). */
send(): void {
const text = this.input.trim();
if (!text || this.isStreaming) return;
this.sendUserMessage(text);
this.input = '';
}
/** Envoi depuis une suggestion rapide (bouton cliquable en bas). */
useQuickSuggestion(suggestion: string): void {
if (this.isStreaming) return;
this.sendUserMessage(suggestion);
}
/** Clic sur l'action primaire — on délègue entièrement au parent. */
onPrimaryAction(): void {
if (this.isStreaming) return;
this.primaryActionClick.emit();
}
// --- Logique envoi + streaming -----------------------------------------
private sendUserMessage(text: string): void {
this.errorMessage = null;
this.messages.push({ role: 'user', content: text });
this.currentAssistantText = '';
this.isStreaming = true;
this.scrollToBottom();
// Construit la liste effectivement envoyée au backend : systemPromptAddon
// (si fourni) préfixé, puis l'historique visible. Le system n'est PAS stocké
// dans this.messages → reste invisible côté UI.
const payload = this.systemPromptAddon
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
: this.messages;
const stream$ = this.campaignId
? this.chatService.streamChatForCampaign(this.campaignId, payload, this.entityType, this.entityId)
: this.chatService.streamChat(this.loreId, payload, this.pageId);
this.streamSub = stream$.subscribe({
next: (event) => {
if (event.type === 'token') {
this.currentAssistantText += event.value;
this.scrollToBottom();
}
// 'done' : l'Observable va compléter → géré par complete()
},
error: (err) => {
this.isStreaming = false;
this.errorMessage = err?.message ?? 'Erreur inconnue.';
this.currentAssistantText = '';
},
complete: () => {
// On fige le texte streamé en message assistant réel, puis on reset le buffer.
const reply = this.currentAssistantText;
if (reply) {
this.messages.push({ role: 'assistant', content: reply });
this.assistantReply.emit(reply);
}
this.currentAssistantText = '';
this.isStreaming = false;
this.scrollToBottom();
}
});
}
private abortStream(): void {
this.streamSub?.unsubscribe();
this.streamSub = null;
this.isStreaming = false;
this.currentAssistantText = '';
}
/**
* Scroll différé au prochain tick : donne à Angular le temps de rendre
* le nouveau contenu avant qu'on mesure/ajuste la position du scroll.
*/
private scrollToBottom(): void {
queueMicrotask(() => {
const el = this.messagesContainer?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
}
ngOnDestroy(): void {
this.abortStream();
}
}

View File

@@ -0,0 +1,12 @@
<nav class="breadcrumb" aria-label="Fil d'Ariane" *ngIf="items.length">
<ol>
<li *ngFor="let item of items; let last = last; trackBy: trackByIndex"
class="breadcrumb-item"
[class.current]="last">
<a *ngIf="item.route && !last"
[routerLink]="item.route"
class="breadcrumb-link">{{ item.label }}</a>
<span *ngIf="!item.route || last" class="breadcrumb-text">{{ item.label }}</span>
</li>
</ol>
</nav>

View File

@@ -0,0 +1,50 @@
.breadcrumb {
font-size: 0.82rem;
color: #6b7280;
margin-bottom: 1rem;
ol {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
list-style: none;
margin: 0;
padding: 0;
}
}
.breadcrumb-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
&::before {
content: '';
color: #4b5563;
}
&:first-child::before {
content: none;
}
&.current .breadcrumb-text {
color: #e5e7eb;
font-weight: 500;
}
}
.breadcrumb-link {
color: #9ca3af;
text-decoration: none;
transition: color 0.15s;
&:hover {
color: #a5b4fc;
text-decoration: underline;
}
}
.breadcrumb-text {
color: inherit;
}

View File

@@ -0,0 +1,35 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
* Un niveau dans le fil d'Ariane.
* Si `route` est défini, l'item est cliquable (navigation).
* Sinon, c'est la position courante (dernier niveau, non-cliquable).
*/
export interface BreadcrumbItem {
label: string;
route?: string | any[];
}
/**
* Composant réutilisable de fil d'Ariane.
* Utilisation type :
* <app-breadcrumb [items]="[
* { label: 'Mon Univers', route: ['/lore', loreId] },
* { label: 'PNJ', route: ['/lore', loreId, 'folders', nodeId, 'edit'] },
* { label: 'Aldric' }
* ]"></app-breadcrumb>
*/
@Component({
selector: 'app-breadcrumb',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './breadcrumb.component.html',
styleUrls: ['./breadcrumb.component.scss']
})
export class BreadcrumbComponent {
@Input() items: BreadcrumbItem[] = [];
trackByIndex = (i: number) => i;
}

Some files were not shown because too many files have changed in this diff Show More