Amélioration de l'IA pour la partie atelier PDF

Mise en place d'un outil permettant de faire des tableau d'objets pour des boutiques par exemple
This commit is contained in:
2026-06-08 17:00:22 +02:00
parent 70ec1f2fb9
commit 833280c784
38 changed files with 1712 additions and 12 deletions

View File

@@ -27,6 +27,10 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
{ path: 'campaigns/:campaignId/notebooks', loadComponent: () => import('./campaigns/notebook/notebook-list/notebook-list.component').then(m => m.NotebookListComponent) },
{ path: 'campaigns/:campaignId/notebooks/:notebookId', loadComponent: () => import('./campaigns/notebook/notebook-detail/notebook-detail.component').then(m => m.NotebookDetailComponent) },
{ path: 'campaigns/:campaignId/item-catalogs', loadComponent: () => import('./campaigns/item-catalog/item-catalog-list/item-catalog-list.component').then(m => m.ItemCatalogListComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) },
{ path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) },

View File

@@ -225,6 +225,14 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/notebooks`
};
// Catalogues d'objets (boutiques, butins…) → page de liste (outil).
const catalogsNode: TreeItem = {
id: 'item-catalogs-root',
label: 'Catalogues d\'objets',
iconKey: 'package',
route: `/campaigns/${campaignId}/item-catalogs`
};
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
const importNode: TreeItem = {
id: 'import-pdf-root',
@@ -233,7 +241,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/import`
};
return [...arcNodes, npcsNode, tablesNode, notebooksNode, importNode];
return [...arcNodes, npcsNode, tablesNode, notebooksNode, catalogsNode, importNode];
}
/**

View File

@@ -0,0 +1,68 @@
<div class="ice-page">
<div class="ice-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-save" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="14"></lucide-icon>
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
</div>
<h1>{{ catalogId ? 'Éditer le catalogue' : 'Nouveau catalogue d\'objets' }}</h1>
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div>
<div class="form-row">
<label for="ic-name">Nom *</label>
<input id="ic-name" type="text" [(ngModel)]="name" placeholder="Ex: Échoppe de l'armurier nain">
</div>
<div class="form-row">
<label for="ic-desc">Description</label>
<textarea id="ic-desc" rows="2" [(ngModel)]="description" placeholder="À quoi sert ce catalogue / cette boutique ?"></textarea>
</div>
<!-- Génération IA -->
<div class="ai-box">
<div class="ai-title"><lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA</div>
<p class="ai-hint">Décris la boutique/le butin ; l'IA propose les objets (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
<textarea rows="2" [(ngModel)]="aiPrompt"
placeholder="Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques"></textarea>
<div class="ai-actions">
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
{{ generating ? 'Génération…' : 'Générer' }}
</button>
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span>
</div>
</div>
<div class="items-head">
<h2>Objets</h2>
<button class="btn-mini" (click)="addItem()">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> Ajouter
</button>
</div>
<div class="item-row head">
<span class="c-name">Nom</span>
<span class="c-price">Prix</span>
<span class="c-cat">Catégorie</span>
<span class="c-desc">Description</span>
<span class="c-del"></span>
</div>
<div class="item-row" *ngFor="let it of items; let i = index">
<input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet">
<input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po">
<input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes">
<input class="c-desc" type="text" [(ngModel)]="it.description" placeholder="Effet / détails">
<button class="c-del btn-del" (click)="removeItem(i)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<p class="empty-hint" *ngIf="items.length === 0">Aucun objet — clique « Ajouter ».</p>
</div>

View File

@@ -0,0 +1,87 @@
.ice-page { max-width: 980px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.ice-toolbar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.8rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
&:disabled { opacity: 0.5; cursor: default; }
}
.btn-save { background: #667eea; border-color: #667eea; color: #fff; }
.btn-save:hover:not(:disabled) { background: #5568d3; }
}
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
h2 { font-size: 1rem; margin: 0; }
.error {
padding: 0.6rem 0.9rem; border-radius: 6px; margin-bottom: 1rem;
background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.4); color: #e88;
}
.form-row {
display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 1rem;
label { font-size: 0.85rem; color: var(--color-text-muted, #9aa0aa); }
input, textarea {
padding: 0.5rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04);
color: inherit; font: inherit;
}
}
.ai-box {
margin: 1rem 0; padding: 0.85rem 1rem; border-radius: 10px;
background: rgba(168,130,255,0.08); border: 1px solid rgba(168,130,255,0.28);
.ai-title { display: flex; align-items: center; gap: 0.4rem; font-weight: 600; color: #c4a8ff; }
.ai-hint { margin: 0.3rem 0 0.5rem; font-size: 0.8rem; color: var(--color-text-muted, #9aa0aa); }
textarea {
width: 100%; padding: 0.5rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; font: inherit;
}
.ai-actions { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.5rem; }
.btn-ai {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.9rem; border: none; border-radius: 7px;
background: #8a6dff; color: #fff; font-weight: 600; cursor: pointer;
&:hover:not(:disabled) { background: #7a5cf0; }
&:disabled { opacity: 0.55; cursor: default; }
}
.ai-error { color: #e88; font-size: 0.82rem; }
}
.items-head {
display: flex; align-items: center; justify-content: space-between; margin: 1.25rem 0 0.5rem;
.btn-mini {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.3rem 0.6rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.8rem;
&:hover { background: rgba(255,255,255,0.09); }
}
}
.item-row {
display: grid; grid-template-columns: 1.4fr 80px 1fr 1.8fr 36px;
gap: 0.5rem; align-items: center; margin-bottom: 0.4rem;
&.head {
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.3rem;
}
input {
padding: 0.4rem 0.55rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04);
color: inherit; font: inherit; width: 100%;
}
.btn-del {
display: inline-flex; align-items: center; justify-content: center; padding: 0.35rem;
border-radius: 6px; border: 1px solid rgba(224,90,90,0.3);
background: rgba(224,90,90,0.08); color: #e88; cursor: pointer;
&:hover { background: rgba(224,90,90,0.18); }
}
}
.empty-hint { color: var(--color-text-muted, #9aa0aa); font-style: italic; }

View File

@@ -0,0 +1,152 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Sparkles } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { ItemCatalog, CatalogItem, ItemCatalogCreate } from '../../../services/item-catalog.model';
/**
* Création/édition d'un catalogue d'objets (boutique, butin…).
* Routes : /campaigns/:campaignId/item-catalogs/create
* /campaigns/:campaignId/item-catalogs/:catalogId/edit
*/
@Component({
selector: 'app-item-catalog-edit',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './item-catalog-edit.component.html',
styleUrls: ['./item-catalog-edit.component.scss']
})
export class ItemCatalogEditComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Save = Save;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
catalogId: string | null = null;
name = '';
description = '';
items: CatalogItem[] = [];
saving = false;
errorMessage = '';
aiPrompt = '';
generating = false;
aiError = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private service: ItemCatalogService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.catalogId = params.get('catalogId');
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
if (this.catalogId) {
this.service.getById(this.catalogId).subscribe({
next: (c: ItemCatalog) => {
this.name = c.name;
this.description = c.description ?? '';
this.items = c.items.map(i => ({ ...i }));
},
error: () => this.back()
});
} else {
this.addItem();
}
}
addItem(): void {
this.items.push({ name: '', price: '', category: '', description: '' });
}
removeItem(i: number): void {
this.items.splice(i, 1);
}
generateWithAI(): void {
if (!this.campaignId) return;
if (!this.aiPrompt.trim()) { this.aiError = 'Décris le catalogue à générer.'; return; }
this.generating = true;
this.aiError = '';
this.service.generate(this.campaignId, this.aiPrompt.trim()).subscribe({
next: (c) => {
this.generating = false;
if (c.name && !this.name.trim()) this.name = c.name;
if (c.description) this.description = c.description;
this.items = (c.items ?? []).map(i => ({ ...i }));
},
error: (err) => {
this.generating = false;
this.aiError = err?.error?.message || 'Échec de la génération IA. Réessaie ou reformule.';
}
});
}
save(): void {
if (!this.campaignId) return;
if (!this.name.trim()) { this.errorMessage = 'Le nom est requis.'; return; }
this.saving = true;
this.errorMessage = '';
const cleanItems = this.items
.filter(i => i.name.trim())
.map(i => ({
name: i.name.trim(),
price: i.price?.trim() || undefined,
category: i.category?.trim() || undefined,
description: i.description?.trim() || undefined
}));
if (this.catalogId) {
const payload: ItemCatalog = {
id: this.catalogId,
name: this.name.trim(),
description: this.description.trim() || undefined,
campaignId: this.campaignId,
items: cleanItems
};
this.service.update(this.catalogId, payload).subscribe({
next: () => this.goToView(this.catalogId!),
error: (e) => this.fail(e)
});
} else {
const payload: ItemCatalogCreate = {
name: this.name.trim(),
description: this.description.trim() || undefined,
campaignId: this.campaignId,
items: cleanItems
};
this.service.create(payload).subscribe({
next: (c) => this.goToView(c.id!),
error: (e) => this.fail(e)
});
}
}
private goToView(id: string): void {
this.saving = false;
this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', id]);
}
private fail(err: unknown): void {
this.saving = false;
this.errorMessage = 'Échec de l\'enregistrement.';
console.error('ItemCatalog save failed', err);
}
back(): void {
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
}
}

View File

@@ -0,0 +1,31 @@
<div class="icl-page">
<div class="icl-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-create" (click)="create()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouveau catalogue
</button>
</div>
<header class="icl-header">
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> Catalogues d'objets</h1>
<p class="icl-hint">
Boutiques, butins, trésors… à remplir à la main ou via l'IA. Consultables en session
quand les joueurs visitent une échoppe.
</p>
</header>
<div class="icl-list">
<p class="empty" *ngIf="catalogs.length === 0">Aucun catalogue pour l'instant.</p>
<button class="icl-item" *ngFor="let c of catalogs" (click)="open(c)">
<lucide-icon [img]="Package" [size]="16"></lucide-icon>
<span class="icl-item-name">{{ c.name }}</span>
<span class="icl-item-count">{{ c.items.length }} objet(s)</span>
<span class="icl-del" (click)="remove(c, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,36 @@
.icl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.icl-toolbar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.8rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.btn-create { background: #667eea; border-color: #667eea; color: #fff; }
.btn-create:hover { background: #5568d3; }
}
.icl-header {
margin-bottom: 1.25rem;
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
.icl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
}
.icl-list { display: flex; flex-direction: column; gap: 0.4rem; }
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.icl-item {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 0.85rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.07); }
.icl-item-name { flex: 1; font-weight: 500; }
.icl-item-count { font-size: 0.78rem; color: var(--color-text-muted, #9aa0aa); }
.icl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
&:hover { background: rgba(224,90,90,0.15); } }
}

View File

@@ -0,0 +1,77 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Package } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { ItemCatalog } from '../../../services/item-catalog.model';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Liste des catalogues d'objets d'une campagne + création.
* Route : /campaigns/:campaignId/item-catalogs
*/
@Component({
selector: 'app-item-catalog-list',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './item-catalog-list.component.html',
styleUrls: ['./item-catalog-list.component.scss']
})
export class ItemCatalogListComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Package = Package;
campaignId = '';
catalogs: ItemCatalog[] = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private service: ItemCatalogService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.load();
}
}
load(): void {
this.service.getByCampaign(this.campaignId).subscribe({
next: (list) => this.catalogs = list,
error: () => this.catalogs = []
});
}
create(): void {
this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', 'create']);
}
open(c: ItemCatalog): void {
this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', c.id]);
}
remove(c: ItemCatalog, ev: Event): void {
ev.stopPropagation();
this.confirmDialog.confirm({
title: 'Supprimer le catalogue',
message: `Supprimer « ${c.name} » ?`,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.service.delete(c.id!).subscribe(() => this.load());
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -0,0 +1,33 @@
<div class="ic-page" *ngIf="catalog">
<div class="ic-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
<span class="spacer"></span>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> Éditer
</button>
</div>
<header class="ic-header">
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> {{ catalog.name }}</h1>
<p class="ic-desc" *ngIf="catalog.description">{{ catalog.description }}</p>
</header>
<p class="ic-empty" *ngIf="catalog.items.length === 0">
Aucun objet — édite le catalogue pour en ajouter.
</p>
<section class="ic-group" *ngFor="let g of groups">
<h2 *ngIf="g.category !== '—'">{{ g.category }}</h2>
<table>
<tbody>
<tr *ngFor="let it of g.items">
<td class="col-name">{{ it.name }}</td>
<td class="col-price">{{ it.price }}</td>
<td class="col-desc">{{ it.description }}</td>
</tr>
</tbody>
</table>
</section>
</div>

View File

@@ -0,0 +1,40 @@
.ic-page { max-width: 880px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.ic-toolbar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
}
.ic-header {
margin-bottom: 1.25rem;
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
.ic-desc { margin: 0; color: var(--color-text-muted, #9aa0aa); }
}
.ic-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.ic-group {
margin-bottom: 1.25rem;
h2 {
font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;
color: #c4a8ff; margin: 0 0 0.4rem; padding-bottom: 0.25rem;
border-bottom: 1px solid rgba(168,130,255,0.2);
}
table { width: 100%; border-collapse: collapse; }
td {
padding: 0.45rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.06); vertical-align: top;
}
.col-name { font-weight: 500; white-space: nowrap; }
.col-price {
white-space: nowrap; color: #e0c074; font-variant-numeric: tabular-nums;
text-align: right; width: 90px;
}
.col-desc { color: var(--color-text-muted, #cfd3da); font-size: 0.9rem; }
}

View File

@@ -0,0 +1,73 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Package } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { ItemCatalog, CatalogItem } from '../../../services/item-catalog.model';
interface ItemGroup { category: string; items: CatalogItem[]; }
/**
* Vue d'un catalogue d'objets : liste (groupée par catégorie).
* Route : /campaigns/:campaignId/item-catalogs/:catalogId
*/
@Component({
selector: 'app-item-catalog-view',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './item-catalog-view.component.html',
styleUrls: ['./item-catalog-view.component.scss']
})
export class ItemCatalogViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Package = Package;
campaignId: string | null = null;
catalogId: string | null = null;
catalog: ItemCatalog | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: ItemCatalogService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.catalogId = params.get('catalogId');
if (this.catalogId) {
this.service.getById(this.catalogId).subscribe({
next: c => this.catalog = c,
error: () => this.back()
});
}
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
}
/** Objets groupés par catégorie (non catégorisés en dernier). */
get groups(): ItemGroup[] {
const map = new Map<string, CatalogItem[]>();
for (const it of this.catalog?.items ?? []) {
const cat = (it.category ?? '').trim() || '—';
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(it);
}
return [...map.entries()]
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr')))
.map(([category, items]) => ({ category, items }));
}
edit(): void {
if (this.campaignId && this.catalogId) {
this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', this.catalogId, 'edit']);
}
}
back(): void {
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
}
}

View File

@@ -2,7 +2,7 @@ import {
Folder,
Users, Swords, MapPin, Shield, Crown, Skull, Gem,
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
Ship, Flame, Star, Moon, Key, Globe, Compass, Dices, FileUp, LucideIconData
Ship, Flame, Star, Moon, Key, Globe, Compass, Dices, FileUp, Package, LucideIconData
} from 'lucide-angular';
import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons';
@@ -44,6 +44,7 @@ export const LORE_ICON_OPTIONS: IconOption[] = [
{ key: 'compass', icon: Compass },
{ key: 'dice', icon: Dices },
{ key: 'file-up', icon: FileUp },
{ key: 'package', icon: Package },
];
/** Icône par défaut pour un dossier sans icône. */

View File

@@ -0,0 +1,26 @@
/** Reflet de l'ItemCatalogDTO côté Core. Un catalogue d'objets (boutique, butin…). */
export interface CatalogItem {
name: string;
price?: string;
category?: string;
description?: string;
}
export interface ItemCatalog {
id?: string;
name: string;
description?: string;
icon?: string;
campaignId: string;
order?: number;
items: CatalogItem[];
}
export interface ItemCatalogCreate {
name: string;
description?: string;
icon?: string;
campaignId: string;
items: CatalogItem[];
}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ItemCatalog, ItemCatalogCreate } from './item-catalog.model';
/**
* CRUD des catalogues d'objets (campagne) + génération IA. Mirroir de RandomTableService.
*/
@Injectable({ providedIn: 'root' })
export class ItemCatalogService {
private apiUrl = '/api/item-catalogs';
constructor(private http: HttpClient) {}
getByCampaign(campaignId: string): Observable<ItemCatalog[]> {
return this.http.get<ItemCatalog[]>(`${this.apiUrl}/campaign/${campaignId}`);
}
getById(id: string): Observable<ItemCatalog> {
return this.http.get<ItemCatalog>(`${this.apiUrl}/${id}`);
}
create(payload: ItemCatalogCreate): Observable<ItemCatalog> {
return this.http.post<ItemCatalog>(this.apiUrl, payload);
}
update(id: string, payload: ItemCatalog): Observable<ItemCatalog> {
return this.http.put<ItemCatalog>(`${this.apiUrl}/${id}`, payload);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) à préremplir. */
generate(campaignId: string, description: string): Observable<ItemCatalog> {
return this.http.post<ItemCatalog>(`${this.apiUrl}/generate`, { campaignId, description });
}
}

View File

@@ -180,7 +180,8 @@
[sessionId]="session.id"
[canAddToJournal]="session.active"
(rolled)="onDiceRolled($event)"
(aiReplyToJournal)="onAiReplyToJournal($event)">
(aiReplyToJournal)="onAiReplyToJournal($event)"
(noteToJournal)="onItemNoteToJournal($event)">
</app-session-reference-panel>
</aside>

View File

@@ -265,6 +265,22 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
});
}
/**
* Réception d'un objet de catalogue à consigner dans le journal.
* Le panneau fournit déjà une chaîne formatée (🛒 …) : on la sauvegarde
* telle quelle comme entrée NOTE.
*/
onItemNoteToJournal(content: string): void {
if (!this.session || !this.session.active) return;
const trimmed = content.trim();
if (!trimmed) return;
const input: SessionEntryInput = { type: 'NOTE', content: trimmed };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => this.entries = [created, ...this.entries],
error: () => console.error('Erreur lors de l\'ajout de l\'objet au journal')
});
}
deleteEntry(entry: SessionEntry): void {
if (!this.session) return;
const session = this.session;

View File

@@ -0,0 +1,38 @@
<div class="sic-panel">
<p class="loading-hint" *ngIf="loading">Chargement…</p>
<!-- Liste des catalogues -->
<div *ngIf="!loading && !selected" class="sic-list">
<p class="empty-hint" *ngIf="catalogs.length === 0">
Aucun catalogue d'objets dans cette campagne.
</p>
<button *ngFor="let c of catalogs" type="button" class="sic-item" (click)="select(c)">
<lucide-icon [img]="Package" [size]="13"></lucide-icon>
<span class="sic-item-name">{{ c.name }}</span>
<span class="sic-item-count">{{ c.items.length }}</span>
</button>
</div>
<!-- Catalogue sélectionné -->
<div *ngIf="selected" class="sic-detail">
<button type="button" class="sic-back" (click)="backToList()">
<lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Catalogues
</button>
<h4>{{ selected.name }}</h4>
<div class="sic-group" *ngFor="let g of groups">
<div class="sic-group-title" *ngIf="g.category !== '—'">{{ g.category }}</div>
<div class="sic-obj" *ngFor="let it of g.items">
<div class="sic-obj-main">
<span class="sic-obj-name">{{ it.name }}</span>
<span class="sic-obj-price" *ngIf="it.price">{{ it.price }}</span>
</div>
<div class="sic-obj-desc" *ngIf="it.description">{{ it.description }}</div>
<button type="button" class="sic-note" (click)="note(it)" [disabled]="!canAddToJournal"
title="Consigner au journal (ex. achat)">
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon> Journal
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,48 @@
.sic-panel { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.25rem; }
.loading-hint, .empty-hint {
color: var(--color-text-muted, #9aa0aa); font-size: 0.85rem; font-style: italic; padding: 0.5rem 0;
}
.sic-list { display: flex; flex-direction: column; gap: 0.3rem; }
.sic-item {
display: flex; align-items: center; gap: 0.45rem; width: 100%;
padding: 0.45rem 0.6rem; border-radius: 7px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.08); }
.sic-item-name { flex: 1; }
.sic-item-count {
font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa);
background: rgba(255,255,255,0.06); padding: 0.05rem 0.35rem; border-radius: 4px;
}
}
.sic-detail { display: flex; flex-direction: column; gap: 0.5rem; }
.sic-back {
display: inline-flex; align-items: center; gap: 0.25rem; align-self: flex-start;
padding: 0.2rem 0.4rem; border: none; background: none;
color: var(--color-text-muted, #9aa0aa); cursor: pointer; font-size: 0.8rem;
&:hover { color: inherit; }
}
h4 { margin: 0; font-size: 0.95rem; }
.sic-group-title {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: #c4a8ff; margin: 0.5rem 0 0.2rem;
}
.sic-obj {
padding: 0.4rem 0.5rem; border-radius: 7px; background: rgba(255,255,255,0.03); margin-bottom: 0.3rem;
.sic-obj-main { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
.sic-obj-name { font-weight: 500; }
.sic-obj-price { color: #e0c074; font-size: 0.82rem; white-space: nowrap; }
.sic-obj-desc { font-size: 0.82rem; color: var(--color-text-muted, #cfd3da); margin-top: 0.15rem; }
.sic-note {
display: inline-flex; align-items: center; gap: 0.25rem; margin-top: 0.35rem;
padding: 0.2rem 0.45rem; border-radius: 5px; font-size: 0.74rem;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.05); color: inherit; cursor: pointer;
&:hover:not(:disabled) { background: rgba(255,255,255,0.1); }
&:disabled { opacity: 0.5; cursor: default; }
}
}

View File

@@ -0,0 +1,64 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Package, BookmarkPlus, ChevronLeft } from 'lucide-angular';
import { catchError, of } from 'rxjs';
import { ItemCatalogService } from '../../services/item-catalog.service';
import { ItemCatalog, CatalogItem } from '../../services/item-catalog.model';
interface ItemGroup { category: string; items: CatalogItem[]; }
/**
* Panneau « Objets » du mode jeu : consulter les catalogues (boutiques) de la
* campagne et consigner un objet au journal (ex. « le joueur achète X »).
*/
@Component({
selector: 'app-session-item-catalogs-panel',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './session-item-catalogs-panel.component.html',
styleUrls: ['./session-item-catalogs-panel.component.scss']
})
export class SessionItemCatalogsPanelComponent implements OnInit {
readonly Package = Package;
readonly BookmarkPlus = BookmarkPlus;
readonly ChevronLeft = ChevronLeft;
@Input() campaignId!: string;
@Input() canAddToJournal = true;
/** Émis pour consigner un objet au journal (entrée NOTE). */
@Output() noteToJournal = new EventEmitter<string>();
catalogs: ItemCatalog[] = [];
loading = false;
selected: ItemCatalog | null = null;
constructor(private service: ItemCatalogService) {}
ngOnInit(): void {
if (!this.campaignId) return;
this.loading = true;
this.service.getByCampaign(this.campaignId).pipe(catchError(() => of([] as ItemCatalog[])))
.subscribe(list => { this.catalogs = list; this.loading = false; });
}
select(c: ItemCatalog): void { this.selected = c; }
backToList(): void { this.selected = null; }
get groups(): ItemGroup[] {
const map = new Map<string, CatalogItem[]>();
for (const it of this.selected?.items ?? []) {
const cat = (it.category ?? '').trim() || '—';
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(it);
}
return [...map.entries()]
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr')))
.map(([category, items]) => ({ category, items }));
}
note(it: CatalogItem): void {
if (!this.canAddToJournal || !this.selected) return;
const price = it.price ? ` (${it.price})` : '';
this.noteToJournal.emit(`🛒 ${this.selected.name}${it.name}${price}`);
}
}

View File

@@ -22,6 +22,13 @@
<lucide-icon [img]="Table2" [size]="14"></lucide-icon>
Tables
</button>
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'objects'"
(click)="selectTab('objects')">
<lucide-icon [img]="Package" [size]="14"></lucide-icon>
Objets
</button>
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'characters'"
@@ -64,6 +71,14 @@
(aiReplyToJournal)="onAiSaveToJournal($event)">
</app-session-random-tables-panel>
<!-- ====== Catalogues d'objets ====== -->
<app-session-item-catalogs-panel
*ngIf="activeTab === 'objects'"
[campaignId]="campaignId"
[canAddToJournal]="canAddToJournal"
(noteToJournal)="onItemNote($event)">
</app-session-item-catalogs-panel>
<!-- ====== Personnages (PJ + PNJ) ====== -->
<div *ngIf="activeTab === 'characters'" class="ref-list">
<p class="loading-hint" *ngIf="loadingChars">Chargement…</p>

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2 } from 'lucide-angular';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular';
import { catchError, of } from 'rxjs';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
@@ -14,8 +14,9 @@ import {
} from '../session-dice-panel/session-dice-panel.component';
import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-panel.component';
import { SessionRandomTablesPanelComponent } from '../session-random-tables-panel/session-random-tables-panel.component';
import { SessionItemCatalogsPanelComponent } from '../session-item-catalogs-panel/session-item-catalogs-panel.component';
type TabId = 'dice' | 'tables' | 'characters' | 'scenes' | 'ai';
type TabId = 'dice' | 'tables' | 'objects' | 'characters' | 'scenes' | 'ai';
/**
* Panneau latéral du mode jeu : référence rapide en lecture seule.
@@ -30,7 +31,7 @@ type TabId = 'dice' | 'tables' | 'characters' | 'scenes' | 'ai';
@Component({
selector: 'app-session-reference-panel',
standalone: true,
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent],
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent],
templateUrl: './session-reference-panel.component.html',
styleUrls: ['./session-reference-panel.component.scss']
})
@@ -42,6 +43,7 @@ export class SessionReferencePanelComponent implements OnChanges {
readonly ExternalLink = ExternalLink;
readonly Sparkles = Sparkles;
readonly Table2 = Table2;
readonly Package = Package;
@Input() campaignId!: string;
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
@@ -51,6 +53,8 @@ export class SessionReferencePanelComponent implements OnChanges {
@Output() rolled = new EventEmitter<DiceRollResult>();
/** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
@Output() aiReplyToJournal = new EventEmitter<string>();
/** Émis pour consigner un objet de catalogue au journal (entrée NOTE). */
@Output() noteToJournal = new EventEmitter<string>();
activeTab: TabId = 'dice';
@@ -143,4 +147,8 @@ export class SessionReferencePanelComponent implements OnChanges {
onAiSaveToJournal(content: string): void {
this.aiReplyToJournal.emit(content);
}
onItemNote(content: string): void {
this.noteToJournal.emit(content);
}
}