Mise à jour avec la possibilité de mettre des images
This commit is contained in:
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
74
web/src/app/campaigns/arc-view/arc-view.component.html
Normal file
74
web/src/app/campaigns/arc-view/arc-view.component.html
Normal 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>
|
||||
1
web/src/app/campaigns/arc-view/arc-view.component.scss
Normal file
1
web/src/app/campaigns/arc-view/arc-view.component.scss
Normal file
@@ -0,0 +1 @@
|
||||
// Styles partagés via styles/_view.scss
|
||||
107
web/src/app/campaigns/arc-view/arc-view.component.ts
Normal file
107
web/src/app/campaigns/arc-view/arc-view.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
// Styles partagés via styles/_view.scss
|
||||
111
web/src/app/campaigns/chapter-view/chapter-view.component.ts
Normal file
111
web/src/app/campaigns/chapter-view/chapter-view.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
90
web/src/app/campaigns/scene-view/scene-view.component.html
Normal file
90
web/src/app/campaigns/scene-view/scene-view.component.html
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
// Styles partagés via styles/_view.scss
|
||||
116
web/src/app/campaigns/scene-view/scene-view.component.ts
Normal file
116
web/src/app/campaigns/scene-view/scene-view.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 --------------------------------------------------------- -->
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
web/src/app/lore/page-view/page-view.component.html
Normal file
65
web/src/app/lore/page-view/page-view.component.html
Normal 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>
|
||||
4
web/src/app/lore/page-view/page-view.component.scss
Normal file
4
web/src/app/lore/page-view/page-view.component.scss
Normal 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).
|
||||
127
web/src/app/lore/page-view/page-view.component.ts
Normal file
127
web/src/app/lore/page-view/page-view.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
15
web/src/app/services/image.model.ts
Normal file
15
web/src/app/services/image.model.ts
Normal 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;
|
||||
}
|
||||
43
web/src/app/services/image.service.ts
Normal file
43
web/src/app/services/image.service.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
107
web/src/app/shared/image-gallery/image-gallery.component.scss
Normal file
107
web/src/app/shared/image-gallery/image-gallery.component.scss
Normal 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; }
|
||||
}
|
||||
76
web/src/app/shared/image-gallery/image-gallery.component.ts
Normal file
76
web/src/app/shared/image-gallery/image-gallery.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
}
|
||||
100
web/src/app/shared/image-uploader/image-uploader.component.ts
Normal file
100
web/src/app/shared/image-uploader/image-uploader.component.ts
Normal 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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// pour éliminer la duplication qui existait dans 16+ fichiers SCSS.
|
||||
@use './styles/buttons';
|
||||
@use './styles/forms';
|
||||
@use './styles/view';
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
|
||||
@@ -57,6 +57,31 @@
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
// Bouton "Assistant IA" des headers — tonalité violette, cohérent avec le drawer.
|
||||
// Variante `.active` appliquée quand le drawer est ouvert (feedback visuel).
|
||||
.btn-ai {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: transparent;
|
||||
color: #a5b4fc;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: #1f2937; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
&.active {
|
||||
background: #1f2937;
|
||||
border-color: #6c63ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Modificateurs combinables
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
150
web/src/styles/_view.scss
Normal file
150
web/src/styles/_view.scss
Normal file
@@ -0,0 +1,150 @@
|
||||
// ==========================================================================
|
||||
// Style "fiche de jeu" — mode consultation (lecture seule).
|
||||
// ==========================================================================
|
||||
// Responsabilité : afficher une entité (Page, Arc, Chapter, Scene) sous forme
|
||||
// d'une belle fiche où chaque champ est un bloc titré, visible d'un bloc,
|
||||
// SANS scrollbar interne — le contenu texte s'étend verticalement selon sa
|
||||
// taille réelle grâce à `white-space: pre-wrap` sur un élément natif
|
||||
// (pas de textarea). L'utilisateur fait défiler la page avec la molette.
|
||||
//
|
||||
// Utilisé par : page-view, arc-view, chapter-view, scene-view.
|
||||
// Principe DRY : les 4 composants partagent ces sélecteurs globaux.
|
||||
|
||||
.view-page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1000px;
|
||||
|
||||
// En-tête : titre + sous-titre + boutons d'action (Modifier, Supprimer...)
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.3rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.view-subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.view-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Bloc de contenu : une section = un champ (titre + corps)
|
||||
.view-section {
|
||||
padding: 1.25rem 0;
|
||||
border-top: 1px solid #1e1e3a;
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.view-section-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #a5b4fc; // violet discret, cohérent avec .btn-ai
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.view-section-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Corps texte — c'est ici que la magie "pas de scrollbar" opère :
|
||||
// `white-space: pre-wrap` conserve les sauts de ligne du textarea d'origine
|
||||
// et le texte se hauteur-adapte naturellement (element = bloc HTML classique).
|
||||
.view-section-body {
|
||||
color: #e0e0e0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// État vide : champ non renseigné, on l'indique discrètement.
|
||||
.view-section-empty {
|
||||
color: #4b5563;
|
||||
font-style: italic;
|
||||
font-size: 0.88rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Variante "privé MJ" — fond rouge discret pour les notes secrètes
|
||||
// (gmNotes, gmSecretNotes). Cohérent avec expandable-section variant="private".
|
||||
.view-section--private {
|
||||
background: rgba(127, 29, 29, 0.08);
|
||||
border-left: 3px solid #7f1d1d;
|
||||
padding-left: 1rem;
|
||||
margin-left: -1rem;
|
||||
border-radius: 0 4px 4px 0;
|
||||
|
||||
.view-section-title { color: #fca5a5; }
|
||||
}
|
||||
|
||||
// Affichage 2 colonnes pour des champs courts côte-à-côte (location/timing...).
|
||||
.view-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
|
||||
.view-section {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Chips (tags et pages liées en lecture seule)
|
||||
.view-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
|
||||
.view-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 999px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
|
||||
&[href]:hover {
|
||||
border-color: #6c63ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.view-chip--tag {
|
||||
background: #1e1b4b;
|
||||
border-color: #312e81;
|
||||
color: #c4b5fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user