Mise à jour avec la possibilité de mettre des images

This commit is contained in:
2026-04-21 02:47:09 +02:00
parent 5b133aa2fe
commit 1a5b6f8d79
125 changed files with 4866 additions and 348 deletions

View File

@@ -10,14 +10,18 @@ export const routes: Routes = [
{ 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-edit/page-edit.component').then(m => m.PageEditComponent) },
{ 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-edit/arc-edit.component').then(m => m.ArcEditComponent) },
{ 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-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
{ 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/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-edit/scene-edit.component').then(m => m.SceneEditComponent) },
{ 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: '', redirectTo: '/lore', pathMatch: 'full' }
];

View File

@@ -80,7 +80,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
campaignId: this.campaignId,
order: this.existingArcCount + 1
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
error: () => console.error('Erreur lors de la création de l\'arc')
});
}

View File

@@ -1,12 +1,34 @@
<div class="edit-page">
<div class="page-header">
<h1>{{ arc?.name || 'Arc' }}</h1>
<p class="subtitle">Arc narratif</p>
<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
@@ -108,3 +130,14 @@
</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

@@ -3,6 +3,19 @@
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;

View File

@@ -4,7 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2 } from 'lucide-angular';
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';
@@ -13,6 +13,8 @@ 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.
@@ -26,12 +28,23 @@ import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link
@Component({
selector: 'app-arc-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent],
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 = '';
@@ -45,6 +58,9 @@ export class ArcEditComponent implements OnInit, OnDestroy {
/** 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,
@@ -102,6 +118,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
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,
@@ -143,9 +160,10 @@ export class ArcEditComponent implements OnInit, OnDestroy {
gmNotes: this.form.value.gmNotes,
rewards: this.form.value.rewards,
resolution: this.form.value.resolution,
relatedPageIds: this.relatedPageIds
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la sauvegarde')
});
}
@@ -159,7 +177,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]);
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]);
}
ngOnDestroy(): void {

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

@@ -59,10 +59,24 @@ export function loadCampaignTreeData(
}
export function buildCampaignTree(campaignId: string, data: CampaignTreeData): TreeItem[] {
return data.arcs.map(arc => {
const chapterItems: TreeItem[] = (data.chaptersByArc[arc.id!] ?? []).map(ch => {
const sceneItems: TreeItem[] = (data.scenesByChapter[ch.id!] ?? []).map(sc => ({
id: sc.id!,
// 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}`
}));
@@ -73,7 +87,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`
});
return {
id: ch.id!,
id: `chapter-${ch.id}`,
label: ch.name,
children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`
@@ -86,7 +100,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`
});
return {
id: arc.id!,
id: `arc-${arc.id}`,
label: arc.name,
children: chapterItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}`

View File

@@ -82,7 +82,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
arcId: this.arcId,
order: this.existingChapterCount + 1
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
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')
});
}

View File

@@ -1,12 +1,34 @@
<div class="edit-page">
<div class="page-header">
<h1>{{ chapter?.name || 'Chapitre' }}</h1>
<p class="subtitle">Chapitre</p>
<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
@@ -90,3 +112,14 @@
</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

@@ -3,6 +3,19 @@
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;

View File

@@ -4,7 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2 } from 'lucide-angular';
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';
@@ -13,6 +13,8 @@ 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.
@@ -24,12 +26,23 @@ import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link
@Component({
selector: 'app-chapter-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent],
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 = '';
@@ -40,6 +53,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
availablePages: Page[] = [];
loreId: string | null = null;
relatedPageIds: string[] = [];
illustrationImageIds: string[] = [];
constructor(
private fb: FormBuilder,
@@ -96,6 +110,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.form.patchValue({
name: chapter.name,
description: chapter.description ?? '',
@@ -132,9 +147,10 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
gmNotes: this.form.value.gmNotes,
playerObjectives: this.form.value.playerObjectives,
narrativeStakes: this.form.value.narrativeStakes,
relatedPageIds: this.relatedPageIds
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
error: () => console.error('Erreur lors de la sauvegarde')
});
}
@@ -148,7 +164,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]);
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
}
ngOnDestroy(): void {

View File

@@ -0,0 +1,60 @@
<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-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,111 @@
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, 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;
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'
]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -84,7 +84,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
chapterId: this.chapterId,
order: this.existingSceneCount + 1
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
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')
});
}

View File

@@ -1,12 +1,34 @@
<div class="edit-page">
<div class="page-header">
<h1>{{ scene?.name || 'Scène' }}</h1>
<p class="subtitle">Scène</p>
<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
@@ -135,3 +157,14 @@
</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

@@ -3,6 +3,19 @@
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;

View File

@@ -4,7 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2 } from 'lucide-angular';
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';
@@ -14,6 +14,8 @@ 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.
@@ -22,12 +24,23 @@ import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link
@Component({
selector: 'app-scene-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent],
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 = '';
@@ -39,6 +52,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
availablePages: Page[] = [];
loreId: string | null = null;
relatedPageIds: string[] = [];
illustrationImageIds: string[] = [];
constructor(
private fb: FormBuilder,
@@ -108,6 +122,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
this.form.patchValue({
name: scene.name,
description: scene.description ?? '',
@@ -154,9 +169,10 @@ export class SceneEditComponent implements OnInit, OnDestroy {
choicesConsequences: this.form.value.choicesConsequences,
combatDifficulty: this.form.value.combatDifficulty,
enemies: this.form.value.enemies,
relatedPageIds: this.relatedPageIds
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
error: () => console.error('Erreur lors de la sauvegarde')
});
}
@@ -170,7 +186,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]);
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]);
}
ngOnDestroy(): void {

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

@@ -72,7 +72,7 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
const children: TreeItem[] = [
...subFolders.map(buildFolderItem),
...nodePages.map(p => ({
id: p.id!,
id: `page-${p.id}`,
label: p.title,
route: `/lore/${lore.id}/pages/${p.id}`
})),
@@ -89,8 +89,11 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
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: node.id!,
id: `folder-${node.id}`,
label: node.name,
iconKey: node.icon ?? undefined,
route: `/lore/${lore.id}/folders/${node.id}/edit`,

View File

@@ -120,7 +120,10 @@ export class PageCreateComponent implements OnInit, OnDestroy {
templateId: this.selectedTemplateId!,
title: raw.title
}).subscribe({
next: created => this.router.navigate(['/lore', this.loreId, 'pages', created.id]),
// 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')
});
}
@@ -190,9 +193,11 @@ export class PageCreateComponent implements OnInit, OnDestroy {
const tpl = this.selectedTemplate;
if (!tpl || !this.canSubmit) return null;
const title = this.form.value.title as string;
const fieldsList = tpl.fields.length ? tpl.fields.map(f => `"${f}"`).join(', ') : '(aucun champ)';
const exampleJson = tpl.fields.length
? '{\n ' + tpl.fields.map(f => `"${f}": "valeur proposée"`).join(',\n ') + '\n}'
// 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

View File

@@ -16,6 +16,7 @@
<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
@@ -47,15 +48,27 @@
<!-- Champs dynamiques du template -------------------------------- -->
<ng-container *ngIf="template?.fields?.length">
<h2 class="section-title">Champs</h2>
<div class="field" *ngFor="let fieldName of template!.fields">
<label>{{ fieldName }}</label>
<textarea
[(ngModel)]="values[fieldName]"
[name]="'value_' + fieldName"
rows="4"
[placeholder]="'Valeur pour ' + fieldName + '...'">
</textarea>
</div>
<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 --------------------------------------------------------- -->

View File

@@ -1,7 +1,7 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, Sparkles } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
@@ -17,6 +17,7 @@ import { ChipsInputComponent } from '../../shared/chips-input/chips-input.compon
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';
/**
@@ -35,7 +36,7 @@ import { Lore } from '../../services/lore.model';
@Component({
selector: 'app-page-edit',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent],
imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent],
templateUrl: './page-edit.component.html',
styleUrls: ['./page-edit.component.scss']
})
@@ -55,8 +56,13 @@ export class PageEditComponent implements OnInit, OnDestroy {
title = '';
nodeId = '';
notes = '';
/** Valeurs des champs dynamiques, indexées par fieldName. */
/** 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). */
@@ -156,13 +162,22 @@ export class PageEditComponent implements OnInit, OnDestroy {
this.title = page.title;
this.nodeId = page.nodeId;
this.notes = page.notes ?? '';
// On initialise une entrée pour chaque field du template, même vide,
// 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 ?? []) {
base[f] = page.values?.[f] ?? '';
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);
@@ -176,11 +191,12 @@ export class PageEditComponent implements OnInit, OnDestroy {
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]),
next: () => this.router.navigate(['/lore', this.loreId, 'pages', this.pageId]),
error: () => console.error('Erreur lors de la sauvegarde de la page')
});
}
@@ -230,10 +246,12 @@ export class PageEditComponent implements OnInit, OnDestroy {
* - 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 ?? []) {
const suggestion = suggestions[field];
if (field.type !== 'TEXT') continue;
const suggestion = suggestions[field.name];
if (suggestion && suggestion.trim()) {
this.values[field] = suggestion;
this.values[field.name] = suggestion;
}
}
}

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

@@ -38,7 +38,17 @@
<ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index">
<span class="field-chip">{{ f }}</span>
<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>
@@ -52,12 +62,20 @@
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du champ..."
(keydown.enter)="$event.preventDefault(); addField()" />
<button type="button" class="btn-add" (click)="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">Ajoutez les champs qui apparaîtront dans chaque page</p>
<p class="hint">Les champs Texte sont editables librement et utilisables par l'IA. Les champs Image hebergent une galerie d'illustrations.</p>
</div>

View File

@@ -97,11 +97,44 @@
.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 {

View File

@@ -2,12 +2,13 @@ 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 } from 'lucide-angular';
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';
/**
@@ -26,14 +27,24 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
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 définis. */
fields: string[] = ['Nom', 'Description'];
/**
* 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,
@@ -61,15 +72,27 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
addField(): void {
const name = this.newFieldName.trim();
if (!name || this.fields.includes(name)) return;
this.fields = [...this.fields, name];
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;

View File

@@ -44,7 +44,16 @@
<ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index">
<span class="field-chip">{{ f }}</span>
<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>
@@ -58,12 +67,20 @@
[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">Définissez les champs qui apparaîtront dans les pages créées avec ce template</p>
<p class="hint">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p>
</div>

View File

@@ -103,9 +103,39 @@
padding: 0.6rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
display: flex;
display: inline-flex;
align-items: center;
justify-content: space-between;
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 {

View File

@@ -3,14 +3,14 @@ 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 } from 'lucide-angular';
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 { Template } from '../../services/template.model';
import { FieldType, Template, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/**
@@ -28,14 +28,17 @@ 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: string[] = [];
fields: TemplateField[] = [];
newFieldName = '';
newFieldType: FieldType = 'TEXT';
constructor(
private fb: FormBuilder,
@@ -70,7 +73,12 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
private hydrate(template: Template): void {
this.template = template;
this.fields = [...template.fields];
// 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,
@@ -81,8 +89,9 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
addField(): void {
const name = this.newFieldName.trim();
if (!name || this.fields.includes(name)) return;
this.fields = [...this.fields, name];
if (!name) return;
if (this.fields.some(f => f.name === name)) return;
this.fields = [...this.fields, { name, type: this.newFieldType }];
this.newFieldName = '';
}
@@ -90,6 +99,14 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
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;

View File

@@ -28,12 +28,16 @@ export type ChatStreamEvent =
* 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 endpoint = 'http://localhost:8080/api/ai/chat/stream';
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é.
* 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 ;
@@ -46,15 +50,39 @@ export class AiChatService {
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();
// Payload : pageId inclus uniquement s'il est fourni et non vide, pour
// garder le comportement "chat générique au Lore" par défaut.
const body: Record<string, unknown> = { loreId, messages };
if (pageId) body['pageId'] = pageId;
fetch(this.endpoint, {
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -35,6 +35,9 @@ export interface Arc {
/** 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)
@@ -51,6 +54,7 @@ export interface ArcCreate {
resolution?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
export interface Chapter {
@@ -66,6 +70,7 @@ export interface Chapter {
narrativeStakes?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
export interface ChapterCreate {
@@ -79,6 +84,7 @@ export interface ChapterCreate {
narrativeStakes?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
export interface Scene {
@@ -99,6 +105,7 @@ export interface Scene {
enemies?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}
export interface SceneCreate {
@@ -117,4 +124,5 @@ export interface SceneCreate {
enemies?: string;
relatedPageIds?: string[];
illustrationImageIds?: string[];
}

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

@@ -7,6 +7,11 @@ export interface Page {
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[];

View File

@@ -1,12 +1,28 @@
// 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: string[];
fields: TemplateField[];
fieldCount?: number;
}
@@ -16,5 +32,5 @@ export interface TemplateCreate {
name: string;
description: string;
defaultNodeId?: string | null;
fields: string[];
fields: TemplateField[];
}

View File

@@ -3,7 +3,7 @@ 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 } from '../../services/ai-chat.service';
import { AiChatService, ChatMessage, NarrativeEntityType } from '../../services/ai-chat.service';
/**
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
@@ -42,6 +42,12 @@ export class AiChatDrawerComponent implements OnDestroy {
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
@@ -50,6 +56,13 @@ export class AiChatDrawerComponent implements OnDestroy {
* 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 ?';
@@ -131,7 +144,11 @@ export class AiChatDrawerComponent implements OnDestroy {
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
: this.messages;
this.streamSub = this.chatService.streamChat(this.loreId, payload, this.pageId).subscribe({
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;

View File

@@ -0,0 +1,47 @@
<!-- Grille de vignettes + uploader si editable. -->
<div class="gallery"
*ngIf="imageIds.length > 0 || editable; else empty">
<div class="gallery-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<!-- Bouton + (uploader compact), uniquement en mode edition -->
<app-image-uploader
*ngIf="editable"
[compact]="true"
(uploaded)="onUploaded($event)">
</app-image-uploader>
</div>
<!-- Etat vide (lecture uniquement). -->
<ng-template #empty>
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
</div>
</ng-template>
<!-- Lightbox : image plein ecran sur fond noir, clic pour fermer. -->
<div class="lightbox-backdrop"
*ngIf="lightboxId"
(click)="closeLightbox()"
role="dialog"
aria-label="Image en plein ecran">
<img [src]="urlFor(lightboxId)" alt="Image agrandie" class="lightbox-image" />
<button type="button" class="lightbox-close" (click)="closeLightbox()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="24"></lucide-icon>
</button>
</div>

View File

@@ -0,0 +1,107 @@
.gallery {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
align-items: flex-start;
}
.gallery-tile {
position: relative;
width: 120px;
height: 120px;
border-radius: 6px;
overflow: hidden;
background: #1a1a2e;
border: 1px solid #2a2a3d;
cursor: zoom-in;
transition: border-color 0.15s, transform 0.15s;
&:hover {
border-color: #6c63ff;
transform: translateY(-2px);
.gallery-remove { opacity: 1; }
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.gallery-remove {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: rgba(17, 17, 30, 0.85);
color: #fca5a5;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
&:hover { background: #7f1d1d; color: white; }
}
.gallery-empty {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #4b5563;
font-size: 0.85rem;
font-style: italic;
}
// Lightbox plein ecran
.lightbox-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
cursor: zoom-out;
animation: fade-in 0.15s ease-out;
}
.lightbox-image {
max-width: 95vw;
max-height: 90vh;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
}
.lightbox-close {
position: fixed;
top: 1rem;
right: 1rem;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(30, 30, 60, 0.8);
color: white;
cursor: pointer;
transition: background 0.15s;
&:hover { background: #6c63ff; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}

View File

@@ -0,0 +1,76 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
/**
* Composant reutilisable de galerie d'images.
*
* Deux modes :
* - editable=false (defaut) : affichage en grille, clic sur une image ouvre
* un lightbox plein ecran pour zoomer.
* - editable=true : bouton "+ ajouter" en fin de grille (via app-image-uploader),
* chaque vignette a un bouton "X" pour la retirer.
*
* La galerie raisonne sur une liste d'IDs d'images (string[]). Elle ne stocke
* pas les objets Image eux-memes : les thumbs utilisent `imageService.contentUrl(id)`
* directement comme src. Le navigateur cache les binaires via Cache-Control immutable
* pose par le backend, donc aucune requete redondante.
*
* Usage :
* <app-image-gallery [imageIds]="scene.illustrationImageIds"></app-image-gallery>
* <app-image-gallery [imageIds]="tempIds" [editable]="true"
* (imageIdsChange)="tempIds = $event"></app-image-gallery>
*/
@Component({
selector: 'app-image-gallery',
standalone: true,
imports: [CommonModule, LucideAngularModule, ImageUploaderComponent],
templateUrl: './image-gallery.component.html',
styleUrls: ['./image-gallery.component.scss']
})
export class ImageGalleryComponent {
readonly X = X;
readonly ImageIcon = ImageIcon;
/** IDs d'images a afficher. */
@Input() imageIds: string[] = [];
/** Mode edition : afficher le bouton d'ajout + les boutons de suppression. */
@Input() editable = false;
/** Emet la nouvelle liste quand l'utilisateur ajoute/retire une image. */
@Output() imageIdsChange = new EventEmitter<string[]>();
/** ID de l'image actuellement ouverte en lightbox (null = ferme). */
lightboxId: string | null = null;
constructor(private imageService: ImageService) {}
/** URL absolue du binaire d'une image. */
urlFor(id: string): string {
return this.imageService.contentUrl(id);
}
onUploaded(image: Image): void {
this.imageIdsChange.emit([...this.imageIds, image.id]);
}
remove(id: string, event: MouseEvent): void {
event.stopPropagation(); // Evite d'ouvrir le lightbox en cliquant sur X.
// On supprime aussi cote serveur pour ne pas laisser d'image orpheline.
// Best-effort : on n'attend pas le retour pour emettre la nouvelle liste.
this.imageService.delete(id).subscribe({ error: () => {} });
this.imageIdsChange.emit(this.imageIds.filter(i => i !== id));
}
openLightbox(id: string): void {
this.lightboxId = id;
}
closeLightbox(): void {
this.lightboxId = null;
}
}

View File

@@ -0,0 +1,51 @@
<!-- Mode compact : bouton "+ ajouter" carre, utilise dans les galeries. -->
<ng-container *ngIf="compact; else dropZone">
<label class="upload-compact" [class.loading]="uploading" [title]="errorMessage || 'Ajouter une image'">
<ng-container *ngIf="!uploading; else spinner">
<lucide-icon [img]="Upload" [size]="18"></lucide-icon>
<span>Ajouter</span>
</ng-container>
<input type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
(change)="onFileSelected($event)"
[disabled]="uploading"
hidden />
</label>
<p *ngIf="errorMessage" class="upload-error-inline">
<lucide-icon [img]="AlertCircle" [size]="12"></lucide-icon>
{{ errorMessage }}
</p>
</ng-container>
<!-- Mode standard : grande drop-zone cliquable. -->
<ng-template #dropZone>
<label class="upload-zone"
[class.drag-over]="dragOver"
[class.loading]="uploading"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave()"
(drop)="onDrop($event)">
<ng-container *ngIf="!uploading; else spinner">
<lucide-icon [img]="Upload" [size]="32"></lucide-icon>
<p class="upload-zone-title">Glisse une image ici</p>
<p class="upload-zone-hint">ou clique pour choisir un fichier (JPEG, PNG, WebP, GIF, max 10 Mo)</p>
</ng-container>
<input type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
(change)="onFileSelected($event)"
[disabled]="uploading"
hidden />
</label>
<p *ngIf="errorMessage" class="upload-error" role="alert">
<lucide-icon [img]="AlertCircle" [size]="14"></lucide-icon>
{{ errorMessage }}
</p>
</ng-template>
<ng-template #spinner>
<div class="upload-spinner" aria-label="Upload en cours"></div>
<p class="upload-zone-hint">Upload en cours...</p>
</ng-template>

View File

@@ -0,0 +1,88 @@
// Drop-zone standard
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 140px;
padding: 1.5rem;
background: #1a1a2e;
border: 2px dashed #2a2a3d;
border-radius: 8px;
color: #9ca3af;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
&:hover { border-color: #6c63ff; color: #d1d5db; }
&.drag-over { border-color: #6c63ff; background: #1f1f3a; color: white; }
&.loading { cursor: progress; opacity: 0.7; }
}
.upload-zone-title {
margin: 0;
font-size: 0.92rem;
font-weight: 500;
color: #d1d5db;
}
.upload-zone-hint {
margin: 0;
font-size: 0.78rem;
color: #6b7280;
text-align: center;
}
// Variante compacte (bouton carre pour galerie)
.upload-compact {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
width: 120px;
height: 120px;
background: #1a1a2e;
border: 2px dashed #2a2a3d;
border-radius: 6px;
color: #6b7280;
font-size: 0.78rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
&:hover { border-color: #6c63ff; color: #a5b4fc; background: #1f1f3a; }
&.loading { cursor: progress; opacity: 0.7; }
}
.upload-error, .upload-error-inline {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.5rem 0 0;
padding: 0.5rem 0.7rem;
background: #3f1f1f;
color: #fca5a5;
border: 1px solid #7f1d1d;
border-radius: 6px;
font-size: 0.82rem;
}
.upload-error-inline {
padding: 0.3rem 0.5rem;
font-size: 0.72rem;
margin-top: 0.3rem;
}
// Spinner CSS simple (pas de dep externe)
.upload-spinner {
width: 28px;
height: 28px;
border: 3px solid #2a2a3d;
border-top-color: #6c63ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,100 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Upload, AlertCircle } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
/**
* Composant reutilisable d'upload d'image (drop-zone + bouton file).
*
* Usage :
* <app-image-uploader (uploaded)="onImageUploaded($event)"></app-image-uploader>
*
* Responsabilites :
* - Accepter un fichier via drag&drop OU clic sur la zone
* - Valider cote client (MIME + taille) pour eviter un aller-retour inutile
* - POSTer vers /api/images (service ImageService)
* - Emettre (uploaded) avec l'objet Image recu
* - Afficher l'etat loading et les erreurs
*/
@Component({
selector: 'app-image-uploader',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './image-uploader.component.html',
styleUrls: ['./image-uploader.component.scss']
})
export class ImageUploaderComponent {
readonly Upload = Upload;
readonly AlertCircle = AlertCircle;
/** Compact mode : bouton "+ ajouter" plutot que grande drop-zone. */
@Input() compact = false;
/** Emit quand l'image est uploadee avec succes. */
@Output() uploaded = new EventEmitter<Image>();
uploading = false;
errorMessage: string | null = null;
dragOver = false;
/** MIME types alignes avec le backend (ImageService.java). */
private readonly ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
private readonly MAX_BYTES = 10 * 1024 * 1024;
constructor(private imageService: ImageService) {}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.handleFile(input.files[0]);
// Reset pour permettre de re-uploader la meme image si besoin.
input.value = '';
}
}
onDragOver(event: DragEvent): void {
event.preventDefault();
this.dragOver = true;
}
onDragLeave(): void {
this.dragOver = false;
}
onDrop(event: DragEvent): void {
event.preventDefault();
this.dragOver = false;
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
this.handleFile(event.dataTransfer.files[0]);
}
}
private handleFile(file: File): void {
this.errorMessage = null;
// Validation cote client (premier filet de securite).
if (!this.ALLOWED_MIMES.includes(file.type)) {
this.errorMessage = 'Format non supporte (JPEG, PNG, WebP, GIF uniquement).';
return;
}
if (file.size > this.MAX_BYTES) {
this.errorMessage = `Fichier trop volumineux (max ${this.MAX_BYTES / 1024 / 1024} Mo).`;
return;
}
this.uploading = true;
this.imageService.upload(file).subscribe({
next: (image) => {
this.uploading = false;
this.uploaded.emit(image);
},
error: (err) => {
this.uploading = false;
this.errorMessage = err?.status === 413
? 'Fichier refuse par le serveur (trop volumineux).'
: 'Echec de l\'upload. Verifiez que le backend et MinIO tournent.';
}
});
}
}

View File

@@ -28,25 +28,30 @@
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
<ng-template #treeNode let-item let-level="level">
<div class="tree-item" [style.padding-left.px]="level * 12">
<button class="tree-btn" [class.action]="item.isAction" (click)="clickItem(item)">
<span
<div class="tree-row">
<button
*ngIf="!item.isAction && item.children?.length"
class="chevron-zone"
type="button"
class="chevron-btn"
(click)="clickChevron($event, item)">
<lucide-icon
[img]="isExpanded(item.id) ? ChevronDown : ChevronRight"
[size]="12">
</lucide-icon>
</span>
<lucide-icon
*ngIf="iconFor(item) as icon"
[img]="icon"
[size]="14"
class="item-icon">
</lucide-icon>
{{ item.label }}
<span class="tree-item-meta" *ngIf="!item.isAction && item.meta">{{ item.meta }}</span>
</button>
</button>
<span *ngIf="item.isAction || !item.children?.length" class="chevron-spacer"></span>
<button type="button" class="tree-btn" [class.action]="item.isAction" (click)="clickItem(item)">
<lucide-icon
*ngIf="iconFor(item) as icon"
[img]="icon"
[size]="14"
class="item-icon">
</lucide-icon>
{{ item.label }}
<span class="tree-item-meta" *ngIf="!item.isAction && item.meta">{{ item.meta }}</span>
</button>
</div>
<div class="tree-children" *ngIf="isExpanded(item.id) && item.children?.length">
<ng-container *ngFor="let child of item.children">
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>

View File

@@ -127,19 +127,37 @@
margin-right: 0.1rem;
}
.chevron-zone {
.tree-row {
display: flex;
align-items: center;
gap: 0.15rem;
width: 100%;
}
.chevron-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
width: 18px;
height: 18px;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 3px;
cursor: pointer;
color: #6b7280;
padding: 0;
&:hover { background: #374151; color: white; }
}
.chevron-spacer {
display: inline-block;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.tree-children {
display: flex;
flex-direction: column;