Migration Angular 20 -> 21 : fin de la migration securite (0 vulnerabilite npm)

- ng update @angular/core@21 @angular/cli@21 (corrige GHSA-jrmj-c5cx-3cw6, XSS SVG)
- Migration automatique des templates vers le control flow natif (@if/@for, 138 fichiers)
- Correction des 5 `track` invalides generes par la migration (trackBy a 1 argument
  -> track $index) + suppression des fonctions trackBy mortes
- TypeScript 5.9, zone.js 0.15 ; npm audit : 0 vulnerabilite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:55:36 +02:00
parent 6acad41672
commit 23878f1c63
142 changed files with 7055 additions and 6292 deletions

3389
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,14 +15,14 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^20.3.24", "@angular/animations": "^21.2.16",
"@angular/common": "^20.3.24", "@angular/common": "^21.2.16",
"@angular/compiler": "^20.3.24", "@angular/compiler": "^21.2.16",
"@angular/core": "^20.3.24", "@angular/core": "^21.2.16",
"@angular/forms": "^20.3.24", "@angular/forms": "^21.2.16",
"@angular/platform-browser": "^20.3.24", "@angular/platform-browser": "^21.2.16",
"@angular/platform-browser-dynamic": "^20.3.24", "@angular/platform-browser-dynamic": "^21.2.16",
"@angular/router": "^20.3.24", "@angular/router": "^21.2.16",
"dompurify": "^3.4.1", "dompurify": "^3.4.1",
"lucide-angular": "^1.0.0", "lucide-angular": "^1.0.0",
"marked": "^18.0.2", "marked": "^18.0.2",
@@ -31,9 +31,9 @@
"zone.js": "~0.15.1" "zone.js": "~0.15.1"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^20.3.27", "@angular-devkit/build-angular": "^21.2.14",
"@angular/cli": "^20.3.27", "@angular/cli": "^21.2.14",
"@angular/compiler-cli": "^20.3.24", "@angular/compiler-cli": "^21.2.16",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"typescript": "~5.9.3" "typescript": "~5.9.3"
} }

View File

@@ -3,7 +3,7 @@
<div class="app-container"> <div class="app-container">
<app-sidebar></app-sidebar> <app-sidebar></app-sidebar>
<ng-container *ngIf="sidebarConfig$ | async as config"> @if (sidebarConfig$ | async; as config) {
<app-secondary-sidebar <app-secondary-sidebar
[title]="config.title" [title]="config.title"
[titleRoute]="config.titleRoute || null" [titleRoute]="config.titleRoute || null"
@@ -11,7 +11,7 @@
[createActions]="config.createActions" [createActions]="config.createActions"
[bottomPanel]="config.bottomPanel || null"> [bottomPanel]="config.bottomPanel || null">
</app-secondary-sidebar> </app-secondary-sidebar>
</ng-container> }
<main class="main-content"> <main class="main-content">
<router-outlet></router-outlet> <router-outlet></router-outlet>

View File

@@ -1,5 +1,5 @@
import { Component, HostListener } from '@angular/core'; import { Component, HostListener } from '@angular/core';
import { AsyncPipe, NgIf } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SidebarComponent } from './sidebar/sidebar.component'; import { SidebarComponent } from './sidebar/sidebar.component';
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component'; import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
@@ -13,15 +13,14 @@ import { VersionCheckerService } from './services/version-checker.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [
RouterOutlet, RouterOutlet,
SidebarComponent, SidebarComponent,
SecondarySidebarComponent, SecondarySidebarComponent,
GlobalSearchComponent, GlobalSearchComponent,
UpdateBannerComponent, UpdateBannerComponent,
ConfirmDialogHostComponent, ConfirmDialogHostComponent,
AsyncPipe, AsyncPipe
NgIf, ],
],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss']
}) })

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -20,7 +20,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
*/ */
@Component({ @Component({
selector: 'app-arc-create', selector: 'app-arc-create',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent], imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './arc-create.component.html', templateUrl: './arc-create.component.html',
styleUrls: ['./arc-create.component.scss'] styleUrls: ['./arc-create.component.scss']
}) })

View File

@@ -7,9 +7,9 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-ai" <button type="button" class="btn-ai"
(click)="toggleChat()" (click)="toggleChat()"
[class.active]="chatOpen" [class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cet arc"> title="Ouvrir l'Assistant IA pour dialoguer autour de cet arc">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </button>
@@ -58,7 +58,7 @@
formControlName="name" formControlName="name"
placeholder="Ex: L'Ombre du Nord" placeholder="Ex: L'Ombre du Nord"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched" [class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/> />
</div> </div>
<div class="field"> <div class="field">
@@ -145,25 +145,29 @@
</div> </div>
<!-- ===== Pages Lore associées (phase B2 cross-context) ===== --> <!-- ===== Pages Lore associées (phase B2 cross-context) ===== -->
<div class="field" *ngIf="loreId"> @if (loreId) {
<label>Pages Lore associées</label> <div class="field">
<app-lore-link-picker <label>Pages Lore associées</label>
[value]="relatedPageIds" <app-lore-link-picker
[availablePages]="availablePages" [value]="relatedPageIds"
[loreId]="loreId" [availablePages]="availablePages"
(valueChange)="relatedPageIds = $event"> [loreId]="loreId"
</app-lore-link-picker> (valueChange)="relatedPageIds = $event">
<small class="field-hint"> </app-lore-link-picker>
Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée. <small class="field-hint">
</small> Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.
</div> </small>
</div>
}
<div class="field lore-hint" *ngIf="!loreId"> @if (!loreId) {
<small class="field-hint"> <div class="field lore-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne <small class="field-hint">
pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.). 💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
</small> pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).
</div> </small>
</div>
}
</form> </form>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -33,7 +33,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-arc-edit', selector: 'app-arc-edit',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent], imports: [ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './arc-edit.component.html', templateUrl: './arc-edit.component.html',
styleUrls: ['./arc-edit.component.scss'] styleUrls: ['./arc-edit.component.scss']
}) })

View File

@@ -1,123 +1,150 @@
<div class="view-page" *ngIf="arc"> @if (arc) {
<div class="view-page">
<header class="view-header"> <header class="view-header">
<div> <div>
<h1> <h1>
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon> @if (arc.icon) {
{{ arc.name }} <lucide-icon [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
</h1> }
<p class="view-subtitle"> {{ arc.name }}
{{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }} </h1>
</p> <p class="view-subtitle">
</div> {{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }}
<div class="view-actions"> </p>
<button type="button" class="btn-primary" (click)="editMode()"> </div>
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> <div class="view-actions">
Modifier <button type="button" class="btn-primary" (click)="editMode()">
</button> <lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
<button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu"> Modifier
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> </button>
Supprimer <button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu">
</button> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</div> Supprimer
</header> </button>
</div>
<!-- Illustrations (rendu editorial magazine) --> </header>
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0"> <!-- Illustrations (rendu editorial magazine) -->
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery> @if ((arc.illustrationImageIds?.length ?? 0) > 0) {
</section> <section class="view-section">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
<!-- Cartes & plans --> </section>
<section class="view-section" *ngIf="(arc.mapImageIds?.length ?? 0) > 0"> }
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2> <!-- Cartes & plans -->
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery> @if ((arc.mapImageIds?.length ?? 0) > 0) {
</section> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut. <app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. --> </section>
<section class="view-section" *ngIf="arc.type === 'HUB'"> }
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2> <!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut.
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
<p class="view-section-empty" *ngIf="hubQuests.length === 0"> @if (arc.type === 'HUB') {
Aucune quête pour ce hub. Créez-en une pour démarrer. <section class="view-section">
</p> <h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
@if (hubQuests.length === 0) {
<div class="hub-quest-grid" *ngIf="hubQuests.length > 0"> <p class="view-section-empty">
<button type="button" Aucune quête pour ce hub. Créez-en une pour démarrer.
class="hub-quest-card" </p>
*ngFor="let q of hubQuests" }
(click)="openQuest(q)"> @if (hubQuests.length > 0) {
<div class="hub-quest-card-head"> <div class="hub-quest-grid">
<span class="hub-quest-card-icon" *ngIf="q.icon"> @for (q of hubQuests; track q) {
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon> <button type="button"
</span> class="hub-quest-card"
<span class="hub-quest-card-name">{{ q.name }}</span> (click)="openQuest(q)">
</div> <div class="hub-quest-card-head">
@if (q.icon) {
<p class="hub-quest-card-desc" *ngIf="q.description?.trim()">{{ q.description }}</p> <span class="hub-quest-card-icon">
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon>
<div class="hub-quest-card-locked-hint" *ngIf="(q.prerequisites?.length ?? 0) > 0"> </span>
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon> }
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span> <span class="hub-quest-card-name">{{ q.name }}</span>
<ul class="hub-quest-card-prereq-list"> </div>
<li *ngFor="let p of q.prerequisites">{{ describePrerequisite(p) }}</li> @if (q.description?.trim()) {
</ul> <p class="hub-quest-card-desc">{{ q.description }}</p>
</div> }
</button> @if ((q.prerequisites?.length ?? 0) > 0) {
</div> <div class="hub-quest-card-locked-hint">
</section> <lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span>
<section class="view-section"> <ul class="hub-quest-card-prereq-list">
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2> @for (p of q.prerequisites; track p) {
<p class="view-section-body" *ngIf="arc.description?.trim(); else emptyDesc">{{ arc.description }}</p> <li>{{ describePrerequisite(p) }}</li>
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template> }
</section> </ul>
</div>
<div class="view-row"> }
</button>
}
</div>
}
</section>
}
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Thèmes principaux</h2> <h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
<p class="view-section-body" *ngIf="arc.themes?.trim(); else emptyThemes">{{ arc.themes }}</p> @if (arc.description?.trim()) {
<ng-template #emptyThemes><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ arc.description }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section> </section>
<div class="view-row">
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Thèmes principaux</h2>
@if (arc.themes?.trim()) {
<p class="view-section-body">{{ arc.themes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> Enjeux globaux</h2>
@if (arc.stakes?.trim()) {
<p class="view-section-body">{{ arc.stakes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
</div>
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> Enjeux globaux</h2> <h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2>
<p class="view-section-body" *ngIf="arc.stakes?.trim(); else emptyStakes">{{ arc.stakes }}</p> @if (arc.rewards?.trim()) {
<ng-template #emptyStakes><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ arc.rewards }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section> </section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2>
@if (arc.resolution?.trim()) {
<p class="view-section-body">{{ arc.resolution }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<!-- Notes MJ (bloc privé rouge discret) -->
@if (arc.gmNotes?.trim()) {
<section class="view-section view-section--private">
<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) -->
@if (loreId && (arc.relatedPageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
@for (relId of arc.relatedPageIds; track relId) {
<a class="view-chip"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
}
</div>
</section>
}
</div> </div>
}
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2>
<p class="view-section-body" *ngIf="arc.rewards?.trim(); else emptyRewards">{{ arc.rewards }}</p>
<ng-template #emptyRewards><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2>
<p class="view-section-body" *ngIf="arc.resolution?.trim(); else emptyResolution">{{ arc.resolution }}</p>
<ng-template #emptyResolution><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<!-- Notes MJ (bloc privé rouge discret) -->
<section class="view-section view-section--private" *ngIf="arc.gmNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes et planification du MJ
</h2>
<p class="view-section-body">{{ arc.gmNotes }}</p>
</section>
<!-- Pages Lore liées (chips cliquables) -->
<section class="view-section" *ngIf="loreId && (arc.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of arc.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
@@ -25,7 +25,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-arc-view', selector: 'app-arc-view',
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent], imports: [RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './arc-view.component.html', templateUrl: './arc-view.component.html',
styleUrls: ['./arc-view.component.scss'] styleUrls: ['./arc-view.component.scss']
}) })

View File

@@ -18,102 +18,114 @@
formControlName="name" formControlName="name"
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..." placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched" [class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label for="campaign-description">Description / Pitch</label>
<textarea
id="campaign-description"
formControlName="description"
placeholder="Résumez l'intrigue principale de votre campagne..."
rows="5"
></textarea>
</div>
<div class="field">
<label for="campaign-player-count">Nombre de joueurs</label>
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
</div>
<div class="field">
<label for="campaign-lore">Univers associé</label>
<select id="campaign-lore" formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
<p class="hint">
Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.
</p>
</div>
<div class="field">
<label for="campaign-game-system">Système de JDR</label>
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
<!-- Mode creation inline : remplace temporairement le select. -->
<div *ngIf="creatingGameSystem" class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/> />
<div class="inline-create-actions"> </div>
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight" <div class="field">
(click)="submitCreateGameSystem()"> <label for="campaign-description">Description / Pitch</label>
<lucide-icon [img]="Check" [size]="14"></lucide-icon> <textarea
Créer id="campaign-description"
</button> formControlName="description"
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()"> placeholder="Résumez l'intrigue principale de votre campagne..."
Annuler rows="5"
</button> ></textarea>
</div> </div>
<div class="field">
<label for="campaign-player-count">Nombre de joueurs</label>
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
</div>
<div class="field">
<label for="campaign-lore">Univers associé</label>
<select id="campaign-lore" formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option>
@for (lore of availableLores; track lore) {
<option [value]="lore.id">{{ lore.name }}</option>
}
</select>
<p class="hint"> <p class="hint">
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
et le reste depuis la section "Systèmes" plus tard. Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.
</p> </p>
</div> </div>
<p *ngIf="!creatingGameSystem" class="hint"> <div class="field">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...) <label for="campaign-game-system">Système de JDR</label>
dans ses suggestions pour respecter les mécaniques du JDR. @if (!creatingGameSystem) {
</p> <select id="campaign-game-system" formControlName="gameSystemId">
<p *ngIf="!creatingGameSystem" class="hint hint-warning"> <option value="">— Aucun (campagne générique) —</option>
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>. @for (gs of availableGameSystems; track gs) {
Le changer plus tard rendra les champs des fiches existantes invisibles <option [value]="gs.id">{{ gs.name }}</option>
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système). }
Choisissez bien dès le départ si possible. <option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</p> </select>
}
<!-- Mode creation inline : remplace temporairement le select. -->
@if (creatingGameSystem) {
<div class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
<p class="hint">
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
et le reste depuis la section "Systèmes" plus tard.
</p>
</div>
}
@if (!creatingGameSystem) {
<p class="hint">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
dans ses suggestions pour respecter les mécaniques du JDR.
</p>
}
@if (!creatingGameSystem) {
<p class="hint hint-warning">
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
Le changer plus tard rendra les champs des fiches existantes invisibles
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
Choisissez bien dès le départ si possible.
</p>
}
</div>
<div class="info-box">
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
<ul>
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
</ul>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
Créer la campagne
</button>
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
</div>
</form>
</div> </div>
</div>
<div class="info-box">
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
<ul>
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
</ul>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
Créer la campagne
</button>
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
</div>
</form>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular'; import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular';
@@ -22,7 +22,7 @@ export interface CampaignCreatePayload {
@Component({ @Component({
selector: 'app-campaign-create', selector: 'app-campaign-create',
imports: [CommonModule, ReactiveFormsModule, FormsModule, LucideAngularModule], imports: [ReactiveFormsModule, FormsModule, LucideAngularModule],
templateUrl: './campaign-create.component.html', templateUrl: './campaign-create.component.html',
styleUrls: ['./campaign-create.component.scss'] styleUrls: ['./campaign-create.component.scss']
}) })

View File

@@ -1,206 +1,233 @@
<div class="campaign-detail" *ngIf="campaign"> @if (campaign) {
<div class="campaign-detail">
<!-- ============ Header : mode lecture ============ --> <!-- ============ Header : mode lecture ============ -->
<div class="detail-header" *ngIf="!editing"> @if (!editing) {
<div class="header-texts"> <div class="detail-header">
<h1>{{ campaign.name }}</h1> <div class="header-texts">
<p class="description">{{ campaign.description }}</p> <h1>{{ campaign.name }}</h1>
<div class="meta"> <p class="description">{{ campaign.description }}</p>
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span> <div class="meta">
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
<!-- Badge "Univers" : lien vers le Lore associé si présent --> <!-- Badge "Univers" : lien vers le Lore associé si présent -->
<a *ngIf="linkedLore" @if (linkedLore) {
class="badge badge-lore" <a
[routerLink]="['/lore', linkedLore.id]" class="badge badge-lore"
title="Ouvrir l'univers associé"> [routerLink]="['/lore', linkedLore.id]"
<lucide-icon [img]="Globe" [size]="12"></lucide-icon> title="Ouvrir l'univers associé">
{{ linkedLore.name }} <lucide-icon [img]="Globe" [size]="12"></lucide-icon>
</a> {{ linkedLore.name }}
</a>
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) --> }
<span *ngIf="campaign.loreId && !linkedLore" class="badge badge-lore-missing" title="L'univers associé est introuvable"> <!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
<lucide-icon [img]="Globe" [size]="12"></lucide-icon> @if (campaign.loreId && !linkedLore) {
Univers introuvable <span class="badge badge-lore-missing" title="L'univers associé est introuvable">
</span> <lucide-icon [img]="Globe" [size]="12"></lucide-icon>
Univers introuvable
</span>
}
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="startEdit()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteCampaign()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div> </div>
</div> }
<div class="header-actions"> <!-- ============ Header : mode édition inline ============ -->
<button type="button" class="btn-secondary" (click)="startEdit()"> @if (editing) {
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> <div class="detail-header edit-mode">
Modifier <div class="field">
</button> <label>Nom</label>
<button type="button" class="btn-danger" (click)="deleteCampaign()"> <input type="text" [(ngModel)]="editName" name="editName" required />
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> </div>
Supprimer <div class="field">
</button> <label>Description</label>
</div> <textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div> </div>
<div class="field">
<!-- ============ Header : mode édition inline ============ --> <label>Univers associé</label>
<div class="detail-header edit-mode" *ngIf="editing"> <select [(ngModel)]="editLoreId" name="editLoreId">
<div class="field"> <option value="">— Aucun univers (campagne libre) —</option>
<label>Nom</label> @for (lore of availableLores; track lore) {
<input type="text" [(ngModel)]="editName" name="editName" required /> <option [value]="lore.id">{{ lore.name }}</option>
</div> }
<div class="field"> </select>
<label>Description</label> </div>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea> <div class="field">
</div> <label>Système de JDR</label>
<div class="field"> @if (!creatingGameSystem) {
<label>Univers associé</label> <select
<select [(ngModel)]="editLoreId" name="editLoreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
</div>
<div class="field">
<label>Système de JDR</label>
<select *ngIf="!creatingGameSystem"
[(ngModel)]="editGameSystemId" [(ngModel)]="editGameSystemId"
name="editGameSystemId" name="editGameSystemId"
(ngModelChange)="onEditGameSystemChange($event)"> (ngModelChange)="onEditGameSystemChange($event)">
<option value="">— Aucun (générique) —</option> <option value="">— Aucun (générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option> @for (gs of availableGameSystems; track gs) {
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option> <option [value]="gs.id">{{ gs.name }}</option>
</select> }
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
<div *ngIf="creatingGameSystem" class="inline-create"> </select>
<input }
type="text" @if (creatingGameSystem) {
[(ngModel)]="newGameSystemName" <div class="inline-create">
name="newGameSystemName" <input
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)" type="text"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()" [(ngModel)]="newGameSystemName"
(keydown.escape)="cancelCreateGameSystem()" name="newGameSystemName"
autofocus placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
/> (keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
<div class="inline-create-actions"> (keydown.escape)="cancelCreateGameSystem()"
<button type="button" class="btn-inline-primary" autofocus
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight" />
(click)="submitCreateGameSystem()"> <div class="inline-create-actions">
<lucide-icon [img]="Check" [size]="14"></lucide-icon> <button type="button" class="btn-inline-primary"
Créer [disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
</button> (click)="submitCreateGameSystem()">
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()"> <lucide-icon [img]="Check" [size]="14"></lucide-icon>
Annuler Créer
</button> </button>
</div> <button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
</div> Annuler
</div> </button>
<div class="header-actions"> </div>
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()"> </div>
Sauvegarder }
</button> </div>
<button type="button" class="btn-secondary" (click)="cancelEdit()"> <div class="header-actions">
Annuler <button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
</button> Sauvegarder
</div> </button>
</div> <button type="button" class="btn-secondary" (click)="cancelEdit()">
Annuler
<section class="detail-section personas-section" *ngIf="!editing"> </button>
<div class="section-header">
<h2>Personnages</h2>
</div>
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
(donnée de scénario, partagée par toutes les Parties). -->
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
<div class="persona-subsection">
<div class="subsection-header">
<h3>
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
Personnages non-joueurs
<span class="count-badge" *ngIf="npcs.length > 0">{{ npcs.length }}</span>
</h3>
<button class="btn-add" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PNJ
</button>
</div>
<div class="characters-grid" *ngIf="npcs.length > 0">
<div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div> </div>
</div> </div>
</div> }
@if (!editing) {
<div class="empty-state empty-state--compact" *ngIf="npcs.length === 0"> <section class="detail-section personas-section">
<p>Aucun PNJ pour le moment.</p> <div class="section-header">
<button class="btn-add-first" (click)="createNpc()"> <h2>Personnages</h2>
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> </div>
Créer votre premier PNJ <!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
</button> ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
</div> (donnée de scénario, partagée par toutes les Parties). -->
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
<div class="persona-subsection">
<div class="subsection-header">
<h3>
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
Personnages non-joueurs
@if (npcs.length > 0) {
<span class="count-badge">{{ npcs.length }}</span>
}
</h3>
<button class="btn-add" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PNJ
</button>
</div>
@if (npcs.length > 0) {
<div class="characters-grid">
@for (npc of npcs; track npc) {
<div class="character-card" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div>
}
</div>
}
@if (npcs.length === 0) {
<div class="empty-state empty-state--compact">
<p>Aucun PNJ pour le moment.</p>
<button class="btn-add-first" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PNJ
</button>
</div>
}
</div>
</section>
}
@if (!editing) {
<section class="detail-section arcs-section">
<div class="section-header">
<h2>Arcs narratifs</h2>
<div class="section-header-actions">
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
</div>
</div>
@if (arcs.length > 0) {
<div class="arcs-grid">
@for (arc of arcs; track arc) {
<div class="arc-card" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
</div>
}
</div>
}
@if (arcs.length === 0) {
<div class="empty-state">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p>
<button class="btn-add-first" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier arc
</button>
</div>
}
</section>
}
<!-- ============ Parties (Playthroughs) ============ -->
@if (!editing) {
<section class="detail-section playthroughs-section">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Mes parties
</h2>
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle partie
</button>
</div>
@if (playthroughs.length > 0) {
<div class="playthroughs-grid">
@for (p of playthroughs; track p) {
<div class="playthrough-card"
(click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span>
@if (p.description) {
<span class="playthrough-meta">{{ p.description }}</span>
}
</div>
</div>
}
</div>
}
@if (playthroughs.length === 0) {
<div class="empty-state empty-state--compact">
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
</div>
}
</section>
}
<!-- Sessions retirées : elles vivent désormais dans une Partie.
Les faits narratifs sont définis dans les conditions de chaque quête
(chapter-edit > « Conditions de déblocage »). -->
</div> </div>
</section> }
<section class="detail-section arcs-section" *ngIf="!editing">
<div class="section-header">
<h2>Arcs narratifs</h2>
<div class="section-header-actions">
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
</div>
</div>
<div class="arcs-grid" *ngIf="arcs.length > 0">
<div class="arc-card" *ngFor="let arc of arcs" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
</div>
</div>
<div class="empty-state" *ngIf="arcs.length === 0">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p>
<button class="btn-add-first" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier arc
</button>
</div>
</section>
<!-- ============ Parties (Playthroughs) ============ -->
<section class="detail-section playthroughs-section" *ngIf="!editing">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Mes parties
</h2>
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle partie
</button>
</div>
<div class="playthroughs-grid" *ngIf="playthroughs.length > 0">
<div class="playthrough-card"
*ngFor="let p of playthroughs"
(click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span>
<span class="playthrough-meta" *ngIf="p.description">{{ p.description }}</span>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="playthroughs.length === 0">
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
</div>
</section>
<!-- Sessions retirées : elles vivent désormais dans une Partie.
Les faits narratifs sont définis dans les conditions de chaque quête
(chapter-edit > « Conditions de déblocage »). -->
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles } from 'lucide-angular';
@@ -27,7 +27,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
@Component({ @Component({
selector: 'app-campaign-detail', selector: 'app-campaign-detail',
imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink], imports: [FormsModule, LucideAngularModule, RouterLink],
templateUrl: './campaign-detail.component.html', templateUrl: './campaign-detail.component.html',
styleUrls: ['./campaign-detail.component.scss'] styleUrls: ['./campaign-detail.component.scss']
}) })

View File

@@ -13,164 +13,199 @@
</div> </div>
<!-- Étape 1 : upload (masqué une fois en revue) --> <!-- Étape 1 : upload (masqué une fois en revue) -->
<section class="upload-area" *ngIf="!reviewing"> @if (!reviewing) {
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" /> <section class="upload-area">
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()"> <input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<lucide-icon [img]="Upload" [size]="16"></lucide-icon> <button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }} <lucide-icon [img]="Upload" [size]="16"></lucide-icon>
</button> {{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
</button>
<!-- Progression live --> <!-- Progression live -->
<div class="import-progress" *ngIf="importing"> @if (importing) {
<p class="import-phase">{{ importPhase }}</p> <div class="import-progress">
<div class="progress-bar" *ngIf="importProgress"> <p class="import-phase">{{ importPhase }}</p>
<div class="progress-fill" @if (importProgress) {
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0"> <div class="progress-bar">
<div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div>
</div>
}
@if (importCounts) {
<p class="import-counts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s)
</p>
}
</div> </div>
</div> }
<p class="import-counts" *ngIf="importCounts"> @if (importError) {
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) <p class="import-error">{{ importError }}</p>
</p> }
</div> </section>
}
<p class="import-error" *ngIf="importError">{{ importError }}</p>
</section>
<!-- Étape 2 : revue de l'arbre éditable --> <!-- Étape 2 : revue de l'arbre éditable -->
<section class="review-area" *ngIf="reviewing"> @if (reviewing) {
<div class="review-header"> <section class="review-area">
<p class="review-summary"> <div class="review-header">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s), <p class="review-summary">
<strong>{{ chapterCount }}</strong> chapitre(s), À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
<strong>{{ sceneCount }}</strong> scène(s). <strong>{{ chapterCount }}</strong> chapitre(s),
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ; <strong>{{ sceneCount }}</strong> scène(s).
ils ne seront pas recréés. Modifiez les nouveaux, puis créez. Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
</p> ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
</div> </p>
</div>
<div class="tree"> <div class="tree">
<!-- Arc --> <!-- Arc -->
<div class="arc-card" *ngFor="let arc of tree; let ai = index"> @for (arc of tree; track arc; let ai = $index) {
<div class="node-head arc-head" [class.existing-node]="arc.existing"> <div class="arc-card">
<button type="button" class="btn-collapse" (click)="toggleArc(arc)"> <div class="node-head arc-head" [class.existing-node]="arc.existing">
<lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon> <button type="button" class="btn-collapse" (click)="toggleArc(arc)">
</button> <lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" />
<span class="badge-existing" *ngIf="arc.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!arc.existing" (click)="removeArc(ai)" title="Supprimer l'arc">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<div class="node-body" *ngIf="!arc.collapsed">
<div class="arc-type-toggle" *ngIf="!arc.existing">
<span class="toggle-label">Type :</span>
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
(click)="setArcType(arc, 'LINEAR')">Linéaire</button>
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
</div>
<textarea class="node-desc" *ngIf="!arc.existing" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
<!-- Chapitres -->
<div class="chapter-card" *ngFor="let chapter of arc.chapters; let ci = index">
<div class="node-head chapter-head" [class.existing-node]="chapter.existing">
<button type="button" class="btn-collapse" (click)="toggleChapter(chapter)">
<lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
</button>
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[readonly]="chapter.existing" placeholder="Nom du chapitre" />
<span class="badge-existing" *ngIf="chapter.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!chapter.existing" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button> </button>
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" />
@if (arc.existing) {
<span class="badge-existing">déjà présent</span>
}
@if (!arc.existing) {
<button type="button" class="btn-remove" (click)="removeArc(ai)" title="Supprimer l'arc">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
}
</div> </div>
@if (!arc.collapsed) {
<div class="node-body" *ngIf="!chapter.collapsed"> <div class="node-body">
<textarea class="node-desc" *ngIf="!chapter.existing" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci" @if (!arc.existing) {
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea> <div class="arc-type-toggle">
<span class="toggle-label">Type :</span>
<!-- Scènes --> <button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
<div class="scene-card" *ngFor="let scene of chapter.scenes; let si = index" (click)="setArcType(arc, 'LINEAR')">Linéaire</button>
[class.existing-node]="scene.existing"> <button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
<div class="scene-row"> (click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
<lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon>
<div class="scene-fields">
<input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" />
<input type="text" class="node-desc-inline" *ngIf="!scene.existing" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
</div> </div>
<span class="badge-existing" *ngIf="scene.existing">déjà présent</span> }
<button type="button" class="btn-details" *ngIf="!scene.existing" (click)="toggleDetails(scene)" @if (!arc.existing) {
title="Narration joueurs, notes MJ, pièces"> <textarea class="node-desc" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon> rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
Détails<span *ngIf="scene.rooms.length"> · {{ scene.rooms.length }} pièce(s)</span> }
</button> <!-- Chapitres -->
<button type="button" class="btn-remove" *ngIf="!scene.existing" (click)="removeScene(chapter, si)" title="Supprimer la scène"> @for (chapter of arc.chapters; track chapter; let ci = $index) {
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon> <div class="chapter-card">
</button> <div class="node-head chapter-head" [class.existing-node]="chapter.existing">
</div> <button type="button" class="btn-collapse" (click)="toggleChapter(chapter)">
<lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
<div class="scene-details" *ngIf="scene.detailsOpen && !scene.existing"> </button>
<label class="field-label">À lire aux joueurs</label> <lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration" <input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3" [readonly]="chapter.existing" placeholder="Nom du chapitre" />
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea> @if (chapter.existing) {
<span class="badge-existing">déjà présent</span>
<label class="field-label">Notes MJ</label> }
<textarea class="node-desc" [(ngModel)]="scene.gmNotes" @if (!chapter.existing) {
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3" <button type="button" class="btn-remove" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
placeholder="Secrets, développement, conséquences (optionnel)"></textarea> <lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
<!-- Pièces (donjon) : vide = scène narrative classique --> }
<span class="field-label">Pièces (lieu explorable)</span> </div>
<div class="rooms"> @if (!chapter.collapsed) {
<div class="room-row" *ngFor="let room of scene.rooms; let ri = index"> <div class="node-body">
<input type="text" class="room-name" [(ngModel)]="room.name" @if (!chapter.existing) {
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" /> <textarea class="node-desc" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
<input type="text" class="room-field" [(ngModel)]="room.description" rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" /> }
<input type="text" class="room-field" [(ngModel)]="room.enemies" <!-- Scènes -->
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" /> @for (scene of chapter.scenes; track scene; let si = $index) {
<input type="text" class="room-field" [(ngModel)]="room.loot" <div class="scene-card"
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" /> [class.existing-node]="scene.existing">
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce"> <div class="scene-row">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon> <lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon>
<div class="scene-fields">
<input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" />
@if (!scene.existing) {
<input type="text" class="node-desc-inline" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
}
</div>
@if (scene.existing) {
<span class="badge-existing">déjà présent</span>
}
@if (!scene.existing) {
<button type="button" class="btn-details" (click)="toggleDetails(scene)"
title="Narration joueurs, notes MJ, pièces">
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
Détails@if (scene.rooms.length) {
<span> · {{ scene.rooms.length }} pièce(s)</span>
}
</button>
}
@if (!scene.existing) {
<button type="button" class="btn-remove" (click)="removeScene(chapter, si)" title="Supprimer la scène">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
}
</div>
@if (scene.detailsOpen && !scene.existing) {
<div class="scene-details">
<label class="field-label">À lire aux joueurs</label>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
<label class="field-label">Notes MJ</label>
<textarea class="node-desc" [(ngModel)]="scene.gmNotes"
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
<!-- Pièces (donjon) : vide = scène narrative classique -->
<span class="field-label">Pièces (lieu explorable)</span>
<div class="rooms">
@for (room of scene.rooms; track room; let ri = $index) {
<div class="room-row">
<input type="text" class="room-name" [(ngModel)]="room.name"
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
<input type="text" class="room-field" [(ngModel)]="room.description"
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" />
<input type="text" class="room-field" [(ngModel)]="room.enemies"
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" />
<input type="text" class="room-field" [(ngModel)]="room.loot"
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" />
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
}
<button type="button" class="btn-add-inline" (click)="addRoom(scene)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
</button>
</div>
</div>
}
</div>
}
<button type="button" class="btn-add-inline" (click)="addScene(chapter)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
</button> </button>
</div> </div>
<button type="button" class="btn-add-inline" (click)="addRoom(scene)"> }
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
</button>
</div>
</div> </div>
</div> }
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<button type="button" class="btn-add-inline" (click)="addScene(chapter)"> <lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
</button> </button>
</div> </div>
</div> }
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
</button>
</div> </div>
</div> }
<button type="button" class="btn-add" (click)="addArc()"> <button type="button" class="btn-add" (click)="addArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc <lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
</button> </button>
</div> </div>
@if (applyError) {
<p class="apply-error" *ngIf="applyError">{{ applyError }}</p> <p class="apply-error">{{ applyError }}</p>
}
<div class="review-actions"> <div class="review-actions">
<button type="button" class="btn-primary" [disabled]="applying || !hasNewContent" (click)="apply()"> <button type="button" class="btn-primary" [disabled]="applying || !hasNewContent" (click)="apply()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon> <lucide-icon [img]="Check" [size]="16"></lucide-icon>
@@ -179,5 +214,6 @@
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button> <button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div> </div>
</section> </section>
}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
@@ -47,7 +47,7 @@ interface ArcNode {
*/ */
@Component({ @Component({
selector: 'app-campaign-import', selector: 'app-campaign-import',
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [FormsModule, LucideAngularModule],
templateUrl: './campaign-import.component.html', templateUrl: './campaign-import.component.html',
styleUrls: ['./campaign-import.component.scss'] styleUrls: ['./campaign-import.component.scss']
}) })

View File

@@ -8,18 +8,20 @@
<div class="campaigns-grid"> <div class="campaigns-grid">
<div class="campaign-card" *ngFor="let campaign of campaigns" (click)="navigateToDetail(campaign.id!)"> @for (campaign of campaigns; track campaign) {
<div class="card-header"> <div class="campaign-card" (click)="navigateToDetail(campaign.id!)">
<span class="status-badge en-cours">En cours</span> <div class="card-header">
<span class="card-date">{{ campaign.playerCount }} joueurs</span> <span class="status-badge en-cours">En cours</span>
<span class="card-date">{{ campaign.playerCount }} joueurs</span>
</div>
<h2>{{ campaign.name }}</h2>
<p class="card-description">{{ campaign.description }}</p>
<div class="card-stats">
<span>⚔️ {{ campaign.arcCount || 0 }} arcs</span>
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
</div>
</div> </div>
<h2>{{ campaign.name }}</h2> }
<p class="card-description">{{ campaign.description }}</p>
<div class="card-stats">
<span>⚔️ {{ campaign.arcCount || 0 }} arcs</span>
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
</div>
</div>
<div class="campaign-card card-new" (click)="openCreateModal()"> <div class="campaign-card card-new" (click)="openCreateModal()">
<div class="new-icon"> <div class="new-icon">
@@ -35,8 +37,9 @@
</div> </div>
<app-campaign-create @if (showCreateModal) {
*ngIf="showCreateModal" <app-campaign-create
(close)="onModalClose()" (close)="onModalClose()"
(created)="onCampaignCreated($event)"> (created)="onCampaignCreated($event)">
</app-campaign-create> </app-campaign-create>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, Map, Plus } from 'lucide-angular'; import { LucideAngularModule, Map, Plus } from 'lucide-angular';
import { CampaignService } from '../services/campaign.service'; import { CampaignService } from '../services/campaign.service';
@@ -9,7 +9,7 @@ import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campa
@Component({ @Component({
selector: 'app-campaigns', selector: 'app-campaigns',
imports: [CommonModule, LucideAngularModule, CampaignCreateComponent], imports: [LucideAngularModule, CampaignCreateComponent],
templateUrl: './campaigns.component.html', templateUrl: './campaigns.component.html',
styleUrls: ['./campaigns.component.scss'] styleUrls: ['./campaigns.component.scss']
}) })

View File

@@ -2,7 +2,9 @@
<div class="page-header"> <div class="page-header">
<h1>{{ isHub ? 'Créer une nouvelle quête' : 'Créer un nouveau chapitre' }}</h1> <h1>{{ isHub ? 'Créer une nouvelle quête' : 'Créer un nouveau chapitre' }}</h1>
<p class="arc-ref" *ngIf="arcName">{{ isHub ? 'Hub' : 'Arc' }} : {{ arcName }}</p> @if (arcName) {
<p class="arc-ref">{{ isHub ? 'Hub' : 'Arc' }} : {{ arcName }}</p>
}
</div> </div>
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form"> <form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
@@ -15,7 +17,7 @@
formControlName="name" formControlName="name"
[placeholder]="isHub ? 'Ex: Sauver le marchand disparu' : 'Ex: Chapitre 1: Les Disparitions'" [placeholder]="isHub ? 'Ex: Sauver le marchand disparu' : 'Ex: Chapitre 1: Les Disparitions'"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched" [class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/> />
</div> </div>
<div class="field"> <div class="field">

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -19,7 +19,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
*/ */
@Component({ @Component({
selector: 'app-chapter-create', selector: 'app-chapter-create',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent], imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './chapter-create.component.html', templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss'] styleUrls: ['./chapter-create.component.scss']
}) })

View File

@@ -7,9 +7,9 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-ai" <button type="button" class="btn-ai"
(click)="toggleChat()" (click)="toggleChat()"
[class.active]="chatOpen" [class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre"> title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </button>
@@ -27,7 +27,7 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form"> <form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête) <!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. --> comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
<div class="hub-section"> <div class="hub-section">
<h2 class="hub-section-title"> <h2 class="hub-section-title">
{{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }} {{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }}
@@ -78,7 +78,7 @@
formControlName="name" formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions" placeholder="Ex: Chapitre 1: Les Disparitions"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched" [class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/> />
</div> </div>
<div class="field"> <div class="field">
@@ -129,25 +129,29 @@
</div> </div>
<!-- ===== Pages Lore associées (B2 cross-context) ===== --> <!-- ===== Pages Lore associées (B2 cross-context) ===== -->
<div class="field" *ngIf="loreId"> @if (loreId) {
<label>Pages Lore associées</label> <div class="field">
<app-lore-link-picker <label>Pages Lore associées</label>
[value]="relatedPageIds" <app-lore-link-picker
[availablePages]="availablePages" [value]="relatedPageIds"
[loreId]="loreId" [availablePages]="availablePages"
(valueChange)="relatedPageIds = $event"> [loreId]="loreId"
</app-lore-link-picker> (valueChange)="relatedPageIds = $event">
<small class="field-hint"> </app-lore-link-picker>
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent. <small class="field-hint">
</small> Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
</div> </small>
</div>
}
<div class="field" *ngIf="!loreId"> @if (!loreId) {
<small class="field-hint"> <div class="field">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne <small class="field-hint">
pour pouvoir lier ce chapitre à des pages du Lore. 💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
</small> pour pouvoir lier ce chapitre à des pages du Lore.
</div> </small>
</div>
}
</form> </form>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -34,15 +34,14 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
@Component({ @Component({
selector: 'app-chapter-edit', selector: 'app-chapter-edit',
imports: [ imports: [
CommonModule, ReactiveFormsModule,
ReactiveFormsModule, LucideAngularModule,
LucideAngularModule, LoreLinkPickerComponent,
LoreLinkPickerComponent, AiChatDrawerComponent,
AiChatDrawerComponent, ImageGalleryComponent,
ImageGalleryComponent, IconPickerComponent,
IconPickerComponent, PrerequisiteEditorComponent
PrerequisiteEditorComponent ],
],
templateUrl: './chapter-edit.component.html', templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss'] styleUrls: ['./chapter-edit.component.scss']
}) })

View File

@@ -11,64 +11,70 @@
</button> </button>
</div> </div>
<div class="graph-empty" *ngIf="scenes.length === 0"> @if (scenes.length === 0) {
<p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p> <div class="graph-empty">
</div> <p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p>
</div>
}
<div class="graph-container" *ngIf="scenes.length > 0"> @if (scenes.length > 0) {
<svg #svgEl <div class="graph-container">
[attr.width]="svgWidth" [attr.height]="svgHeight" <svg #svgEl
class="graph-svg" [attr.width]="svgWidth" [attr.height]="svgHeight"
(pointermove)="onPointerMove($event)" class="graph-svg"
(pointerup)="onPointerUp($event)" (pointermove)="onPointerMove($event)"
(pointercancel)="onPointerUp($event)"> (pointerup)="onPointerUp($event)"
<defs> (pointercancel)="onPointerUp($event)">
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5" <defs>
markerWidth="7" markerHeight="7" orient="auto-start-reverse"> <marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" /> markerWidth="7" markerHeight="7" orient="auto-start-reverse">
</marker> <path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" />
</defs> </marker>
</defs>
<g class="edges"> <g class="edges">
<g class="edge" *ngFor="let edge of edges"> @for (edge of edges; track edge) {
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1" <g class="edge">
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
[attr.x2]="edge.x2" [attr.y2]="edge.y2" [attr.x2]="edge.x2" [attr.y2]="edge.y2"
stroke="#b8c0cc" stroke-width="2" stroke="#b8c0cc" stroke-width="2"
marker-end="url(#arrowhead)" /> marker-end="url(#arrowhead)" />
<text *ngIf="edge.label" @if (edge.label) {
[attr.x]="edge.labelX" <text
[attr.y]="edge.labelY" [attr.x]="edge.labelX"
text-anchor="middle" [attr.y]="edge.labelY"
class="edge-label" text-anchor="middle"
[class.dragging]="draggingLabelKey === edge.key" class="edge-label"
(pointerdown)="onLabelPointerDown($event, edge)"> [class.dragging]="draggingLabelKey === edge.key"
{{ edge.label }} (pointerdown)="onLabelPointerDown($event, edge)">
</text> {{ edge.label }}
</text>
}
</g>
}
</g> </g>
</g> <g class="nodes">
@for (node of nodes; track node) {
<g class="nodes"> <g class="node"
<g class="node" [class.dragging]="draggingId === node.id"
[class.dragging]="draggingId === node.id" (pointerdown)="onPointerDown($event, node)">
*ngFor="let node of nodes" <title>{{ node.name }}</title>
(pointerdown)="onPointerDown($event, node)"> <rect [attr.x]="node.x" [attr.y]="node.y"
<title>{{ node.name }}</title>
<rect [attr.x]="node.x" [attr.y]="node.y"
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT" [attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
rx="8" ry="8" class="node-box" /> rx="8" ry="8" class="node-box" />
<text [attr.x]="node.x + NODE_WIDTH / 2" <text [attr.x]="node.x + NODE_WIDTH / 2"
[attr.y]="node.y + NODE_HEIGHT / 2 + 5" [attr.y]="node.y + NODE_HEIGHT / 2 + 5"
text-anchor="middle" text-anchor="middle"
class="node-label"> class="node-label">
{{ node.displayName }} {{ node.displayName }}
</text> </text>
</g>
}
</g> </g>
</g> </svg>
</svg> <small class="graph-hint">
💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
<small class="graph-hint"> </small>
💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas. </div>
</small> }
</div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core'; import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
@@ -21,7 +21,7 @@ interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: nu
*/ */
@Component({ @Component({
selector: 'app-chapter-graph', selector: 'app-chapter-graph',
imports: [CommonModule, RouterModule, LucideAngularModule], imports: [RouterModule, LucideAngularModule],
templateUrl: './chapter-graph.component.html', templateUrl: './chapter-graph.component.html',
styleUrls: ['./chapter-graph.component.scss'] styleUrls: ['./chapter-graph.component.scss']
}) })

View File

@@ -1,112 +1,127 @@
<div class="view-page" *ngIf="chapter"> @if (chapter) {
<div class="view-page">
<header class="view-header"> <header class="view-header">
<div> <div>
<h1> <h1>
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon> @if (chapter.icon) {
{{ chapter.name }} <lucide-icon [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
</h1> }
<p class="view-subtitle"> {{ chapter.name }}
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }} </h1>
<span class="cond-badge" *ngIf="(chapter.prerequisites?.length ?? 0) > 0" <p class="view-subtitle">
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
@if ((chapter.prerequisites?.length ?? 0) > 0) {
<span class="cond-badge"
title="Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies."> title="Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.">
<lucide-icon [img]="Lock" [size]="12"></lucide-icon> <lucide-icon [img]="Lock" [size]="12"></lucide-icon>
Conditionnel Conditionnel
</span> </span>
</p> }
</div> </p>
<div class="view-actions"> </div>
<button type="button" class="btn-secondary" (click)="openGraph()" <div class="view-actions">
title="Voir l'organigramme des scènes et de leurs branches"> <button type="button" class="btn-secondary" (click)="openGraph()"
<lucide-icon [img]="Network" [size]="14"></lucide-icon> title="Voir l'organigramme des scènes et de leurs branches">
Carte du chapitre <lucide-icon [img]="Network" [size]="14"></lucide-icon>
</button> Carte du chapitre
<button type="button" class="btn-primary" (click)="editMode()"> </button>
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> <button type="button" class="btn-primary" (click)="editMode()">
Modifier <lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
</button> Modifier
<button type="button" class="btn-danger" (click)="deleteChapter()" title="Supprimer le chapitre et ses scènes"> </button>
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <button type="button" class="btn-danger" (click)="deleteChapter()" title="Supprimer le chapitre et ses scènes">
Supprimer <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> Supprimer
</div> </button>
</header> </div>
</header>
<!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête) <!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête)
OU dès qu'un chapitre linéaire porte des conditions. --> OU dès qu'un chapitre linéaire porte des conditions. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0"> @if (parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0) {
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs"> @if ((chapter.prerequisites?.length ?? 0) > 0) {
<ul class="view-section-body"> <ul class="view-section-body">
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li> @for (p of chapter.prerequisites; track p) {
</ul> <li>{{ describePrerequisite(p) }}</li>
<small class="view-section-empty"> }
Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque </ul>
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran <small class="view-section-empty">
« Faits » de la Partie. Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
</small> dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
</ng-container> « Faits » de la Partie.
</small>
<ng-template #noPrereqs> } @else {
<p class="view-section-empty"> <p class="view-section-empty">
Aucune condition — cette quête est disponible dès le début dans chaque Partie. Aucune condition — cette quête est disponible dès le début dans chaque Partie.
</p> </p>
<p class="view-section-empty"> <p class="view-section-empty">
Cliquez sur <strong>Modifier</strong> pour ajouter des conditions Cliquez sur <strong>Modifier</strong> pour ajouter des conditions
(« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »). (« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »).
</p> </p>
</ng-template> }
</section> </section>
}
<!-- Illustrations (rendu editorial magazine) --> <!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0"> @if ((chapter.illustrationImageIds?.length ?? 0) > 0) {
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery> <section class="view-section">
</section> <app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans --> }
<section class="view-section" *ngIf="(chapter.mapImageIds?.length ?? 0) > 0"> <!-- Cartes & plans -->
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2> @if ((chapter.mapImageIds?.length ?? 0) > 0) {
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery> <section class="view-section">
</section> <h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
<section class="view-section"> </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"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> Objectifs des joueurs</h2> <h2 class="view-section-title"><span class="view-section-icon">📖</span> Synopsis</h2>
<p class="view-section-body" *ngIf="chapter.playerObjectives?.trim(); else emptyObj">{{ chapter.playerObjectives }}</p> @if (chapter.description?.trim()) {
<ng-template #emptyObj><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ chapter.description }}</p>
</section> } @else {
<p class="view-section-empty">Non renseigné</p>
<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> </section>
<div class="view-row">
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> Objectifs des joueurs</h2>
@if (chapter.playerObjectives?.trim()) {
<p class="view-section-body">{{ chapter.playerObjectives }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Enjeux narratifs</h2>
@if (chapter.narrativeStakes?.trim()) {
<p class="view-section-body">{{ chapter.narrativeStakes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
</div>
@if (chapter.gmNotes?.trim()) {
<section class="view-section view-section--private">
<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>
}
@if (loreId && (chapter.relatedPageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
@for (relId of chapter.relatedPageIds; track relId) {
<a class="view-chip"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
}
</div>
</section>
}
</div> </div>
}
<section class="view-section view-section--private" *ngIf="chapter.gmNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes du Maître de Jeu
</h2>
<p class="view-section-body">{{ chapter.gmNotes }}</p>
</section>
<section class="view-section" *ngIf="loreId && (chapter.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of chapter.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
@@ -24,7 +24,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-chapter-view', selector: 'app-chapter-view',
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent], imports: [RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './chapter-view.component.html', templateUrl: './chapter-view.component.html',
styleUrls: ['./chapter-view.component.scss'] styleUrls: ['./chapter-view.component.scss']
}) })

View File

@@ -10,16 +10,17 @@
<lucide-icon [img]="User" [size]="22"></lucide-icon> <lucide-icon [img]="User" [size]="22"></lucide-icon>
{{ characterId ? 'Éditer la fiche' : 'Nouveau personnage' }} {{ characterId ? 'Éditer la fiche' : 'Nouveau personnage' }}
</h1> </h1>
<button @if (characterId) {
*ngIf="characterId" <button
type="button" type="button"
class="btn-ai" class="btn-ai"
(click)="toggleChat()" (click)="toggleChat()"
[class.active]="chatOpen" [class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PJ"> title="Ouvrir l'Assistant IA pour dialoguer autour de ce PJ">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </button>
}
</div> </div>
</div> </div>
@@ -33,70 +34,72 @@
[(ngModel)]="name" [(ngModel)]="name"
name="name" name="name"
placeholder="Ex: Thorin le Grand-Hache, Lyra l'Errante..." placeholder="Ex: Thorin le Grand-Hache, Lyra l'Errante..."
/> />
</div>
<div class="field-row image-row">
<div class="field portrait-field">
<label>Portrait</label>
<app-single-image-picker
[imageId]="portraitImageId"
aspectRatio="1 / 1"
hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
</div> </div>
<div class="field header-field">
<label>Bandeau / Header</label> <div class="field-row image-row">
<app-single-image-picker <div class="field portrait-field">
[imageId]="headerImageId" <label>Portrait</label>
aspectRatio="3 / 1" <app-single-image-picker
hint="Format paysage conseille (1200×400)." [imageId]="portraitImageId"
(imageIdChange)="headerImageId = $event"> aspectRatio="1 / 1"
</app-single-image-picker> hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
</div>
<div class="field header-field">
<label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div> </div>
</div>
<div class="template-fields"> <div class="template-fields">
<app-dynamic-fields-form <app-dynamic-fields-form
[fields]="templateFields" [fields]="templateFields"
[values]="values" [values]="values"
[imageValues]="imageValues" [imageValues]="imageValues"
[keyValueValues]="keyValueValues" [keyValueValues]="keyValueValues"
(valuesChange)="values = $event" (valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event" (imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event"> (keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form> </app-dynamic-fields-form>
</div> </div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ characterId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
@if (characterId) {
<button
type="button"
class="btn-danger"
(click)="deleteCharacter()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
}
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ characterId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
<button
*ngIf="characterId"
type="button"
class="btn-danger"
(click)="deleteCharacter()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div> </div>
</div> </div>
</div> @if (characterId && campaignId) {
<app-ai-chat-drawer
<app-ai-chat-drawer [campaignId]="campaignId"
*ngIf="characterId && campaignId" entityType="character"
[campaignId]="campaignId" [entityId]="characterId"
entityType="character" [isOpen]="chatOpen"
[entityId]="characterId" welcomeMessage="Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels."
[isOpen]="chatOpen" [quickSuggestions]="chatQuickSuggestions"
welcomeMessage="Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels." (close)="chatOpen = false">
[quickSuggestions]="chatQuickSuggestions" </app-ai-chat-drawer>
(close)="chatOpen = false"> }
</app-ai-chat-drawer>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
@@ -26,7 +26,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-character-edit', selector: 'app-character-edit',
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent], imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './character-edit.component.html', templateUrl: './character-edit.component.html',
styleUrls: ['./character-edit.component.scss'] styleUrls: ['./character-edit.component.scss']
}) })

View File

@@ -5,10 +5,12 @@
Retour Retour
</button> </button>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="characterId"> @if (characterId) {
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen">
Assistant IA <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
</button> Assistant IA
</button>
}
<button class="btn-edit" (click)="edit()"> <button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> <lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer Editer
@@ -18,11 +20,12 @@
<app-persona-view [persona]="character" [templateFields]="templateFields"></app-persona-view> <app-persona-view [persona]="character" [templateFields]="templateFields"></app-persona-view>
</div> </div>
<app-ai-chat-drawer @if (characterId && campaignId) {
*ngIf="characterId && campaignId" <app-ai-chat-drawer
[campaignId]="campaignId" [campaignId]="campaignId"
entityType="character" entityType="character"
[entityId]="characterId" [entityId]="characterId"
[isOpen]="chatOpen" [isOpen]="chatOpen"
(close)="chatOpen = false"> (close)="chatOpen = false">
</app-ai-chat-drawer> </app-ai-chat-drawer>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
@@ -17,7 +17,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
*/ */
@Component({ @Component({
selector: 'app-character-view', selector: 'app-character-view',
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent], imports: [LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './character-view.component.html', templateUrl: './character-view.component.html',
styleUrls: ['./character-view.component.scss'] styleUrls: ['./character-view.component.scss']
}) })

View File

@@ -12,7 +12,9 @@
<h1>{{ catalogId ? 'Éditer le catalogue' : 'Nouveau catalogue d\'objets' }}</h1> <h1>{{ catalogId ? 'Éditer le catalogue' : 'Nouveau catalogue d\'objets' }}</h1>
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div> @if (errorMessage) {
<div class="error">{{ errorMessage }}</div>
}
<div class="form-row"> <div class="form-row">
<label for="ic-name">Nom *</label> <label for="ic-name">Nom *</label>
@@ -29,13 +31,15 @@
<div class="ai-title"><lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA</div> <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> <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" <textarea rows="2" [(ngModel)]="aiPrompt"
placeholder="Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques"></textarea> placeholder="Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques"></textarea>
<div class="ai-actions"> <div class="ai-actions">
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating"> <button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
{{ generating ? 'Génération…' : 'Générer' }} {{ generating ? 'Génération…' : 'Générer' }}
</button> </button>
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span> @if (aiError) {
<span class="ai-error">{{ aiError }}</span>
}
</div> </div>
</div> </div>
@@ -54,15 +58,19 @@
<span class="c-del"></span> <span class="c-del"></span>
</div> </div>
<div class="item-row" *ngFor="let it of items; let i = index"> @for (it of items; track it; let i = $index) {
<input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet"> <div class="item-row">
<input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po"> <input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet">
<input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes"> <input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po">
<input class="c-desc" type="text" [(ngModel)]="it.description" placeholder="Effet / détails"> <input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes">
<button class="c-del btn-del" (click)="removeItem(i)" title="Supprimer"> <input class="c-desc" type="text" [(ngModel)]="it.description" placeholder="Effet / détails">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <button class="c-del btn-del" (click)="removeItem(i)" title="Supprimer">
</button> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</div> </button>
</div>
}
<p class="empty-hint" *ngIf="items.length === 0">Aucun objet — clique « Ajouter ».</p> @if (items.length === 0) {
<p class="empty-hint">Aucun objet — clique « Ajouter ».</p>
}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Sparkles } from 'lucide-angular';
@@ -14,7 +14,7 @@ import { ItemCatalog, CatalogItem, ItemCatalogCreate } from '../../../services/i
*/ */
@Component({ @Component({
selector: 'app-item-catalog-edit', selector: 'app-item-catalog-edit',
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [FormsModule, LucideAngularModule],
templateUrl: './item-catalog-edit.component.html', templateUrl: './item-catalog-edit.component.html',
styleUrls: ['./item-catalog-edit.component.scss'] styleUrls: ['./item-catalog-edit.component.scss']
}) })

View File

@@ -18,14 +18,18 @@
</header> </header>
<div class="icl-list"> <div class="icl-list">
<p class="empty" *ngIf="catalogs.length === 0">Aucun catalogue pour l'instant.</p> @if (catalogs.length === 0) {
<button class="icl-item" *ngFor="let c of catalogs" (click)="open(c)"> <p class="empty">Aucun catalogue pour l'instant.</p>
<lucide-icon [img]="Package" [size]="16"></lucide-icon> }
<span class="icl-item-name">{{ c.name }}</span> @for (c of catalogs; track c) {
<span class="icl-item-count">{{ c.items.length }} objet(s)</span> <button class="icl-item" (click)="open(c)">
<span class="icl-del" (click)="remove(c, $event)" title="Supprimer"> <lucide-icon [img]="Package" [size]="16"></lucide-icon>
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <span class="icl-item-name">{{ c.name }}</span>
</span> <span class="icl-item-count">{{ c.items.length }} objet(s)</span>
</button> <span class="icl-del" (click)="remove(c, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Package } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Plus, Trash2, Package } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service'; import { ItemCatalogService } from '../../../services/item-catalog.service';
@@ -13,7 +13,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-item-catalog-list', selector: 'app-item-catalog-list',
imports: [CommonModule, LucideAngularModule], imports: [LucideAngularModule],
templateUrl: './item-catalog-list.component.html', templateUrl: './item-catalog-list.component.html',
styleUrls: ['./item-catalog-list.component.scss'] styleUrls: ['./item-catalog-list.component.scss']
}) })

View File

@@ -1,33 +1,42 @@
<div class="ic-page" *ngIf="catalog"> @if (catalog) {
<div class="ic-toolbar"> <div class="ic-page">
<button class="btn-back" (click)="back()"> <div class="ic-toolbar">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour <button class="btn-back" (click)="back()">
</button> <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
<span class="spacer"></span> </button>
<button class="btn-edit" (click)="edit()"> <span class="spacer"></span>
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> Éditer <button class="btn-edit" (click)="edit()">
</button> <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>
@if (catalog.description) {
<p class="ic-desc">{{ catalog.description }}</p>
}
</header>
@if (catalog.items.length === 0) {
<p class="ic-empty">
Aucun objet — édite le catalogue pour en ajouter.
</p>
}
@for (g of groups; track g) {
<section class="ic-group">
@if (g.category !== '—') {
<h2>{{ g.category }}</h2>
}
<table>
<tbody>
@for (it of g.items; track it) {
<tr>
<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> </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

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Package } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Edit3, Package } from 'lucide-angular';
import { ItemCatalogService } from '../../../services/item-catalog.service'; import { ItemCatalogService } from '../../../services/item-catalog.service';
@@ -14,7 +14,7 @@ interface ItemGroup { category: string; items: CatalogItem[]; }
*/ */
@Component({ @Component({
selector: 'app-item-catalog-view', selector: 'app-item-catalog-view',
imports: [CommonModule, LucideAngularModule], imports: [LucideAngularModule],
templateUrl: './item-catalog-view.component.html', templateUrl: './item-catalog-view.component.html',
styleUrls: ['./item-catalog-view.component.scss'] styleUrls: ['./item-catalog-view.component.scss']
}) })

View File

@@ -5,37 +5,55 @@
<span class="nac-name">{{ action.name }}</span> <span class="nac-name">{{ action.name }}</span>
</div> </div>
<p class="nac-desc" *ngIf="action.description">{{ action.description }}</p> @if (action.description) {
<p class="nac-desc">{{ action.description }}</p>
}
<!-- Cibles --> <!-- Cibles -->
<div class="nac-targets" *ngIf="status !== 'created' && needsArc"> @if (status !== 'created' && needsArc) {
<label> <div class="nac-targets">
Arc <label>
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()"> Arc
<option *ngFor="let a of arcs" [value]="a.id">{{ a.name }}</option> <select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
</select> @for (a of arcs; track a) {
</label> <option [value]="a.id">{{ a.name }}</option>
<label *ngIf="needsChapter"> }
Chapitre </select>
<select [(ngModel)]="selectedChapterId"> </label>
<option *ngFor="let c of targetChapters" [value]="c.id">{{ c.name }}</option> @if (needsChapter) {
</select> <label>
</label> Chapitre
</div> <select [(ngModel)]="selectedChapterId">
@for (c of targetChapters; track c) {
<option [value]="c.id">{{ c.name }}</option>
}
</select>
</label>
}
</div>
}
<p class="nac-warn" *ngIf="needsArc && arcs.length === 0"> @if (needsArc && arcs.length === 0) {
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément. <p class="nac-warn">
</p> Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
</p>
}
<div class="nac-foot"> <div class="nac-foot">
<button class="nac-create" *ngIf="status !== 'created'" (click)="create()" [disabled]="!canCreate"> @if (status !== 'created') {
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> <button class="nac-create" (click)="create()" [disabled]="!canCreate">
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }} <lucide-icon [img]="Plus" [size]="13"></lucide-icon>
</button> {{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
<button class="nac-open" *ngIf="status === 'created'" (click)="openCreated()"> </button>
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir }
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon> @if (status === 'created') {
</button> <button class="nac-open" (click)="openCreated()">
<span class="nac-error" *ngIf="status === 'error'">{{ errorMsg }}</span> <lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
</button>
}
@if (status === 'error') {
<span class="nac-error">{{ errorMsg }}</span>
}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, Plus, Check, Drama, Clapperboard, BookText, GitBranch, Dices, ExternalLink } from 'lucide-angular'; import { LucideAngularModule, Plus, Check, Drama, Clapperboard, BookText, GitBranch, Dices, ExternalLink } from 'lucide-angular';
@@ -15,7 +15,7 @@ import { NotebookAction } from '../../../services/notebook-action.model';
*/ */
@Component({ @Component({
selector: 'app-notebook-action-card', selector: 'app-notebook-action-card',
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [FormsModule, LucideAngularModule],
templateUrl: './notebook-action-card.component.html', templateUrl: './notebook-action-card.component.html',
styleUrls: ['./notebook-action-card.component.scss'] styleUrls: ['./notebook-action-card.component.scss']
}) })

View File

@@ -1,98 +1,117 @@
<div class="nbd-page" *ngIf="detail"> @if (detail) {
<div class="nbd-toolbar"> <div class="nbd-page">
<button class="btn-back" (click)="back()"> <div class="nbd-toolbar">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers <button class="btn-back" (click)="back()">
</button> <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()"> </button>
</div> <input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
</div>
<div class="nbd-grid"> <div class="nbd-grid">
<!-- Sources --> <!-- Sources -->
<aside class="nbd-sources"> <aside class="nbd-sources">
<div class="nbd-sources-head"> <div class="nbd-sources-head">
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3> <h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3>
<label class="btn-upload" [class.disabled]="uploading"> <label class="btn-upload" [class.disabled]="uploading">
<lucide-icon [img]="Upload" [size]="13"></lucide-icon> <lucide-icon [img]="Upload" [size]="13"></lucide-icon>
{{ uploading ? 'Indexation…' : 'Ajouter un PDF' }} {{ uploading ? 'Indexation…' : 'Ajouter un PDF' }}
<input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading"> <input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading">
</label> </label>
</div>
<p class="nbd-upload-error" *ngIf="uploadError">{{ uploadError }}</p>
<div class="nbd-source" *ngFor="let s of sources">
<lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
[size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon>
<div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta">
<span *ngIf="s.status === 'READY'">{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
<span *ngIf="s.status === 'INDEXING'">indexation en cours…</span>
<span *ngIf="s.status === 'FAILED'">échec de l'indexation</span>
</div>
</div> </div>
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer"> @if (uploadError) {
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon> <p class="nbd-upload-error">{{ uploadError }}</p>
</button> }
</div> @for (s of sources; track s) {
<div class="nbd-source">
<p class="nbd-empty" *ngIf="sources.length === 0 && !uploading"> <lucide-icon
Ajoute un PDF source pour commencer à discuter avec. [img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
</p> [size]="14"
</aside> [class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon>
<!-- Chat --> <div class="nbd-source-info">
<section class="nbd-chat"> <div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-messages"> <div class="nbd-source-meta">
<p class="nbd-empty" *ngIf="messages.length === 0"> @if (s.status === 'READY') {
Pose une question sur ta source, ou demande une adaptation pour ta campagne. <span>{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
<span *ngIf="!hasReadySource()"><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span> }
</p> @if (s.status === 'INDEXING') {
<div class="nbd-msg" *ngFor="let m of messages" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'"> <span>indexation en cours…</span>
<div class="nbd-msg-role"> }
<lucide-icon *ngIf="m.role === 'assistant'" [img]="Sparkles" [size]="12"></lucide-icon> @if (s.status === 'FAILED') {
{{ m.role === 'user' ? 'Vous' : 'IA' }} <span>échec de l'indexation</span>
</div> }
<ng-container *ngIf="m.role === 'assistant'; else userContent">
<ng-container *ngIf="parsedOf(m) as p">
<div class="nbd-deep-progress" *ngIf="sending && deepProgress && !p.text">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div> </div>
<div class="nbd-msg-content">{{ p.text }}<span class="cursor" *ngIf="sending && !p.text && !p.actions.length && !deepProgress"></span></div> </div>
<app-notebook-action-card <button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
*ngFor="let a of p.actions; trackBy: trackAction" <lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
[action]="a" </button>
[campaignId]="campaignId" </div>
[arcs]="arcs" }
[chaptersByArc]="chaptersByArc" @if (sources.length === 0 && !uploading) {
(created)="onActionCreated()"> <p class="nbd-empty">
</app-notebook-action-card> Ajoute un PDF source pour commencer à discuter avec.
</ng-container> </p>
</ng-container> }
<ng-template #userContent> </aside>
<div class="nbd-msg-content">{{ m.content }}</div> <!-- Chat -->
</ng-template> <section class="nbd-chat">
<div class="nbd-messages">
@if (messages.length === 0) {
<p class="nbd-empty">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
@if (!hasReadySource()) {
<span><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
}
</p>
}
@for (m of messages; track m) {
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
@if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
@if (m.role === 'assistant') {
@if (parsedOf(m); as p) {
@if (sending && deepProgress && !p.text) {
<div class="nbd-deep-progress">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div>
}
<div class="nbd-msg-content">{{ p.text }}@if (sending && !p.text && !p.actions.length && !deepProgress) {
<span class="cursor"></span>
}</div>
@for (a of p.actions; track $index) {
<app-notebook-action-card
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
}
}
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
}
</div> </div>
</div> <div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2"
<div class="nbd-input"> placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
<textarea [(ngModel)]="draft" rows="2" (keydown.enter)="$event.preventDefault(); send()"></textarea>
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…" <button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()"
(keydown.enter)="$event.preventDefault(); send()"></textarea> title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »">
<button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()" <lucide-icon [img]="Layers" [size]="15"></lucide-icon>
title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »"> </button>
<lucide-icon [img]="Layers" [size]="15"></lucide-icon> <button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()"
</button> title="Réponse rapide (recherche ciblée dans le document)">
<button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()" <lucide-icon [img]="Send" [size]="15"></lucide-icon>
title="Réponse rapide (recherche ciblée dans le document)"> </button>
<lucide-icon [img]="Send" [size]="15"></lucide-icon> </div>
</button> </section>
</div> </div>
</section>
</div> </div>
</div> }

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers } from 'lucide-angular';
@@ -20,7 +20,7 @@ import { NotebookActionCardComponent } from '../notebook-action-card/notebook-ac
*/ */
@Component({ @Component({
selector: 'app-notebook-detail', selector: 'app-notebook-detail',
imports: [CommonModule, FormsModule, LucideAngularModule, NotebookActionCardComponent], imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent],
templateUrl: './notebook-detail.component.html', templateUrl: './notebook-detail.component.html',
styleUrls: ['./notebook-detail.component.scss'] styleUrls: ['./notebook-detail.component.scss']
}) })
@@ -108,7 +108,6 @@ export class NotebookDetailComponent implements OnInit {
} }
/** trackBy stable pour les cartes d'action (évite toute recréation parasite). */ /** trackBy stable pour les cartes d'action (évite toute recréation parasite). */
trackAction(index: number): number { return index; }
load(): void { load(): void {
this.service.get(this.notebookId).subscribe({ this.service.get(this.notebookId).subscribe({

View File

@@ -15,7 +15,7 @@
<div class="nbl-create"> <div class="nbl-create">
<input type="text" [(ngModel)]="newName" placeholder="Nom de l'atelier (ex: Adaptation du module X)" <input type="text" [(ngModel)]="newName" placeholder="Nom de l'atelier (ex: Adaptation du module X)"
(keyup.enter)="create()"> (keyup.enter)="create()">
<button class="btn-create" (click)="create()" [disabled]="creating"> <button class="btn-create" (click)="create()" [disabled]="creating">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ creating ? 'Création…' : 'Nouvel atelier' }} {{ creating ? 'Création…' : 'Nouvel atelier' }}
@@ -23,13 +23,17 @@
</div> </div>
<div class="nbl-list"> <div class="nbl-list">
<p class="empty" *ngIf="notebooks.length === 0">Aucun atelier pour l'instant.</p> @if (notebooks.length === 0) {
<button class="nbl-item" *ngFor="let nb of notebooks" (click)="open(nb)"> <p class="empty">Aucun atelier pour l'instant.</p>
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon> }
<span class="nbl-item-name">{{ nb.name }}</span> @for (nb of notebooks; track nb) {
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer"> <button class="nbl-item" (click)="open(nb)">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
</span> <span class="nbl-item-name">{{ nb.name }}</span>
</button> <span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, BookOpen } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Plus, Trash2, BookOpen } from 'lucide-angular';
@@ -14,7 +14,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-notebook-list', selector: 'app-notebook-list',
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [FormsModule, LucideAngularModule],
templateUrl: './notebook-list.component.html', templateUrl: './notebook-list.component.html',
styleUrls: ['./notebook-list.component.scss'] styleUrls: ['./notebook-list.component.scss']
}) })

View File

@@ -10,16 +10,17 @@
<lucide-icon [img]="Drama" [size]="22"></lucide-icon> <lucide-icon [img]="Drama" [size]="22"></lucide-icon>
{{ npcId ? 'Éditer le PNJ' : 'Nouveau PNJ' }} {{ npcId ? 'Éditer le PNJ' : 'Nouveau PNJ' }}
</h1> </h1>
<button @if (npcId) {
*ngIf="npcId" <button
type="button" type="button"
class="btn-ai" class="btn-ai"
(click)="toggleChat()" (click)="toggleChat()"
[class.active]="chatOpen" [class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ"> title="Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </button>
}
</div> </div>
</div> </div>
@@ -33,85 +34,89 @@
[(ngModel)]="name" [(ngModel)]="name"
name="name" name="name"
placeholder="Ex: Borin le forgeron, Dame Elara, Kael l'aubergiste..." placeholder="Ex: Borin le forgeron, Dame Elara, Kael l'aubergiste..."
/> />
</div>
<div class="field">
<label for="npc-folder">Dossier</label>
<input
id="npc-folder"
type="text"
[(ngModel)]="folder"
name="folder"
list="npc-folders"
placeholder="Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)"
/>
<datalist id="npc-folders">
<option *ngFor="let f of existingFolders" [value]="f"></option>
</datalist>
</div>
<div class="field-row image-row">
<div class="field portrait-field">
<label>Portrait</label>
<app-single-image-picker
[imageId]="portraitImageId"
aspectRatio="1 / 1"
hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
</div> </div>
<div class="field header-field">
<label>Bandeau / Header</label> <div class="field">
<app-single-image-picker <label for="npc-folder">Dossier</label>
[imageId]="headerImageId" <input
aspectRatio="3 / 1" id="npc-folder"
hint="Format paysage conseille (1200×400)." type="text"
(imageIdChange)="headerImageId = $event"> [(ngModel)]="folder"
</app-single-image-picker> name="folder"
list="npc-folders"
placeholder="Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)"
/>
<datalist id="npc-folders">
@for (f of existingFolders; track f) {
<option [value]="f"></option>
}
</datalist>
</div>
<div class="field-row image-row">
<div class="field portrait-field">
<label>Portrait</label>
<app-single-image-picker
[imageId]="portraitImageId"
aspectRatio="1 / 1"
hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
</div>
<div class="field header-field">
<label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ npcId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
@if (npcId) {
<button
type="button"
class="btn-danger"
(click)="deleteNpc()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
}
</div>
</div> </div>
</div> </div>
<div class="template-fields"> @if (npcId && campaignId) {
<app-dynamic-fields-form <app-ai-chat-drawer
[fields]="templateFields" [campaignId]="campaignId"
[values]="values" entityType="npc"
[imageValues]="imageValues" [entityId]="npcId"
[keyValueValues]="keyValueValues" [isOpen]="chatOpen"
(valuesChange)="values = $event" welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures."
(imageValuesChange)="imageValues = $event" [quickSuggestions]="chatQuickSuggestions"
(keyValueValuesChange)="keyValueValues = $event"> (close)="chatOpen = false">
</app-dynamic-fields-form> </app-ai-chat-drawer>
</div> }
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ npcId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
<button
*ngIf="npcId"
type="button"
class="btn-danger"
(click)="deleteNpc()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
</div>
<app-ai-chat-drawer
*ngIf="npcId && campaignId"
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
@@ -21,7 +21,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-npc-edit', selector: 'app-npc-edit',
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent], imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './npc-edit.component.html', templateUrl: './npc-edit.component.html',
styleUrls: ['./npc-edit.component.scss'] styleUrls: ['./npc-edit.component.scss']
}) })

View File

@@ -5,10 +5,12 @@
Retour Retour
</button> </button>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="npcId"> @if (npcId) {
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen">
Assistant IA <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
</button> Assistant IA
</button>
}
<button class="btn-edit" (click)="edit()"> <button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> <lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer Editer
@@ -18,11 +20,12 @@
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view> <app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
</div> </div>
<app-ai-chat-drawer @if (npcId && campaignId) {
*ngIf="npcId && campaignId" <app-ai-chat-drawer
[campaignId]="campaignId" [campaignId]="campaignId"
entityType="npc" entityType="npc"
[entityId]="npcId" [entityId]="npcId"
[isOpen]="chatOpen" [isOpen]="chatOpen"
(close)="chatOpen = false"> (close)="chatOpen = false">
</app-ai-chat-drawer> </app-ai-chat-drawer>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
@@ -18,7 +18,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
*/ */
@Component({ @Component({
selector: 'app-npc-view', selector: 'app-npc-view',
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent], imports: [LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './npc-view.component.html', templateUrl: './npc-view.component.html',
styleUrls: ['./npc-view.component.scss'] styleUrls: ['./npc-view.component.scss']
}) })

View File

@@ -1,72 +1,83 @@
<div class="playthrough-page" *ngIf="playthrough"> @if (playthrough) {
<div class="playthrough-page">
<header class="page-header"> <header class="page-header">
<button type="button" class="btn-secondary" (click)="back()"> <button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour Retour
</button>
<div class="header-info">
<h1>{{ playthrough.name }}</h1>
<p class="subtitle" *ngIf="playthrough.description">{{ playthrough.description }}</p>
</div>
<div class="header-actions">
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button> </button>
</div> <div class="header-info">
</header> <h1>{{ playthrough.name }}</h1>
@if (playthrough.description) {
<!-- Bloc action principal : démarrer ou reprendre la session --> <p class="subtitle">{{ playthrough.description }}</p>
<section class="play-action"> }
<button type="button" class="btn-primary big" </div>
*ngIf="!activeOnThis" <div class="header-actions">
[disabled]="startingSession" <button type="button" class="btn-danger" (click)="delete()">
(click)="startSession()"> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
<lucide-icon [img]="Play" [size]="16"></lucide-icon> Supprimer
Lancer une session </button>
</button> </div>
</header>
<button type="button" class="btn-primary big" <!-- Bloc action principal : démarrer ou reprendre la session -->
*ngIf="activeOnThis" <section class="play-action">
(click)="openSession(activeOnThis)"> @if (!activeOnThis) {
<lucide-icon [img]="Play" [size]="16"></lucide-icon> <button type="button" class="btn-primary big"
Reprendre la session en cours [disabled]="startingSession"
</button> (click)="startSession()">
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
<button type="button" class="btn-secondary" (click)="openFlags()"> Lancer une session
<lucide-icon [img]="Flag" [size]="14"></lucide-icon> </button>
Faits de la partie }
</button> @if (activeOnThis) {
</section> <button type="button" class="btn-primary big"
(click)="openSession(activeOnThis)">
<!-- PJ --> <lucide-icon [img]="Play" [size]="16"></lucide-icon>
<section class="block"> Reprendre la session en cours
<div class="block-header"> </button>
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2> }
<button type="button" class="btn-add" (click)="createCharacter()"> <button type="button" class="btn-secondary" (click)="openFlags()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> <lucide-icon [img]="Flag" [size]="14"></lucide-icon>
Nouveau PJ Faits de la partie
</button> </button>
</div> </section>
<p class="empty" *ngIf="characters.length === 0">Aucun PJ pour cette partie.</p> <!-- PJ -->
<ul class="character-list" *ngIf="characters.length > 0"> <section class="block">
<li *ngFor="let c of characters"> <div class="block-header">
<a [routerLink]="['/campaigns', campaignId, 'playthroughs', playthroughId, 'characters', c.id]">{{ c.name }}</a> <h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2>
</li> <button type="button" class="btn-add" (click)="createCharacter()">
</ul> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</section> Nouveau PJ
</button>
<!-- Sessions --> </div>
<section class="block"> @if (characters.length === 0) {
<h2>Sessions</h2> <p class="empty">Aucun PJ pour cette partie.</p>
<p class="empty" *ngIf="sessions.length === 0">Aucune session encore. Lancez la première !</p> }
<ul class="session-list" *ngIf="sessions.length > 0"> @if (characters.length > 0) {
<li *ngFor="let s of sessions" (click)="openSession(s)"> <ul class="character-list">
<span class="session-name">{{ s.name }}</span> @for (c of characters; track c) {
<span class="session-status" [class.active]="s.active">{{ s.active ? 'En cours' : 'Terminée' }}</span> <li>
</li> <a [routerLink]="['/campaigns', campaignId, 'playthroughs', playthroughId, 'characters', c.id]">{{ c.name }}</a>
</ul> </li>
</section> }
</ul>
</div> }
</section>
<!-- Sessions -->
<section class="block">
<h2>Sessions</h2>
@if (sessions.length === 0) {
<p class="empty">Aucune session encore. Lancez la première !</p>
}
@if (sessions.length > 0) {
<ul class="session-list">
@for (s of sessions; track s) {
<li (click)="openSession(s)">
<span class="session-name">{{ s.name }}</span>
<span class="session-status" [class.active]="s.active">{{ s.active ? 'En cours' : 'Terminée' }}</span>
</li>
}
</ul>
}
</section>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@@ -25,7 +25,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-playthrough-detail', selector: 'app-playthrough-detail',
imports: [CommonModule, RouterModule, LucideAngularModule], imports: [RouterModule, LucideAngularModule],
templateUrl: './playthrough-detail.component.html', templateUrl: './playthrough-detail.component.html',
styleUrls: ['./playthrough-detail.component.scss'] styleUrls: ['./playthrough-detail.component.scss']
}) })

View File

@@ -1,20 +1,21 @@
<div class="flags-page" *ngIf="playthrough"> @if (playthrough) {
<header class="page-header"> <div class="flags-page">
<button type="button" class="btn-secondary" (click)="back()"> <header class="page-header">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> <button type="button" class="btn-secondary" (click)="back()">
Retour <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
</button> Retour
<div> </button>
<h1>Faits — {{ playthrough.name }}</h1> <div>
<p class="subtitle"> <h1>Faits — {{ playthrough.name }}</h1>
Faits booléens propres à cette partie. Toggle-les en jeu pour débloquer <p class="subtitle">
les quêtes qui en dépendent. Faits booléens propres à cette partie. Toggle-les en jeu pour débloquer
</p> les quêtes qui en dépendent.
</div> </p>
</header> </div>
</header>
<app-playthrough-flags-manager <app-playthrough-flags-manager
[campaignId]="campaignId" [campaignId]="campaignId"
[playthroughId]="playthroughId"> [playthroughId]="playthroughId">
</app-playthrough-flags-manager> </app-playthrough-flags-manager>
</div> </div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
@@ -20,7 +20,7 @@ import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-fl
*/ */
@Component({ @Component({
selector: 'app-playthrough-flags-page', selector: 'app-playthrough-flags-page',
imports: [CommonModule, RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent], imports: [RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent],
templateUrl: './playthrough-flags-page.component.html', templateUrl: './playthrough-flags-page.component.html',
styleUrls: ['./playthrough-flags-page.component.scss'] styleUrls: ['./playthrough-flags-page.component.scss']
}) })

View File

@@ -13,7 +13,9 @@
<h1>{{ tableId ? 'Éditer la table' : 'Nouvelle table aléatoire' }}</h1> <h1>{{ tableId ? 'Éditer la table' : 'Nouvelle table aléatoire' }}</h1>
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div> @if (errorMessage) {
<div class="error">{{ errorMessage }}</div>
}
<div class="form-row"> <div class="form-row">
<label for="rt-name">Nom *</label> <label for="rt-name">Nom *</label>
@@ -28,7 +30,7 @@
<div class="form-row"> <div class="form-row">
<label for="rt-formula">Formule du dé *</label> <label for="rt-formula">Formule du dé *</label>
<input id="rt-formula" type="text" [(ngModel)]="diceFormula" placeholder="1d20, 2d6, d100…" <input id="rt-formula" type="text" [(ngModel)]="diceFormula" placeholder="1d20, 2d6, d100…"
[class.invalid]="diceFormula && !formulaValid"> [class.invalid]="diceFormula && !formulaValid">
<small class="hint" [class.bad]="diceFormula && !formulaValid"> <small class="hint" [class.bad]="diceFormula && !formulaValid">
{{ formulaValid ? 'Formule valide' : 'Format attendu : NdM (ex. 1d20, 2d6, d100)' }} {{ formulaValid ? 'Formule valide' : 'Format attendu : NdM (ex. 1d20, 2d6, d100)' }}
</small> </small>
@@ -41,13 +43,15 @@
</div> </div>
<p class="ai-hint">Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p> <p class="ai-hint">Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
<textarea rows="2" [(ngModel)]="aiPrompt" <textarea rows="2" [(ngModel)]="aiPrompt"
placeholder="Ex: rencontres aléatoires dans une forêt hantée, ton sombre"></textarea> placeholder="Ex: rencontres aléatoires dans une forêt hantée, ton sombre"></textarea>
<div class="ai-actions"> <div class="ai-actions">
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating"> <button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
{{ generating ? 'Génération…' : 'Générer (' + diceFormula + ')' }} {{ generating ? 'Génération…' : 'Générer (' + diceFormula + ')' }}
</button> </button>
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span> @if (aiError) {
<span class="ai-error">{{ aiError }}</span>
}
</div> </div>
</div> </div>
@@ -71,15 +75,19 @@
<span class="c-del"></span> <span class="c-del"></span>
</div> </div>
<div class="entry-row" *ngFor="let e of entries; let i = index"> @for (e of entries; track e; let i = $index) {
<input class="c-range" type="number" [(ngModel)]="e.minRoll"> <div class="entry-row">
<input class="c-range" type="number" [(ngModel)]="e.maxRoll"> <input class="c-range" type="number" [(ngModel)]="e.minRoll">
<input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat"> <input class="c-range" type="number" [(ngModel)]="e.maxRoll">
<input class="c-detail" type="text" [(ngModel)]="e.detail" placeholder="Détail (optionnel)"> <input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat">
<button class="c-del btn-del" (click)="removeEntry(i)" title="Supprimer"> <input class="c-detail" type="text" [(ngModel)]="e.detail" placeholder="Détail (optionnel)">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <button class="c-del btn-del" (click)="removeEntry(i)" title="Supprimer">
</button> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</div> </button>
</div>
}
<p class="empty-hint" *ngIf="entries.length === 0">Aucune entrée — clique « Ajouter ».</p> @if (entries.length === 0) {
<p class="empty-hint">Aucune entrée — clique « Ajouter ».</p>
}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Wand2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Wand2, Sparkles } from 'lucide-angular';
@@ -15,7 +15,7 @@ import { DiceUtils } from '../../../shared/dice.utils';
*/ */
@Component({ @Component({
selector: 'app-random-table-edit', selector: 'app-random-table-edit',
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [FormsModule, LucideAngularModule],
templateUrl: './random-table-edit.component.html', templateUrl: './random-table-edit.component.html',
styleUrls: ['./random-table-edit.component.scss'] styleUrls: ['./random-table-edit.component.scss']
}) })

View File

@@ -1,57 +1,75 @@
<div class="rt-page" *ngIf="table"> @if (table) {
<div class="rt-toolbar"> <div class="rt-page">
<button class="btn-back" (click)="back()"> <div class="rt-toolbar">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> <button class="btn-back" (click)="back()">
Retour <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
</button> Retour
<span class="spacer"></span> </button>
<button class="btn-edit" (click)="edit()"> <span class="spacer"></span>
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> <button class="btn-edit" (click)="edit()">
Éditer <lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
</button> Éditer
</button>
</div>
<header class="rt-header">
<h1><lucide-icon [img]="Dices" [size]="22"></lucide-icon> {{ table.name }}</h1>
@if (table.description) {
<p class="rt-desc">{{ table.description }}</p>
}
<span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span>
</header>
<!-- Zone de jet -->
<section class="rt-roll">
<button class="btn-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Lancer {{ table.diceFormula }}
</button>
@if (lastRoll) {
<div class="rt-result">
<span class="rt-total">{{ lastRoll.total }}</span>
@if (lastRoll.rolls.length > 1) {
<span class="rt-rolls">({{ lastRoll.rolls.join(' + ') }})</span>
}
<span class="rt-arrow"></span>
@if (matched) {
<span class="rt-matched">{{ matched.label }}</span>
}
@if (!matched) {
<span class="rt-nomatch">Aucune entrée pour ce résultat</span>
}
</div>
}
</section>
@if (matched?.detail) {
<div class="rt-detail">{{ matched?.detail }}</div>
}
<!-- Liste des entrées -->
<section class="rt-entries">
@if (table.entries.length === 0) {
<div class="rt-empty">
Aucune entrée — édite la table pour en ajouter.
</div>
}
@if (table.entries.length > 0) {
<table>
<thead>
<tr><th class="col-range">Jet</th><th>Résultat</th></tr>
</thead>
<tbody>
@for (e of table.entries; track e) {
<tr [class.matched]="isMatched(e)">
<td class="col-range">{{ rangeLabel(e) }}</td>
<td>
<div class="entry-label">{{ e.label }}</div>
@if (e.detail) {
<div class="entry-detail">{{ e.detail }}</div>
}
</td>
</tr>
}
</tbody>
</table>
}
</section>
</div> </div>
}
<header class="rt-header">
<h1><lucide-icon [img]="Dices" [size]="22"></lucide-icon> {{ table.name }}</h1>
<p class="rt-desc" *ngIf="table.description">{{ table.description }}</p>
<span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span>
</header>
<!-- Zone de jet -->
<section class="rt-roll">
<button class="btn-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Lancer {{ table.diceFormula }}
</button>
<div class="rt-result" *ngIf="lastRoll">
<span class="rt-total">{{ lastRoll.total }}</span>
<span class="rt-rolls" *ngIf="lastRoll.rolls.length > 1">({{ lastRoll.rolls.join(' + ') }})</span>
<span class="rt-arrow"></span>
<span class="rt-matched" *ngIf="matched">{{ matched.label }}</span>
<span class="rt-nomatch" *ngIf="!matched">Aucune entrée pour ce résultat</span>
</div>
</section>
<div class="rt-detail" *ngIf="matched?.detail">{{ matched?.detail }}</div>
<!-- Liste des entrées -->
<section class="rt-entries">
<div class="rt-empty" *ngIf="table.entries.length === 0">
Aucune entrée — édite la table pour en ajouter.
</div>
<table *ngIf="table.entries.length > 0">
<thead>
<tr><th class="col-range">Jet</th><th>Résultat</th></tr>
</thead>
<tbody>
<tr *ngFor="let e of table.entries" [class.matched]="isMatched(e)">
<td class="col-range">{{ rangeLabel(e) }}</td>
<td>
<div class="entry-label">{{ e.label }}</div>
<div class="entry-detail" *ngIf="e.detail">{{ e.detail }}</div>
</td>
</tr>
</tbody>
</table>
</section>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
@@ -14,7 +14,7 @@ import { DiceUtils, DiceRoll } from '../../../shared/dice.utils';
*/ */
@Component({ @Component({
selector: 'app-random-table-view', selector: 'app-random-table-view',
imports: [CommonModule, LucideAngularModule], imports: [LucideAngularModule],
templateUrl: './random-table-view.component.html', templateUrl: './random-table-view.component.html',
styleUrls: ['./random-table-view.component.scss'] styleUrls: ['./random-table-view.component.scss']
}) })

View File

@@ -2,7 +2,9 @@
<div class="page-header"> <div class="page-header">
<h1>Créer une nouvelle scène</h1> <h1>Créer une nouvelle scène</h1>
<p class="chapter-ref" *ngIf="chapterName">Chapitre : {{ chapterName }}</p> @if (chapterName) {
<p class="chapter-ref">Chapitre : {{ chapterName }}</p>
}
</div> </div>
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form"> <form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
@@ -15,7 +17,7 @@
formControlName="name" formControlName="name"
placeholder="Ex: Arrivée au village" placeholder="Ex: Arrivée au village"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched" [class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/> />
</div> </div>
<div class="field"> <div class="field">

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -19,7 +19,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
*/ */
@Component({ @Component({
selector: 'app-scene-create', selector: 'app-scene-create',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent], imports: [ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './scene-create.component.html', templateUrl: './scene-create.component.html',
styleUrls: ['./scene-create.component.scss'] styleUrls: ['./scene-create.component.scss']
}) })

View File

@@ -7,9 +7,9 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-ai" <button type="button" class="btn-ai"
(click)="toggleChat()" (click)="toggleChat()"
[class.active]="chatOpen" [class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de cette scène"> title="Ouvrir l'Assistant IA pour dialoguer autour de cette scène">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </button>
@@ -58,7 +58,7 @@
formControlName="name" formControlName="name"
placeholder="Ex: Arrivée au village" placeholder="Ex: Arrivée au village"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched" [class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/> />
</div> </div>
<div class="field"> <div class="field">
@@ -136,60 +136,63 @@
<!-- Section : Branches narratives (graphe intra-chapitre) --> <!-- Section : Branches narratives (graphe intra-chapitre) -->
<app-expandable-section title="Branches narratives" icon="🌿"> <app-expandable-section title="Branches narratives" icon="🌿">
<div class="branches-hint" *ngIf="siblingScenes.length === 0"> @if (siblingScenes.length === 0) {
<small class="field-hint"> <div class="branches-hint">
💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches. <small class="field-hint">
Créez d'abord d'autres scènes, puis revenez ici pour les connecter. 💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches.
</small> Créez d'abord d'autres scènes, puis revenez ici pour les connecter.
</div> </small>
<div class="branches-list" *ngIf="siblingScenes.length > 0">
<div class="branch-item" *ngFor="let branch of branches; let i = index; trackBy: trackByIndex">
<div class="field">
<label>Libellé du choix</label>
<input
type="text"
[value]="branch.label"
(input)="updateBranchLabel(i, $any($event.target).value)"
placeholder="Ex: Si les joueurs attaquent le garde" />
</div>
<div class="field">
<label>Scène de destination *</label>
<select
(change)="updateBranchTarget(i, $any($event.target).value)">
<option value="" [selected]="!branch.targetSceneId">— Choisir une scène —</option>
<option *ngFor="let s of siblingScenes"
[value]="s.id"
[selected]="s.id === branch.targetSceneId">{{ s.name }}</option>
</select>
</div>
<div class="field">
<label>Condition MJ (optionnel)</label>
<input
type="text"
[value]="branch.condition || ''"
(input)="updateBranchCondition(i, $any($event.target).value)"
placeholder="Ex: Jet de Persuasion DD 15 réussi" />
</div>
<button type="button" class="btn-remove-branch" (click)="removeBranch(i)"
title="Supprimer cette branche">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Retirer
</button>
</div> </div>
}
<button type="button" class="btn-add-branch" (click)="addBranch()"> @if (siblingScenes.length > 0) {
+ Ajouter une branche <div class="branches-list">
</button> @for (branch of branches; track $index; let i = $index) {
<div class="branch-item">
<small class="field-hint"> <div class="field">
Chaque branche représente une "sortie" possible depuis cette scène selon l'action des joueurs. <label>Libellé du choix</label>
Les cibles sont limitées aux scènes du même chapitre. <input
</small> type="text"
</div> [value]="branch.label"
(input)="updateBranchLabel(i, $any($event.target).value)"
placeholder="Ex: Si les joueurs attaquent le garde" />
</div>
<div class="field">
<label>Scène de destination *</label>
<select
(change)="updateBranchTarget(i, $any($event.target).value)">
<option value="" [selected]="!branch.targetSceneId">— Choisir une scène —</option>
@for (s of siblingScenes; track s) {
<option
[value]="s.id"
[selected]="s.id === branch.targetSceneId">{{ s.name }}</option>
}
</select>
</div>
<div class="field">
<label>Condition MJ (optionnel)</label>
<input
type="text"
[value]="branch.condition || ''"
(input)="updateBranchCondition(i, $any($event.target).value)"
placeholder="Ex: Jet de Persuasion DD 15 réussi" />
</div>
<button type="button" class="btn-remove-branch" (click)="removeBranch(i)"
title="Supprimer cette branche">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Retirer
</button>
</div>
}
<button type="button" class="btn-add-branch" (click)="addBranch()">
+ Ajouter une branche
</button>
<small class="field-hint">
Chaque branche représente une "sortie" possible depuis cette scène selon l'action des joueurs.
Les cibles sont limitées aux scènes du même chapitre.
</small>
</div>
}
</app-expandable-section> </app-expandable-section>
<!-- Section : Combat ou rencontre --> <!-- Section : Combat ou rencontre -->
@@ -210,26 +213,30 @@
</app-expandable-section> </app-expandable-section>
<!-- Section : Pages Lore associées (B2 cross-context) --> <!-- Section : Pages Lore associées (B2 cross-context) -->
<app-expandable-section title="Pages Lore associées" icon="🔗" *ngIf="loreId"> @if (loreId) {
<app-expandable-section title="Pages Lore associées" icon="🔗">
<div class="field">
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page.
</small>
</div>
</app-expandable-section>
}
@if (!loreId) {
<div class="field"> <div class="field">
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint"> <small class="field-hint">
Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page. 💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir épingler des pages du Lore à cette scène.
</small> </small>
</div> </div>
</app-expandable-section> }
<div class="field" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir épingler des pages du Lore à cette scène.
</small>
</div>
<!-- Lieu explorable : pièces / donjon --> <!-- Lieu explorable : pièces / donjon -->
<app-expandable-section title="Lieu explorable (donjon, crypte…)" icon="🏰" [initiallyOpen]="rooms.length > 0"> <app-expandable-section title="Lieu explorable (donjon, crypte…)" icon="🏰" [initiallyOpen]="rooms.length > 0">

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -30,7 +30,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-scene-edit', selector: 'app-scene-edit',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent], imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent],
templateUrl: './scene-edit.component.html', templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss'] styleUrls: ['./scene-edit.component.scss']
}) })
@@ -218,7 +218,6 @@ export class SceneEditComponent implements OnInit, OnDestroy {
// ─────────────── Gestion des branches narratives ─────────────── // ─────────────── Gestion des branches narratives ───────────────
trackByIndex = (i: number) => i;
addBranch(): void { addBranch(): void {
this.branches.push({ label: '', targetSceneId: '', condition: '' }); this.branches.push({ label: '', targetSceneId: '', condition: '' });

View File

@@ -1,147 +1,188 @@
<div class="view-page" *ngIf="scene"> @if (scene) {
<div class="view-page">
<header class="view-header"> <header class="view-header">
<div> <div>
<h1> <h1>
<lucide-icon *ngIf="scene.icon" [img]="resolveCampaignIcon(scene.icon)" [size]="22" class="title-icon"></lucide-icon> @if (scene.icon) {
{{ scene.name }} <lucide-icon [img]="resolveCampaignIcon(scene.icon)" [size]="22" class="title-icon"></lucide-icon>
</h1> }
<p class="view-subtitle">Scène</p> {{ scene.name }}
</div> </h1>
<div class="view-actions"> <p class="view-subtitle">Scène</p>
<button type="button" class="btn-primary" (click)="editMode()"> </div>
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> <div class="view-actions">
Modifier <button type="button" class="btn-primary" (click)="editMode()">
</button> <lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
<button type="button" class="btn-danger" (click)="deleteScene()" title="Supprimer la scène"> Modifier
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> </button>
Supprimer <button type="button" class="btn-danger" (click)="deleteScene()" title="Supprimer la scène">
</button> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</div> Supprimer
</header> </button>
</div>
<!-- Illustrations (rendu editorial magazine) --> </header>
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0"> <!-- Illustrations (rendu editorial magazine) -->
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery> @if ((scene.illustrationImageIds?.length ?? 0) > 0) {
</section> <section class="view-section">
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
<!-- Cartes & plans --> </section>
<section class="view-section" *ngIf="(scene.mapImageIds?.length ?? 0) > 0"> }
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2> <!-- Cartes & plans -->
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery> @if ((scene.mapImageIds?.length ?? 0) > 0) {
</section> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<!-- Description courte --> <app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
<section class="view-section"> </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> <!-- Description courte -->
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template> <section class="view-section">
</section> <h2 class="view-section-title"><span class="view-section-icon">📝</span> Description</h2>
@if (scene.description?.trim()) {
<!-- Contexte et ambiance --> <p class="view-section-body">{{ scene.description }}</p>
<div class="view-row" *ngIf="scene.location?.trim() || scene.timing?.trim()"> } @else {
<section class="view-section" *ngIf="scene.location?.trim()"> <p class="view-section-empty">Non renseigné</p>
<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> </section>
<!-- Contexte et ambiance -->
@if (scene.location?.trim() || scene.timing?.trim()) {
<div class="view-row">
@if (scene.location?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📍</span> Lieu</h2>
<p class="view-section-body">{{ scene.location }}</p>
</section>
}
@if (scene.timing?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Moment</h2>
<p class="view-section-body">{{ scene.timing }}</p>
</section>
}
</div>
}
@if (scene.atmosphere?.trim()) {
<section class="view-section">
<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 -->
@if (scene.playerNarration?.trim()) {
<section class="view-section">
<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 -->
@if (scene.choicesConsequences?.trim()) {
<section class="view-section">
<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 -->
@if (scene.combatDifficulty?.trim() || scene.enemies?.trim()) {
@if (scene.combatDifficulty?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2>
<p class="view-section-body">{{ scene.combatDifficulty }}</p>
</section>
}
@if (scene.enemies?.trim()) {
<section class="view-section">
<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>
}
}
<!-- Notes et secrets du MJ (privé) -->
@if (scene.gmSecretNotes?.trim()) {
<section class="view-section view-section--private">
<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 -->
@if (loreId && (scene.relatedPageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
@for (relId of scene.relatedPageIds; track relId) {
<a class="view-chip"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
}
</div>
</section>
}
<!-- Lieu explorable : pièces (mode donjon) -->
@if ((scene.rooms?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🏰</span> Pièces du lieu</h2>
<div class="rooms-readonly">
@for (r of scene.rooms; track r; let i = $index) {
<article class="room-readonly">
<header class="room-readonly-head">
<span class="room-readonly-index">#{{ i + 1 }}</span>
<span class="room-readonly-name">{{ r.name }}</span>
@if (r.floor !== null && r.floor !== undefined) {
<span class="room-readonly-floor">
Étage {{ r.floor }}
</span>
}
</header>
@if (r.description?.trim()) {
<p class="room-readonly-desc">{{ r.description }}</p>
}
<div class="room-readonly-grid">
@if (r.enemies?.trim()) {
<div>
<strong>⚔️ Ennemis</strong>
<p>{{ r.enemies }}</p>
</div>
}
@if (r.loot?.trim()) {
<div>
<strong>💰 Loot</strong>
<p>{{ r.loot }}</p>
</div>
}
@if (r.traps?.trim()) {
<div>
<strong>⚠️ Pièges</strong>
<p>{{ r.traps }}</p>
</div>
}
@if (r.gmNotes?.trim()) {
<div class="room-readonly-private">
<strong>🔒 Notes MJ</strong>
<p>{{ r.gmNotes }}</p>
</div>
}
</div>
@if ((r.branches?.length ?? 0) > 0) {
<div class="room-readonly-branches">
<strong>→ Sorties</strong>
<ul>
@for (b of r.branches; track b) {
<li>
<em>{{ b.label }}</em> → {{ roomNameById(scene, b.targetRoomId) }}
@if (b.condition?.trim()) {
<span class="branch-cond">(si : {{ b.condition }})</span>
}
</li>
}
</ul>
</div>
}
</article>
}
</div>
</section>
}
</div> </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>
<!-- Lieu explorable : pièces (mode donjon) -->
<section class="view-section" *ngIf="(scene.rooms?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🏰</span> Pièces du lieu</h2>
<div class="rooms-readonly">
<article class="room-readonly" *ngFor="let r of scene.rooms; let i = index">
<header class="room-readonly-head">
<span class="room-readonly-index">#{{ i + 1 }}</span>
<span class="room-readonly-name">{{ r.name }}</span>
<span class="room-readonly-floor" *ngIf="r.floor !== null && r.floor !== undefined">
Étage {{ r.floor }}
</span>
</header>
<p class="room-readonly-desc" *ngIf="r.description?.trim()">{{ r.description }}</p>
<div class="room-readonly-grid">
<div *ngIf="r.enemies?.trim()">
<strong>⚔️ Ennemis</strong>
<p>{{ r.enemies }}</p>
</div>
<div *ngIf="r.loot?.trim()">
<strong>💰 Loot</strong>
<p>{{ r.loot }}</p>
</div>
<div *ngIf="r.traps?.trim()">
<strong>⚠️ Pièges</strong>
<p>{{ r.traps }}</p>
</div>
<div *ngIf="r.gmNotes?.trim()" class="room-readonly-private">
<strong>🔒 Notes MJ</strong>
<p>{{ r.gmNotes }}</p>
</div>
</div>
<div class="room-readonly-branches" *ngIf="(r.branches?.length ?? 0) > 0">
<strong>→ Sorties</strong>
<ul>
<li *ngFor="let b of r.branches">
<em>{{ b.label }}</em> → {{ roomNameById(scene, b.targetRoomId) }}
<span class="branch-cond" *ngIf="b.condition?.trim()">(si : {{ b.condition }})</span>
</li>
</ul>
</div>
</article>
</div>
</section>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
@@ -24,7 +24,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-scene-view', selector: 'app-scene-view',
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent], imports: [RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './scene-view.component.html', templateUrl: './scene-view.component.html',
styleUrls: ['./scene-view.component.scss'] styleUrls: ['./scene-view.component.scss']
}) })

View File

@@ -37,120 +37,135 @@
</p> </p>
<!-- Import d'un PDF de règles : l'IA propose un découpage en sections, <!-- Import d'un PDF de règles : l'IA propose un découpage en sections,
que l'utilisateur révise ci-dessous avant d'enregistrer. --> que l'utilisateur révise ci-dessous avant d'enregistrer. -->
<div class="import-row"> <div class="import-row">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" /> <input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-import" [disabled]="importing" (click)="pdfInput.click()"> <button type="button" class="btn-import" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="14"></lucide-icon> <lucide-icon [img]="Upload" [size]="14"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Importer un PDF de règles' }} {{ importing ? 'Import en cours…' : 'Importer un PDF de règles' }}
</button> </button>
<span class="import-hint" *ngIf="!importing">L'IA propose un découpage en sections — vous révisez avant d'enregistrer.</span> @if (!importing) {
<span class="import-hint">L'IA propose un découpage en sections — vous révisez avant d'enregistrer.</span>
}
</div> </div>
<!-- Progression live de l'import (étape + barre + sections trouvées). --> <!-- Progression live de l'import (étape + barre + sections trouvées). -->
<div class="import-progress" *ngIf="importing"> @if (importing) {
<p class="import-phase">{{ importPhase }}</p> <div class="import-progress">
<div class="progress-bar" *ngIf="importProgress"> <p class="import-phase">{{ importPhase }}</p>
<div class="progress-fill" @if (importProgress) {
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0"> <div class="progress-bar">
</div> <div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div>
</div>
}
@if (importFound.length) {
<p class="import-found">
Sections trouvées : {{ importFound.join(' · ') }}
</p>
}
</div> </div>
<p class="import-found" *ngIf="importFound.length"> }
Sections trouvées : {{ importFound.join(' · ') }}
</p>
</div>
<p class="import-note" *ngIf="importNote">{{ importNote }}</p> @if (importNote) {
<p class="import-error" *ngIf="importError">{{ importError }}</p> <p class="import-note">{{ importNote }}</p>
}
@if (importError) {
<p class="import-error">{{ importError }}</p>
}
<div class="section-list"> <div class="section-list">
<div class="section-card" *ngFor="let section of sections; let i = index" [class.collapsed]="section.collapsed"> @for (section of sections; track section; let i = $index) {
<div class="section-card" [class.collapsed]="section.collapsed">
<div class="section-head"> <div class="section-head">
<button type="button" class="btn-collapse" (click)="toggleCollapse(section)"> <button type="button" class="btn-collapse" (click)="toggleCollapse(section)">
<lucide-icon [img]="section.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon> <lucide-icon [img]="section.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
</button> </button>
<input <input
type="text" type="text"
class="section-title-input" class="section-title-input"
[(ngModel)]="section.title" [(ngModel)]="section.title"
[name]="'title-' + i" [name]="'title-' + i"
placeholder="Nom de la section (ex: Combat)" placeholder="Nom de la section (ex: Combat)"
/> />
<button type="button" class="btn-remove" (click)="removeSection(i)" title="Supprimer cette section"> <button type="button" class="btn-remove" (click)="removeSection(i)" title="Supprimer cette section">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
</div> </div>
@if (!section.collapsed) {
<textarea <textarea
*ngIf="!section.collapsed" class="section-content"
class="section-content" [(ngModel)]="section.content"
[(ngModel)]="section.content" [name]="'content-' + i"
[name]="'content-' + i" rows="6"
rows="6" placeholder="Décrivez les règles de cette section..."
placeholder="Décrivez les règles de cette section..." ></textarea>
></textarea> }
</div>
}
@if (sections.length === 0) {
<div class="empty-hint">
Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous.
</div>
}
</div> </div>
<div *ngIf="sections.length === 0" class="empty-hint"> <div class="add-row">
Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous. <span class="add-label">Ajouter une section :</span>
@for (name of suggestedSections; track name) {
<button
type="button"
class="chip"
[class.disabled]="isSectionUsed(name)"
(click)="addSuggested(name)"
[disabled]="isSectionUsed(name)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ name }}
</button>
}
<button type="button" class="chip chip-custom" (click)="addBlank()">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
Autre…
</button>
</div> </div>
</div> </div>
<div class="add-row"> <!-- Templates de fiches PJ/PNJ -->
<span class="add-label">Ajouter une section :</span> <div class="templates-area">
<button <h2 class="sections-title">Fiches de personnages</h2>
type="button" <p class="sections-hint">
*ngFor="let name of suggestedSections" Definissez la structure des fiches PJ et PNJ pour ce systeme. Les champs
class="chip" universels (nom, portrait, header) sont automatiques — ne rajoutez ici
[class.disabled]="isSectionUsed(name)" que les champs specifiques au systeme (Histoire, PV, Stats…).
(click)="addSuggested(name)" </p>
[disabled]="isSectionUsed(name)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> <app-template-fields-editor
{{ name }} label="Champs de la fiche PJ"
</button> hint="Affiches lors de la creation/edition d'un personnage joueur."
<button type="button" class="chip chip-custom" (click)="addBlank()"> [fields]="characterTemplate"
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> [suggestions]="characterFieldSuggestions"
Autre… (fieldsChange)="characterTemplate = $event">
</button> </app-template-fields-editor>
<app-template-fields-editor
label="Champs de la fiche PNJ"
hint="Affiches lors de la creation/edition d'un personnage non-joueur."
[fields]="npcTemplate"
[suggestions]="npcFieldSuggestions"
(fieldsChange)="npcTemplate = $event">
</app-template-fields-editor>
</div> </div>
</div>
<!-- Templates de fiches PJ/PNJ --> <div class="actions">
<div class="templates-area"> <button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<h2 class="sections-title">Fiches de personnages</h2> <lucide-icon [img]="Save" [size]="16"></lucide-icon>
<p class="sections-hint"> {{ id ? 'Enregistrer' : 'Créer' }}
Definissez la structure des fiches PJ et PNJ pour ce systeme. Les champs </button>
universels (nom, portrait, header) sont automatiques — ne rajoutez ici <button type="button" class="btn-secondary" (click)="back()">Annuler</button>
que les champs specifiques au systeme (Histoire, PV, Stats…). </div>
</p>
<app-template-fields-editor
label="Champs de la fiche PJ"
hint="Affiches lors de la creation/edition d'un personnage joueur."
[fields]="characterTemplate"
[suggestions]="characterFieldSuggestions"
(fieldsChange)="characterTemplate = $event">
</app-template-fields-editor>
<app-template-fields-editor
label="Champs de la fiche PNJ"
hint="Affiches lors de la creation/edition d'un personnage non-joueur."
[fields]="npcTemplate"
[suggestions]="npcFieldSuggestions"
(fieldsChange)="npcTemplate = $event">
</app-template-fields-editor>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ id ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
</div> </div>
</div> </div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight, Upload } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight, Upload } from 'lucide-angular';
@@ -41,7 +41,7 @@ const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ']
@Component({ @Component({
selector: 'app-game-system-edit', selector: 'app-game-system-edit',
imports: [CommonModule, FormsModule, LucideAngularModule, TemplateFieldsEditorComponent], imports: [FormsModule, LucideAngularModule, TemplateFieldsEditorComponent],
templateUrl: './game-system-edit.component.html', templateUrl: './game-system-edit.component.html',
styleUrls: ['./game-system-edit.component.scss'] styleUrls: ['./game-system-edit.component.scss']
}) })

View File

@@ -8,21 +8,27 @@
<div class="gs-grid"> <div class="gs-grid">
<div class="gs-card" *ngFor="let gs of gameSystems" (click)="edit(gs.id!)"> @for (gs of gameSystems; track gs) {
<div class="card-header"> <div class="gs-card" (click)="edit(gs.id!)">
<h2>{{ gs.name }}</h2> <div class="card-header">
<div class="card-actions"> <h2>{{ gs.name }}</h2>
<button class="icon-btn" (click)="delete(gs, $event)" title="Supprimer"> <div class="card-actions">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <button class="icon-btn" (click)="delete(gs, $event)" title="Supprimer">
</button> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
</div>
<p class="card-description">{{ gs.description || '(Pas de description)' }}</p>
<div class="card-footer">
@if (gs.author) {
<span class="author">par {{ gs.author }}</span>
}
@if (gs.isPublic) {
<span class="badge-public">public</span>
}
</div> </div>
</div> </div>
<p class="card-description">{{ gs.description || '(Pas de description)' }}</p> }
<div class="card-footer">
<span *ngIf="gs.author" class="author">par {{ gs.author }}</span>
<span *ngIf="gs.isPublic" class="badge-public">public</span>
</div>
</div>
<div class="gs-card card-new" (click)="create()"> <div class="gs-card card-new" (click)="create()">
<div class="new-icon"> <div class="new-icon">

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
import { GameSystemService } from '../services/game-system.service'; import { GameSystemService } from '../services/game-system.service';
@@ -9,7 +9,7 @@ import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.se
@Component({ @Component({
selector: 'app-game-systems', selector: 'app-game-systems',
imports: [CommonModule, LucideAngularModule], imports: [LucideAngularModule],
templateUrl: './game-systems.component.html', templateUrl: './game-systems.component.html',
styleUrls: ['./game-systems.component.scss'] styleUrls: ['./game-systems.component.scss']
}) })

View File

@@ -1,85 +1,92 @@
<div class="folder-view" *ngIf="node"> @if (node) {
<div class="folder-view">
<!-- Fil d'Ariane : Lore → ancêtres → dossier courant --> <!-- Fil d'Ariane : Lore → ancêtres → dossier courant -->
<nav class="breadcrumb" aria-label="Fil d'Ariane"> <nav class="breadcrumb" aria-label="Fil d'Ariane">
<button type="button" class="crumb" (click)="navigateToLoreRoot()" *ngIf="lore"> @if (lore) {
{{ lore.name }} <button type="button" class="crumb" (click)="navigateToLoreRoot()">
</button> {{ lore.name }}
<ng-container *ngFor="let ancestor of ancestors"> </button>
}
@for (ancestor of ancestors; track ancestor) {
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon>
<button type="button" class="crumb" (click)="navigateToSubfolder(ancestor.id!)">
{{ ancestor.name }}
</button>
}
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon> <lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon>
<button type="button" class="crumb" (click)="navigateToSubfolder(ancestor.id!)"> <span class="crumb current">{{ node.name }}</span>
{{ ancestor.name }} </nav>
</button> <!-- Header : icône + nom + actions -->
</ng-container> <div class="detail-header">
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon> <div class="header-texts">
<span class="crumb current">{{ node.name }}</span> <h1>
</nav> <lucide-icon [img]="folderIcon" [size]="24" class="title-icon"></lucide-icon>
{{ node.name }}
<!-- Header : icône + nom + actions --> </h1>
<div class="detail-header"> <p class="description">
<div class="header-texts"> {{ subfolders.length }} sous-dossier(s) · {{ pages.length }} page(s)
<h1> </p>
<lucide-icon [img]="folderIcon" [size]="24" class="title-icon"></lucide-icon> </div>
{{ node.name }} <div class="header-actions">
</h1> <button type="button" class="btn-secondary" (click)="navigateToEdit()" title="Modifier le dossier">
<p class="description"> <lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
{{ subfolders.length }} sous-dossier(s) · {{ pages.length }} page(s) Modifier
</p> </button>
</div> <button type="button" class="btn-danger" (click)="delete()" title="Supprimer le dossier et tout son contenu">
<div class="header-actions"> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
<button type="button" class="btn-secondary" (click)="navigateToEdit()" title="Modifier le dossier"> Supprimer
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> </button>
Modifier </div>
</button>
<button type="button" class="btn-danger" (click)="delete()" title="Supprimer le dossier et tout son contenu">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div> </div>
<!-- Sous-dossiers -->
<section class="detail-section">
<div class="section-header">
<h2>Sous-dossiers</h2>
<button class="btn-add" (click)="navigateToCreateSubfolder()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau sous-dossier
</button>
</div>
@if (subfolders.length > 0) {
<div class="items-grid">
@for (sub of subfolders; track sub) {
<div class="node-card" (click)="navigateToSubfolder(sub.id!)">
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ sub.name }}</span>
</div>
}
</div>
}
@if (subfolders.length === 0) {
<div class="empty-state">
<p>Aucun sous-dossier.</p>
</div>
}
</section>
<!-- Pages -->
<section class="detail-section">
<div class="section-header">
<h2>Pages</h2>
<button class="btn-add" (click)="navigateToCreatePage()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle page
</button>
</div>
@if (pages.length > 0) {
<div class="items-grid">
@for (page of pages; track page) {
<div class="node-card" (click)="navigateToPage(page.id!)">
<lucide-icon [img]="FileText" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ page.title }}</span>
</div>
}
</div>
}
@if (pages.length === 0) {
<div class="empty-state">
<p>Aucune page dans ce dossier.</p>
</div>
}
</section>
</div> </div>
}
<!-- Sous-dossiers -->
<section class="detail-section">
<div class="section-header">
<h2>Sous-dossiers</h2>
<button class="btn-add" (click)="navigateToCreateSubfolder()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau sous-dossier
</button>
</div>
<div class="items-grid" *ngIf="subfolders.length > 0">
<div class="node-card" *ngFor="let sub of subfolders" (click)="navigateToSubfolder(sub.id!)">
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ sub.name }}</span>
</div>
</div>
<div class="empty-state" *ngIf="subfolders.length === 0">
<p>Aucun sous-dossier.</p>
</div>
</section>
<!-- Pages -->
<section class="detail-section">
<div class="section-header">
<h2>Pages</h2>
<button class="btn-add" (click)="navigateToCreatePage()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle page
</button>
</div>
<div class="items-grid" *ngIf="pages.length > 0">
<div class="node-card" *ngFor="let page of pages" (click)="navigateToPage(page.id!)">
<lucide-icon [img]="FileText" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ page.title }}</span>
</div>
</div>
<div class="empty-state" *ngIf="pages.length === 0">
<p>Aucune page dans ce dossier.</p>
</div>
</section>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight } from 'lucide-angular'; import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight } from 'lucide-angular';
@@ -24,7 +24,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
*/ */
@Component({ @Component({
selector: 'app-folder-view', selector: 'app-folder-view',
imports: [CommonModule, LucideAngularModule], imports: [LucideAngularModule],
templateUrl: './folder-view.component.html', templateUrl: './folder-view.component.html',
styleUrls: ['./folder-view.component.scss'] styleUrls: ['./folder-view.component.scss']
}) })

View File

@@ -1,11 +1,11 @@
import { Component, EventEmitter, Output } from '@angular/core'; import { Component, EventEmitter, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { LucideAngularModule, BookCopy, X } from 'lucide-angular'; import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
@Component({ @Component({
selector: 'app-lore-create', selector: 'app-lore-create',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule], imports: [ReactiveFormsModule, LucideAngularModule],
templateUrl: './lore-create.component.html', templateUrl: './lore-create.component.html',
styleUrls: ['./lore-create.component.scss'] styleUrls: ['./lore-create.component.scss']
}) })

View File

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

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular';
@@ -14,7 +14,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
@Component({ @Component({
selector: 'app-lore-detail', selector: 'app-lore-detail',
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [FormsModule, LucideAngularModule],
templateUrl: './lore-detail.component.html', templateUrl: './lore-detail.component.html',
styleUrls: ['./lore-detail.component.scss'] styleUrls: ['./lore-detail.component.scss']
}) })

View File

@@ -14,14 +14,16 @@
formControlName="name" formControlName="name"
placeholder="Ex: Personnages, Créatures..." placeholder="Ex: Personnages, Créatures..."
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched" [class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/> />
</div> </div>
<div class="field"> <div class="field">
<label>Dossier parent <span class="optional">(optionnel)</span></label> <label>Dossier parent <span class="optional">(optionnel)</span></label>
<select formControlName="parentId"> <select formControlName="parentId">
<option value="">— Racine du Lore —</option> <option value="">— Racine du Lore —</option>
<option *ngFor="let parent of availableParents" [value]="parent.id">{{ parent.name }}</option> @for (parent of availableParents; track parent) {
<option [value]="parent.id">{{ parent.name }}</option>
}
</select> </select>
<p class="hint">Laissez vide pour créer un dossier à la racine du lore</p> <p class="hint">Laissez vide pour créer un dossier à la racine du lore</p>
</div> </div>
@@ -29,14 +31,15 @@
<div class="field"> <div class="field">
<label>Icône</label> <label>Icône</label>
<div class="icon-grid"> <div class="icon-grid">
<button @for (option of iconOptions; track option) {
type="button" <button
class="icon-btn" type="button"
*ngFor="let option of iconOptions" class="icon-btn"
[class.selected]="selectedIcon === option.key" [class.selected]="selectedIcon === option.key"
(click)="selectIcon(option.key)"> (click)="selectIcon(option.key)">
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon> <lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
</button> </button>
}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, LucideIconData } from 'lucide-angular'; import { LucideAngularModule, LucideIconData } from 'lucide-angular';
@@ -14,7 +14,7 @@ import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
@Component({ @Component({
selector: 'app-lore-node-create', selector: 'app-lore-node-create',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule], imports: [ReactiveFormsModule, LucideAngularModule],
templateUrl: './lore-node-create.component.html', templateUrl: './lore-node-create.component.html',
styleUrls: ['./lore-node-create.component.scss'] styleUrls: ['./lore-node-create.component.scss']
}) })

View File

@@ -1,54 +1,52 @@
<div class="page" *ngIf="node"> @if (node) {
<div class="page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>Éditer le dossier</h1> <h1>Éditer le dossier</h1>
<p class="subtitle"> <p class="subtitle">
{{ childFolderCount }} sous-dossier(s) · {{ pageCount }} page(s) {{ childFolderCount }} sous-dossier(s) · {{ pageCount }} page(s)
</p> </p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button> <button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button
type="submit"
class="btn-primary"
[disabled]="form.invalid"
(click)="save()">
Sauvegarder
</button>
</div>
</header>
<form [formGroup]="form" class="edit-form">
<div class="field">
<label>Nom du dossier *</label>
<input type="text" formControlName="name" />
</div>
<div class="field">
<label>Dossier parent <span class="optional">(optionnel)</span></label>
<select formControlName="parentId">
<option value="">— Racine du Lore —</option>
<option *ngFor="let parent of availableParents" [value]="parent.id">{{ parent.name }}</option>
</select>
<p class="hint">Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)</p>
</div>
<div class="field">
<label>Icône</label>
<div class="icon-grid">
<button <button
type="button" type="submit"
class="icon-btn" class="btn-primary"
*ngFor="let option of iconOptions" [disabled]="form.invalid"
[class.selected]="selectedIcon === option.key" (click)="save()">
(click)="selectIcon(option.key)"> Sauvegarder
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
</button> </button>
</div> </div>
</div> </header>
<form [formGroup]="form" class="edit-form">
</form> <div class="field">
<label>Nom du dossier *</label>
</div> <input type="text" formControlName="name" />
</div>
<div class="field">
<label>Dossier parent <span class="optional">(optionnel)</span></label>
<select formControlName="parentId">
<option value="">— Racine du Lore —</option>
@for (parent of availableParents; track parent) {
<option [value]="parent.id">{{ parent.name }}</option>
}
</select>
<p class="hint">Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)</p>
</div>
<div class="field">
<label>Icône</label>
<div class="icon-grid">
@for (option of iconOptions; track option) {
<button
type="button"
class="icon-btn"
[class.selected]="selectedIcon === option.key"
(click)="selectIcon(option.key)">
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
</button>
}
</div>
</div>
</form>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -32,7 +32,7 @@ import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
*/ */
@Component({ @Component({
selector: 'app-lore-node-edit', selector: 'app-lore-node-edit',
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule], imports: [ReactiveFormsModule, LucideAngularModule],
templateUrl: './lore-node-edit.component.html', templateUrl: './lore-node-edit.component.html',
styleUrls: ['./lore-node-edit.component.scss'] styleUrls: ['./lore-node-edit.component.scss']
}) })

View File

@@ -8,18 +8,20 @@
<div class="lore-grid"> <div class="lore-grid">
<div class="lore-card" *ngFor="let lore of lores" (click)="navigateToDetail(lore.id!)"> @for (lore of lores; track lore) {
<div class="card-header"> <div class="lore-card" (click)="navigateToDetail(lore.id!)">
<lucide-icon [img]="Folder" [size]="20" class="card-icon"></lucide-icon> <div class="card-header">
<span class="card-date">Il y a 2h</span> <lucide-icon [img]="Folder" [size]="20" class="card-icon"></lucide-icon>
<span class="card-date">Il y a 2h</span>
</div>
<h2>{{ lore.name }}</h2>
<p class="card-description">{{ lore.description }}</p>
<div class="card-stats">
<span>📄 {{ lore.pageCount || 0 }} pages</span>
<span>🌳 {{ lore.nodeCount || 0 }} dossiers</span>
</div>
</div> </div>
<h2>{{ lore.name }}</h2> }
<p class="card-description">{{ lore.description }}</p>
<div class="card-stats">
<span>📄 {{ lore.pageCount || 0 }} pages</span>
<span>🌳 {{ lore.nodeCount || 0 }} dossiers</span>
</div>
</div>
<div class="lore-card card-new" (click)="openCreateModal()"> <div class="lore-card card-new" (click)="openCreateModal()">
<div class="new-icon"> <div class="new-icon">
@@ -35,8 +37,9 @@
</div> </div>
<app-lore-create @if (showCreateModal) {
*ngIf="showCreateModal" <app-lore-create
(close)="onModalClose()" (close)="onModalClose()"
(created)="onLoreCreated($event)"> (created)="onLoreCreated($event)">
</app-lore-create> </app-lore-create>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular'; import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
import { LoreService } from '../services/lore.service'; import { LoreService } from '../services/lore.service';
@@ -9,7 +9,7 @@ import { LoreCreateComponent } from './lore-create/lore-create.component';
@Component({ @Component({
selector: 'app-lore', selector: 'app-lore',
imports: [CommonModule, LucideAngularModule, LoreCreateComponent], imports: [LucideAngularModule, LoreCreateComponent],
templateUrl: './lore.component.html', templateUrl: './lore.component.html',
styleUrls: ['./lore.component.scss'] styleUrls: ['./lore.component.scss']
}) })

View File

@@ -17,64 +17,66 @@
<div class="field"> <div class="field">
<label>Template *</label> <label>Template *</label>
<div class="templates-grid" *ngIf="templates.length; else emptyTemplates"> @if (templates.length) {
<button <div class="templates-grid">
type="button" @for (t of templates; track t) {
class="template-card" <button
*ngFor="let t of templates" type="button"
[class.selected]="selectedTemplateId === t.id" class="template-card"
(click)="selectTemplate(t)"> [class.selected]="selectedTemplateId === t.id"
<div class="template-card-head"> (click)="selectTemplate(t)">
<lucide-icon [img]="FileText" [size]="16"></lucide-icon> <div class="template-card-head">
<span class="template-name">{{ t.name }}</span> <lucide-icon [img]="FileText" [size]="16"></lucide-icon>
</div> <span class="template-name">{{ t.name }}</span>
<p class="template-description">{{ t.description || '—' }}</p> </div>
</button> <p class="template-description">{{ t.description || '—' }}</p>
</button>
<!-- Carte "+" : sauvegarde le brouillon et part creer un nouveau template ; }
template-create renverra ici via le mecanisme returnTo. --> <!-- Carte "+" : sauvegarde le brouillon et part creer un nouveau template ;
<a template-create renverra ici via le mecanisme returnTo. -->
class="template-card template-card-create" <a
[routerLink]="['/lore', loreId, 'templates', 'create']" class="template-card template-card-create"
[queryParams]="{ returnTo: 'page-create' }" [routerLink]="['/lore', loreId, 'templates', 'create']"
(click)="saveDraft()" [queryParams]="{ returnTo: 'page-create' }"
title="Créer un nouveau template pour ce Lore"> (click)="saveDraft()"
<div class="template-card-head"> title="Créer un nouveau template pour ce Lore">
<lucide-icon [img]="Plus" [size]="16"></lucide-icon> <div class="template-card-head">
<span class="template-name">Créer un template</span> <lucide-icon [img]="Plus" [size]="16"></lucide-icon>
</div> <span class="template-name">Créer un template</span>
<p class="template-description"> </div>
Vous reviendrez ici automatiquement, votre saisie sera conservée. <p class="template-description">
</p> Vous reviendrez ici automatiquement, votre saisie sera conservée.
</a> </p>
</div> </a>
</div>
<ng-template #emptyTemplates> } @else {
<p class="empty-hint"> <p class="empty-hint">
Aucun template défini pour ce Lore. Aucun template défini pour ce Lore.
<a [routerLink]="['/lore', loreId, 'templates', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un template</a> d'abord. <a [routerLink]="['/lore', loreId, 'templates', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un template</a> d'abord.
</p> </p>
</ng-template> }
</div> </div>
<!-- Dossier de destination --> <!-- Dossier de destination -->
<div class="field"> <div class="field">
<label for="page-node">Dossier de destination *</label> <label for="page-node">Dossier de destination *</label>
<ng-container *ngIf="nodes.length; else emptyFolders"> @if (nodes.length) {
<select id="page-node" formControlName="nodeId"> <select id="page-node" formControlName="nodeId">
<option value="" disabled>Sélectionnez un dossier</option> <option value="" disabled>Sélectionnez un dossier</option>
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option> @for (node of nodes; track node) {
<option [value]="node.id">{{ node.name }}</option>
}
</select> </select>
<p class="hint">La page sera créée dans ce dossier</p> <p class="hint">La page sera créée dans ce dossier</p>
</ng-container> } @else {
<ng-template #emptyFolders>
<p class="empty-hint"> <p class="empty-hint">
Aucun dossier dans ce Lore. Aucun dossier dans ce Lore.
<a [routerLink]="['/lore', loreId, 'nodes', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un dossier</a> d'abord. <a [routerLink]="['/lore', loreId, 'nodes', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un dossier</a> d'abord.
</p> </p>
</ng-template> }
</div> </div>
<!-- Aide contextuelle --> <!-- Aide contextuelle -->
@@ -84,13 +86,15 @@
</div> </div>
<!-- Erreur wizard (parsing &lt;values&gt; ou échec HTTP) --> <!-- Erreur wizard (parsing &lt;values&gt; ou échec HTTP) -->
<div class="wizard-error" *ngIf="wizardError" role="alert">{{ wizardError }}</div> @if (wizardError) {
<div class="wizard-error" role="alert">{{ wizardError }}</div>
}
<!-- Actions --> <!-- Actions -->
<div class="actions-row"> <div class="actions-row">
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button> <button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-ai" (click)="openWizard()" [disabled]="!canSubmit" <button type="button" class="btn-ai" (click)="openWizard()" [disabled]="!canSubmit"
title="Ouvrir l'assistant IA pour pré-remplir les champs"> title="Ouvrir l'assistant IA pour pré-remplir les champs">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Créer avec l'IA Créer avec l'IA
</button> </button>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { LucideAngularModule, FileText, Sparkles, Plus } from 'lucide-angular'; import { LucideAngularModule, FileText, Sparkles, Plus } from 'lucide-angular';
@@ -26,7 +26,7 @@ import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-d
*/ */
@Component({ @Component({
selector: 'app-page-create', selector: 'app-page-create',
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule, AiChatDrawerComponent], imports: [ReactiveFormsModule, RouterModule, LucideAngularModule, AiChatDrawerComponent],
templateUrl: './page-create.component.html', templateUrl: './page-create.component.html',
styleUrls: ['./page-create.component.scss'] styleUrls: ['./page-create.component.scss']
}) })

View File

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

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -36,7 +36,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
*/ */
@Component({ @Component({
selector: 'app-page-edit', selector: 'app-page-edit',
imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent], imports: [FormsModule, RouterLink, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent],
templateUrl: './page-edit.component.html', templateUrl: './page-edit.component.html',
styleUrls: ['./page-edit.component.scss'] styleUrls: ['./page-edit.component.scss']
}) })

View File

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

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
@@ -28,7 +28,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
*/ */
@Component({ @Component({
selector: 'app-page-view', selector: 'app-page-view',
imports: [CommonModule, RouterModule, LucideAngularModule, BreadcrumbComponent, ImageGalleryComponent], imports: [RouterModule, LucideAngularModule, BreadcrumbComponent, ImageGalleryComponent],
templateUrl: './page-view.component.html', templateUrl: './page-view.component.html',
styleUrls: ['./page-view.component.scss'] styleUrls: ['./page-view.component.scss']
}) })

View File

@@ -23,22 +23,23 @@
<div class="field"> <div class="field">
<label for="template-default-node">Dossier par défaut *</label> <label for="template-default-node">Dossier par défaut *</label>
<ng-container *ngIf="nodes.length; else emptyFolders"> @if (nodes.length) {
<select id="template-default-node" formControlName="defaultNodeId"> <select id="template-default-node" formControlName="defaultNodeId">
<option value="" disabled>Sélectionnez un dossier</option> <option value="" disabled>Sélectionnez un dossier</option>
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option> @for (node of nodes; track node) {
<option [value]="node.id">{{ node.name }}</option>
}
</select> </select>
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p> <p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p>
</ng-container> } @else {
<ng-template #emptyFolders>
<p class="empty-hint"> <p class="empty-hint">
Aucun dossier dans ce Lore. Aucun dossier dans ce Lore.
<a [routerLink]="['/lore', loreId, 'nodes', 'create']" <a [routerLink]="['/lore', loreId, 'nodes', 'create']"
[queryParams]="{ returnTo: nodeCreateReturnTo }" [queryParams]="{ returnTo: nodeCreateReturnTo }"
(click)="saveDraft()">Créer un dossier</a> d'abord. (click)="saveDraft()">Créer un dossier</a> d'abord.
</p> </p>
</ng-template> }
</div> </div>
</div> </div>
@@ -49,47 +50,51 @@
<label class="section-label">Champs du template *</label> <label class="section-label">Champs du template *</label>
<ul class="fields-list"> <ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last"> @for (f of fields; track f; let i = $index; let first = $first; let last = $last) {
<div class="reorder-stack"> <li class="field-row">
<button type="button" class="btn-icon btn-reorder" <div class="reorder-stack">
(click)="moveField(i, -1)" <button type="button" class="btn-icon btn-reorder"
[disabled]="first" (click)="moveField(i, -1)"
aria-label="Monter d'un cran" title="Monter"> [disabled]="first"
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon> aria-label="Monter d'un cran" title="Monter">
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
</button>
<button type="button" class="btn-icon btn-reorder"
(click)="moveField(i, 1)"
[disabled]="last"
aria-label="Descendre d'un cran" title="Descendre">
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button>
</div>
<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>
<button type="button" class="btn-icon btn-reorder" @if (f.type === 'IMAGE') {
(click)="moveField(i, 1)" <select
[disabled]="last" class="layout-select"
aria-label="Descendre d'un cran" title="Descendre"> [ngModel]="f.layout ?? 'GALLERY'"
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon> [ngModelOptions]="{ standalone: true }"
(ngModelChange)="setFieldLayout(i, $event)"
title="Mise en page des images">
<option value="GALLERY">Grille</option>
<option value="HERO">Heros</option>
<option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option>
</select>
}
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
</div> </li>
<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>
<select *ngIf="f.type === 'IMAGE'"
class="layout-select"
[ngModel]="f.layout ?? 'GALLERY'"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="setFieldLayout(i, $event)"
title="Mise en page des images">
<option value="GALLERY">Grille</option>
<option value="HERO">Heros</option>
<option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option>
</select>
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
</ul> </ul>
<div class="field-row add-row"> <div class="field-row add-row">

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular'; import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
@@ -20,7 +20,7 @@ import { popReturnTo } from '../return-stack.helper';
*/ */
@Component({ @Component({
selector: 'app-template-create', selector: 'app-template-create',
imports: [CommonModule, FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule], imports: [FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule],
templateUrl: './template-create.component.html', templateUrl: './template-create.component.html',
styleUrls: ['./template-create.component.scss'] styleUrls: ['./template-create.component.scss']
}) })

View File

@@ -1,117 +1,110 @@
<div class="page" *ngIf="template"> @if (template) {
<div class="page">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>{{ template.name }}</h1> <h1>{{ template.name }}</h1>
<p class="subtitle">Template</p> <p class="subtitle">Template</p>
</div>
<div class="header-actions">
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
<button type="button" class="btn-primary" (click)="save()" [disabled]="form.invalid">Sauvegarder</button>
</div>
</header>
<form [formGroup]="form" class="template-form">
<!-- Colonne gauche ---------------------------------------------- -->
<div class="col-left">
<div class="field">
<label for="template-edit-name">Nom</label>
<input id="template-edit-name" type="text" formControlName="name" />
</div> </div>
<div class="header-actions">
<div class="field"> <button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
<label for="template-edit-default-node">Dossier par défaut</label> <button type="button" class="btn-primary" (click)="save()" [disabled]="form.invalid">Sauvegarder</button>
<select id="template-edit-default-node" formControlName="defaultNodeId">
<option value="">-- Aucun --</option>
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
</select>
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier par défaut</p>
</div> </div>
</header>
<div class="field"> <form [formGroup]="form" class="template-form">
<label for="template-edit-description">Description</label> <!-- Colonne gauche ---------------------------------------------- -->
<textarea id="template-edit-description" formControlName="description" rows="6"></textarea> <div class="col-left">
<div class="field">
<label for="template-edit-name">Nom</label>
<input id="template-edit-name" type="text" formControlName="name" />
</div>
<div class="field">
<label for="template-edit-default-node">Dossier par défaut</label>
<select id="template-edit-default-node" formControlName="defaultNodeId">
<option value="">-- Aucun --</option>
@for (node of nodes; track node) {
<option [value]="node.id">{{ node.name }}</option>
}
</select>
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier par défaut</p>
</div>
<div class="field">
<label for="template-edit-description">Description</label>
<textarea id="template-edit-description" formControlName="description" rows="6"></textarea>
</div>
</div> </div>
<!-- Colonne droite --------------------------------------------- -->
</div> <div class="col-right">
<label class="section-label">Champs du template</label>
<!-- Colonne droite --------------------------------------------- --> <ul class="fields-list">
<div class="col-right"> @for (f of fields; track f; let i = $index; let first = $first; let last = $last) {
<li class="field-row">
<label class="section-label">Champs du template</label> <div class="reorder-stack">
<button type="button" class="btn-icon-ghost btn-reorder"
<ul class="fields-list"> (click)="moveField(i, -1)"
<li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last"> [disabled]="first"
<div class="reorder-stack"> aria-label="Monter d'un cran" title="Monter">
<button type="button" class="btn-icon-ghost btn-reorder" <lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
(click)="moveField(i, -1)" </button>
[disabled]="first" <button type="button" class="btn-icon-ghost btn-reorder"
aria-label="Monter d'un cran" title="Monter"> (click)="moveField(i, 1)"
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon> [disabled]="last"
</button> aria-label="Descendre d'un cran" title="Descendre">
<button type="button" class="btn-icon-ghost btn-reorder" <lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
(click)="moveField(i, 1)" </button>
[disabled]="last" </div>
aria-label="Descendre d'un cran" title="Descendre"> <span class="field-chip"
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button>
</div>
<span class="field-chip"
[class.field-chip-image]="f.type === 'IMAGE'" [class.field-chip-image]="f.type === 'IMAGE'"
[class.field-chip-existing]="f.type !== 'IMAGE' && isExistingField(f)" [class.field-chip-existing]="f.type !== 'IMAGE' && isExistingField(f)"
[class.field-chip-new]="f.type !== 'IMAGE' && !isExistingField(f)"> [class.field-chip-new]="f.type !== 'IMAGE' && !isExistingField(f)">
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon> <lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
{{ f.name }} {{ f.name }}
</span> </span>
<button type="button" <button type="button"
class="btn-icon-ghost btn-type-toggle" class="btn-icon-ghost btn-type-toggle"
(click)="toggleFieldType(i)" (click)="toggleFieldType(i)"
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'"> [title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }} {{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
</button> </button>
<select *ngIf="f.type === 'IMAGE'" @if (f.type === 'IMAGE') {
<select
class="layout-select" class="layout-select"
[ngModel]="f.layout ?? 'GALLERY'" [ngModel]="f.layout ?? 'GALLERY'"
[ngModelOptions]="{ standalone: true }" [ngModelOptions]="{ standalone: true }"
(ngModelChange)="setFieldLayout(i, $event)" (ngModelChange)="setFieldLayout(i, $event)"
title="Mise en page des images"> title="Mise en page des images">
<option value="GALLERY">Grille</option> <option value="GALLERY">Grille</option>
<option value="HERO">Heros</option> <option value="HERO">Heros</option>
<option value="MASONRY">Mosaique</option> <option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option> <option value="CAROUSEL">Carrousel</option>
</select>
}
<button type="button" class="btn-icon-danger" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
}
</ul>
<div class="field-row add-row">
<input
type="text"
[(ngModel)]="newFieldName"
[ngModelOptions]="{ standalone: true }"
placeholder="+ 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> </select>
<button type="button" class="btn-icon-danger" (click)="removeField(i)" aria-label="Supprimer"> <button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button> </button>
</li> </div>
</ul> <p class="hint">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p>
<div class="field-row add-row">
<input
type="text"
[(ngModel)]="newFieldName"
[ngModelOptions]="{ standalone: true }"
placeholder="+ Ajouter un champ"
(keydown.enter)="$event.preventDefault(); addField()" />
<select
class="type-select"
[(ngModel)]="newFieldType"
[ngModelOptions]="{ standalone: true }"
aria-label="Type du champ">
<option value="TEXT">Texte</option>
<option value="IMAGE">Image</option>
</select>
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button>
</div> </div>
</form>
<p class="hint">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p> </div>
}
</div>
</form>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, Subject } from 'rxjs'; import { forkJoin, Subject } from 'rxjs';
@@ -21,7 +21,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
*/ */
@Component({ @Component({
selector: 'app-template-edit', selector: 'app-template-edit',
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule], imports: [FormsModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './template-edit.component.html', templateUrl: './template-edit.component.html',
styleUrls: ['./template-edit.component.scss'] styleUrls: ['./template-edit.component.scss']
}) })

View File

@@ -2,33 +2,43 @@
<div #messagesContainer class="messages-area"> <div #messagesContainer class="messages-area">
<div class="welcome-hint" *ngIf="messages.length === 0 && !currentAssistantText && !error"> @if (messages.length === 0 && !currentAssistantText && !error) {
<lucide-icon [img]="Sparkles" [size]="18"></lucide-icon> <div class="welcome-hint">
<p>Pose une question à l'IA pendant la partie.</p> <lucide-icon [img]="Sparkles" [size]="18"></lucide-icon>
<p class="welcome-sub"> <p>Pose une question à l'IA pendant la partie.</p>
Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal. <p class="welcome-sub">
</p> Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal.
</div> </p>
</div>
}
<div *ngFor="let m of messages" class="msg" [class.msg--user]="m.role === 'user'" [class.msg--assistant]="m.role === 'assistant'"> @for (m of messages; track m) {
<div class="msg-content">{{ m.content }}</div> <div class="msg" [class.msg--user]="m.role === 'user'" [class.msg--assistant]="m.role === 'assistant'">
<button *ngIf="m.role === 'assistant'" <div class="msg-content">{{ m.content }}</div>
type="button" @if (m.role === 'assistant') {
class="msg-action" <button
[disabled]="!canSaveToJournal" type="button"
[title]="canSaveToJournal ? 'Ajouter cette réponse au journal' : 'Session terminée'" class="msg-action"
(click)="onSaveToJournal(m.content)"> [disabled]="!canSaveToJournal"
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon> [title]="canSaveToJournal ? 'Ajouter cette réponse au journal' : 'Session terminée'"
Au journal (click)="onSaveToJournal(m.content)">
</button> <lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
</div> Au journal
</button>
}
</div>
}
<!-- Stream en cours : on affiche les tokens au fil de l'eau. --> <!-- Stream en cours : on affiche les tokens au fil de l'eau. -->
<div *ngIf="currentAssistantText" class="msg msg--assistant msg--streaming"> @if (currentAssistantText) {
<div class="msg-content">{{ currentAssistantText }}<span class="cursor"></span></div> <div class="msg msg--assistant msg--streaming">
</div> <div class="msg-content">{{ currentAssistantText }}<span class="cursor"></span></div>
</div>
}
<p class="error-hint" *ngIf="error">{{ error }}</p> @if (error) {
<p class="error-hint">{{ error }}</p>
}
</div> </div>
<div class="composer"> <div class="composer">
@@ -39,33 +49,37 @@
rows="2" rows="2"
[placeholder]="isStreaming ? 'LIA répond' : 'Demande une idée, un rebondissement, une description'" [placeholder]="isStreaming ? 'LIA répond' : 'Demande une idée, un rebondissement, une description'"
[disabled]="isStreaming" [disabled]="isStreaming"
(keydown.control.enter)="send()"></textarea> (keydown.control.enter)="send()"></textarea>
<div class="composer-actions"> <div class="composer-actions">
<button type="button" <button type="button"
class="btn-link" class="btn-link"
[disabled]="messages.length === 0 && !currentAssistantText" [disabled]="messages.length === 0 && !currentAssistantText"
(click)="clearConversation()" (click)="clearConversation()"
title="Effacer la conversation"> title="Effacer la conversation">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon> <lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button> </button>
<button *ngIf="!isStreaming" @if (!isStreaming) {
type="button" <button
class="btn-primary btn-send" type="button"
[disabled]="!input.trim()" class="btn-primary btn-send"
(click)="send()"> [disabled]="!input.trim()"
<lucide-icon [img]="Send" [size]="14"></lucide-icon> (click)="send()">
Envoyer <lucide-icon [img]="Send" [size]="14"></lucide-icon>
</button> Envoyer
</button>
}
<button *ngIf="isStreaming" @if (isStreaming) {
type="button" <button
class="btn-secondary btn-send" type="button"
(click)="cancelStream()"> class="btn-secondary btn-send"
<lucide-icon [img]="Square" [size]="14"></lucide-icon> (click)="cancelStream()">
Stop <lucide-icon [img]="Square" [size]="14"></lucide-icon>
</button> Stop
</button>
}
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@ import {
Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges,
ElementRef, ViewChild ElementRef, ViewChild
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
LucideAngularModule, Send, Sparkles, Trash2, BookmarkPlus, Square LucideAngularModule, Send, Sparkles, Trash2, BookmarkPlus, Square
@@ -23,7 +23,7 @@ import { AiChatService, ChatMessage } from '../../services/ai-chat.service';
*/ */
@Component({ @Component({
selector: 'app-session-ai-chat-panel', selector: 'app-session-ai-chat-panel',
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [FormsModule, LucideAngularModule],
templateUrl: './session-ai-chat-panel.component.html', templateUrl: './session-ai-chat-panel.component.html',
styleUrls: ['./session-ai-chat-panel.component.scss'] styleUrls: ['./session-ai-chat-panel.component.scss']
}) })

View File

@@ -1,190 +1,193 @@
<div class="session-detail" *ngIf="session"> @if (session) {
<div class="session-detail">
<a class="back-link" [routerLink]="campaignId ? ['/campaigns', campaignId] : ['/campaigns']"> <a class="back-link" [routerLink]="campaignId ? ['/campaigns', campaignId] : ['/campaigns']">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne Retour à la campagne
</a> </a>
<div class="detail-header">
<div class="detail-header"> <div class="header-texts">
<div class="header-texts"> @if (!editingName) {
<div class="title-row" *ngIf="!editingName"> <div class="title-row">
<h1> <h1>
<lucide-icon [img]="Dices" [size]="24"></lucide-icon> <lucide-icon [img]="Dices" [size]="24"></lucide-icon>
{{ session.name }} {{ session.name }}
</h1> </h1>
<button type="button" class="btn-icon" (click)="startRename()" title="Renommer la session"> <button type="button" class="btn-icon" (click)="startRename()" title="Renommer la session">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> <lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
</button> </button>
</div>
}
@if (editingName) {
<div class="title-row edit-mode">
<input type="text"
[(ngModel)]="editName"
name="editName"
(keydown.enter)="saveRename()"
(keydown.escape)="cancelRename()"
autofocus />
<button type="button" class="btn-icon" (click)="saveRename()" [disabled]="!editName.trim()" title="Valider">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-icon" (click)="cancelRename()" title="Annuler">
Annuler
</button>
</div>
}
<div class="meta">
<span class="badge" [class.badge-active]="session.active">
{{ session.active ? 'En cours' : 'Terminée' }}
</span>
<span class="badge badge-muted">Démarrée le {{ session.startedAt | date:'dd/MM/yyyy HH:mm' }}</span>
@if (session.endedAt) {
<span class="badge badge-muted">
Terminée le {{ session.endedAt | date:'dd/MM/yyyy HH:mm' }}
</span>
}
</div>
</div> </div>
<div class="header-actions">
<div class="title-row edit-mode" *ngIf="editingName"> @if (session.active) {
<input type="text" <button type="button" class="btn-secondary" (click)="endSession()">
[(ngModel)]="editName" <lucide-icon [img]="Square" [size]="14"></lucide-icon>
name="editName" Terminer la session
(keydown.enter)="saveRename()" </button>
(keydown.escape)="cancelRename()" }
autofocus /> <button type="button" class="btn-danger" (click)="deleteSession()">
<button type="button" class="btn-icon" (click)="saveRename()" [disabled]="!editName.trim()" title="Valider"> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
<lucide-icon [img]="Check" [size]="14"></lucide-icon> Supprimer
</button> </button>
<button type="button" class="btn-icon" (click)="cancelRename()" title="Annuler">
Annuler
</button>
</div>
<div class="meta">
<span class="badge" [class.badge-active]="session.active">
{{ session.active ? 'En cours' : 'Terminée' }}
</span>
<span class="badge badge-muted">Démarrée le {{ session.startedAt | date:'dd/MM/yyyy HH:mm' }}</span>
<span class="badge badge-muted" *ngIf="session.endedAt">
Terminée le {{ session.endedAt | date:'dd/MM/yyyy HH:mm' }}
</span>
</div> </div>
</div> </div>
<!-- ============ Mode jeu : 2 colonnes (journal + panneau référence) ============ -->
<div class="header-actions"> <div class="play-grid">
<button *ngIf="session.active" type="button" class="btn-secondary" (click)="endSession()"> <!-- Colonne gauche : journal -->
<lucide-icon [img]="Square" [size]="14"></lucide-icon> <div class="play-main">
Terminer la session <!-- Ajouter une entrée -->
</button> @if (session.active) {
<button type="button" class="btn-danger" (click)="deleteSession()"> <section class="detail-section add-entry-section">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <div class="type-selector">
Supprimer @for (type of entryTypes; track type) {
</button> <button
</div>
</div>
<!-- ============ Mode jeu : 2 colonnes (journal + panneau référence) ============ -->
<div class="play-grid">
<!-- Colonne gauche : journal -->
<div class="play-main">
<!-- Ajouter une entrée -->
<section class="detail-section add-entry-section" *ngIf="session.active">
<div class="type-selector">
<button *ngFor="let type of entryTypes"
type="button" type="button"
class="type-chip" class="type-chip"
[class.type-chip--active]="newEntryType === type" [class.type-chip--active]="newEntryType === type"
[style.--type-color]="entryTypeMeta[type].color" [style.--type-color]="entryTypeMeta[type].color"
(click)="newEntryType = type"> (click)="newEntryType = type">
<lucide-icon [img]="typeIcons[type]" [size]="14"></lucide-icon> <lucide-icon [img]="typeIcons[type]" [size]="14"></lucide-icon>
{{ entryTypeMeta[type].label }} {{ entryTypeMeta[type].label }}
</button> </button>
</div> }
<textarea class="entry-input"
[(ngModel)]="newEntryContent"
name="newEntryContent"
rows="3"
[placeholder]="'Ajouter une ' + entryTypeMeta[newEntryType].label.toLowerCase() + ''"
(keydown.control.enter)="submitNewEntry()"></textarea>
<div class="entry-input-footer">
<span class="hint">Ctrl + Entrée pour ajouter</span>
<button type="button"
class="btn-primary"
[disabled]="!newEntryContent.trim() || submittingEntry"
(click)="submitNewEntry()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Ajouter
</button>
</div>
</section>
<!-- Timeline -->
<section class="detail-section timeline-section">
<h2>Journal de session</h2>
<div class="empty-state" *ngIf="entries.length === 0">
<p>Aucune entrée pour le moment.</p>
<p class="hint" *ngIf="session.active">
Saisis une note, un évènement ou un jet ci-dessus pour commencer le journal.
</p>
</div>
<ul class="timeline" *ngIf="entries.length > 0">
<li class="timeline-entry"
*ngFor="let entry of entries"
[style.--type-color]="entryTypeMeta[entry.type].color">
<div class="entry-marker">
<lucide-icon [img]="typeIcons[entry.type]" [size]="14"></lucide-icon>
</div> </div>
<textarea class="entry-input"
<div class="entry-body"> [(ngModel)]="newEntryContent"
<!-- Mode lecture --> name="newEntryContent"
<ng-container *ngIf="editingEntryId !== entry.id"> rows="3"
<div class="entry-header"> [placeholder]="'Ajouter une ' + entryTypeMeta[newEntryType].label.toLowerCase() + ''"
<span class="entry-type">{{ entryTypeMeta[entry.type].label }}</span> (keydown.control.enter)="submitNewEntry()"></textarea>
<span class="entry-time">{{ entry.occurredAt | date:'HH:mm — dd/MM/yyyy' }}</span> <div class="entry-input-footer">
<div class="entry-actions"> <span class="hint">Ctrl + Entrée pour ajouter</span>
<button type="button" class="btn-icon" (click)="startEditEntry(entry)" title="Modifier"> <button type="button"
<lucide-icon [img]="Pencil" [size]="12"></lucide-icon> class="btn-primary"
</button> [disabled]="!newEntryContent.trim() || submittingEntry"
<button type="button" class="btn-icon btn-icon--danger" (click)="deleteEntry(entry)" title="Supprimer"> (click)="submitNewEntry()">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button> Ajouter
</button>
</div>
</section>
}
<!-- Timeline -->
<section class="detail-section timeline-section">
<h2>Journal de session</h2>
@if (entries.length === 0) {
<div class="empty-state">
<p>Aucune entrée pour le moment.</p>
@if (session.active) {
<p class="hint">
Saisis une note, un évènement ou un jet ci-dessus pour commencer le journal.
</p>
}
</div>
}
@if (entries.length > 0) {
<ul class="timeline">
@for (entry of entries; track entry) {
<li class="timeline-entry"
[style.--type-color]="entryTypeMeta[entry.type].color">
<div class="entry-marker">
<lucide-icon [img]="typeIcons[entry.type]" [size]="14"></lucide-icon>
</div> </div>
</div> <div class="entry-body">
<p class="entry-content">{{ entry.content }}</p> <!-- Mode lecture -->
</ng-container> @if (editingEntryId !== entry.id) {
<div class="entry-header">
<!-- Mode édition --> <span class="entry-type">{{ entryTypeMeta[entry.type].label }}</span>
<ng-container *ngIf="editingEntryId === entry.id"> <span class="entry-time">{{ entry.occurredAt | date:'HH:mm — dd/MM/yyyy' }}</span>
<div class="type-selector type-selector--compact"> <div class="entry-actions">
<button *ngFor="let type of entryTypes" <button type="button" class="btn-icon" (click)="startEditEntry(entry)" title="Modifier">
type="button" <lucide-icon [img]="Pencil" [size]="12"></lucide-icon>
class="type-chip" </button>
[class.type-chip--active]="editEntryType === type" <button type="button" class="btn-icon btn-icon--danger" (click)="deleteEntry(entry)" title="Supprimer">
[style.--type-color]="entryTypeMeta[type].color" <lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
(click)="editEntryType = type"> </button>
<lucide-icon [img]="typeIcons[type]" [size]="12"></lucide-icon> </div>
{{ entryTypeMeta[type].label }} </div>
</button> <p class="entry-content">{{ entry.content }}</p>
</div> }
<textarea class="entry-input" <!-- Mode édition -->
[(ngModel)]="editEntryContent" @if (editingEntryId === entry.id) {
name="editEntryContent" <div class="type-selector type-selector--compact">
rows="3" @for (type of entryTypes; track type) {
(keydown.control.enter)="saveEditEntry(entry)" <button
(keydown.escape)="cancelEditEntry()"></textarea> type="button"
<div class="entry-input-footer"> class="type-chip"
<button type="button" class="btn-secondary btn-sm" (click)="cancelEditEntry()"> [class.type-chip--active]="editEntryType === type"
<lucide-icon [img]="X" [size]="12"></lucide-icon> [style.--type-color]="entryTypeMeta[type].color"
Annuler (click)="editEntryType = type">
</button> <lucide-icon [img]="typeIcons[type]" [size]="12"></lucide-icon>
<button type="button" {{ entryTypeMeta[type].label }}
</button>
}
</div>
<textarea class="entry-input"
[(ngModel)]="editEntryContent"
name="editEntryContent"
rows="3"
(keydown.control.enter)="saveEditEntry(entry)"
(keydown.escape)="cancelEditEntry()"></textarea>
<div class="entry-input-footer">
<button type="button" class="btn-secondary btn-sm" (click)="cancelEditEntry()">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
Annuler
</button>
<button type="button"
class="btn-primary btn-sm" class="btn-primary btn-sm"
[disabled]="!editEntryContent.trim()" [disabled]="!editEntryContent.trim()"
(click)="saveEditEntry(entry)"> (click)="saveEditEntry(entry)">
<lucide-icon [img]="Check" [size]="12"></lucide-icon> <lucide-icon [img]="Check" [size]="12"></lucide-icon>
Sauvegarder Sauvegarder
</button> </button>
</div> </div>
</ng-container> }
</div> </div>
</li>
</li> }
</ul> </ul>
</section> }
</section>
</div>
<!-- Colonne droite : panneau référence (Dés / Personnages / Scènes) -->
<aside class="play-aside">
<app-session-reference-panel
[campaignId]="campaignId ?? ''"
[playthroughId]="session.playthroughId"
[sessionId]="session.id"
[canAddToJournal]="session.active"
(rolled)="onDiceRolled($event)"
(aiReplyToJournal)="onAiReplyToJournal($event)"
(noteToJournal)="onItemNoteToJournal($event)">
</app-session-reference-panel>
</aside>
</div> </div>
<!-- Colonne droite : panneau référence (Dés / Personnages / Scènes) -->
<aside class="play-aside">
<app-session-reference-panel
[campaignId]="campaignId ?? ''"
[playthroughId]="session.playthroughId"
[sessionId]="session.id"
[canAddToJournal]="session.active"
(rolled)="onDiceRolled($event)"
(aiReplyToJournal)="onAiReplyToJournal($event)"
(noteToJournal)="onItemNoteToJournal($event)">
</app-session-reference-panel>
</aside>
</div> </div>
}
</div>

View File

@@ -2,13 +2,15 @@
<div class="dice-controls"> <div class="dice-controls">
<div class="face-grid"> <div class="face-grid">
<button *ngFor="let f of faces" @for (f of faces; track f) {
type="button" <button
class="face-chip" type="button"
[class.face-chip--active]="selectedFace === f" class="face-chip"
(click)="selectedFace = f"> [class.face-chip--active]="selectedFace === f"
d{{ f }} (click)="selectedFace = f">
</button> d{{ f }}
</button>
}
</div> </div>
<div class="dice-inputs"> <div class="dice-inputs">
@@ -28,34 +30,41 @@
</button> </button>
</div> </div>
<div class="dice-history" *ngIf="history.length > 0"> @if (history.length > 0) {
<div class="history-header"> <div class="dice-history">
<span>Derniers jets</span> <div class="history-header">
<button type="button" class="btn-link" (click)="clearHistory()" title="Vider l'historique local"> <span>Derniers jets</span>
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon> <button type="button" class="btn-link" (click)="clearHistory()" title="Vider l'historique local">
</button> <lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</div>
<ul class="history-list">
<li *ngFor="let r of history" class="history-item">
<div class="history-text">
<span class="history-notation">{{ r.notation }}</span>
<span class="history-detail" *ngIf="r.rolls.length > 1">[{{ r.rolls.join(', ') }}]</span>
<span class="history-total">= {{ r.total }}</span>
</div>
<button type="button"
class="btn-icon"
[disabled]="!canAddToJournal"
[title]="canAddToJournal ? 'Ajouter au journal' : 'Session terminée'"
(click)="addToJournal(r)">
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
</button> </button>
</li> </div>
</ul> <ul class="history-list">
</div> @for (r of history; track r) {
<li class="history-item">
<div class="history-text">
<span class="history-notation">{{ r.notation }}</span>
@if (r.rolls.length > 1) {
<span class="history-detail">[{{ r.rolls.join(', ') }}]</span>
}
<span class="history-total">= {{ r.total }}</span>
</div>
<button type="button"
class="btn-icon"
[disabled]="!canAddToJournal"
[title]="canAddToJournal ? 'Ajouter au journal' : 'Session terminée'"
(click)="addToJournal(r)">
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
</button>
</li>
}
</ul>
</div>
}
<p class="placeholder-hint" *ngIf="history.length === 0"> @if (history.length === 0) {
Choisis un dé et lance. <p class="placeholder-hint">
</p> Choisis un dé et lance.
</p>
}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Dices, BookmarkPlus, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Dices, BookmarkPlus, Trash2 } from 'lucide-angular';
@@ -26,7 +26,7 @@ export interface DiceRollResult {
*/ */
@Component({ @Component({
selector: 'app-session-dice-panel', selector: 'app-session-dice-panel',
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [FormsModule, LucideAngularModule],
templateUrl: './session-dice-panel.component.html', templateUrl: './session-dice-panel.component.html',
styleUrls: ['./session-dice-panel.component.scss'] styleUrls: ['./session-dice-panel.component.scss']
}) })

View File

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

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Package, BookmarkPlus, ChevronLeft } from 'lucide-angular'; import { LucideAngularModule, Package, BookmarkPlus, ChevronLeft } from 'lucide-angular';
import { catchError, of } from 'rxjs'; import { catchError, of } from 'rxjs';
import { ItemCatalogService } from '../../services/item-catalog.service'; import { ItemCatalogService } from '../../services/item-catalog.service';
@@ -13,7 +13,7 @@ interface ItemGroup { category: string; items: CatalogItem[]; }
*/ */
@Component({ @Component({
selector: 'app-session-item-catalogs-panel', selector: 'app-session-item-catalogs-panel',
imports: [CommonModule, LucideAngularModule], imports: [LucideAngularModule],
templateUrl: './session-item-catalogs-panel.component.html', templateUrl: './session-item-catalogs-panel.component.html',
styleUrls: ['./session-item-catalogs-panel.component.scss'] styleUrls: ['./session-item-catalogs-panel.component.scss']
}) })

View File

@@ -1,51 +1,67 @@
<div class="srt-panel"> <div class="srt-panel">
<p class="loading-hint" *ngIf="loading">Chargement…</p> @if (loading) {
<p class="loading-hint">Chargement…</p>
}
<!-- Liste des tables --> <!-- Liste des tables -->
<div *ngIf="!loading && !selected" class="srt-list"> @if (!loading && !selected) {
<p class="empty-hint" *ngIf="tables.length === 0"> <div class="srt-list">
Aucune table aléatoire dans cette campagne. Crée-en une depuis la sidebar. @if (tables.length === 0) {
</p> <p class="empty-hint">
<button *ngFor="let t of tables" type="button" class="srt-item" (click)="select(t)"> Aucune table aléatoire dans cette campagne. Crée-en une depuis la sidebar.
<lucide-icon [img]="Dices" [size]="13"></lucide-icon> </p>
<span class="srt-item-name">{{ t.name }}</span> }
<span class="srt-item-formula">{{ t.diceFormula }}</span> @for (t of tables; track t) {
</button> <button type="button" class="srt-item" (click)="select(t)">
</div> <lucide-icon [img]="Dices" [size]="13"></lucide-icon>
<span class="srt-item-name">{{ t.name }}</span>
<span class="srt-item-formula">{{ t.diceFormula }}</span>
</button>
}
</div>
}
<!-- Table sélectionnée : jet --> <!-- Table sélectionnée : jet -->
<div *ngIf="selected" class="srt-detail"> @if (selected) {
<button type="button" class="srt-back" (click)="backToList()"> <div class="srt-detail">
<lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Tables <button type="button" class="srt-back" (click)="backToList()">
</button> <lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Tables
</button>
<h4>{{ selected.name }}</h4> <h4>{{ selected.name }}</h4>
<button type="button" class="srt-roll" (click)="roll()">
<button type="button" class="srt-roll" (click)="roll()"> <lucide-icon [img]="Dices" [size]="15"></lucide-icon>
<lucide-icon [img]="Dices" [size]="15"></lucide-icon> Lancer {{ selected.diceFormula }}
Lancer {{ selected.diceFormula }} </button>
</button> @if (lastRoll) {
<div class="srt-result">
<div class="srt-result" *ngIf="lastRoll"> <div class="srt-total">
<div class="srt-total"> {{ lastRoll.total }}
{{ lastRoll.total }} @if (lastRoll.rolls.length > 1) {
<span class="srt-rolls" *ngIf="lastRoll.rolls.length > 1">({{ lastRoll.rolls.join(' + ') }})</span> <span class="srt-rolls">({{ lastRoll.rolls.join(' + ') }})</span>
}
<strong *ngIf="matched">{{ matched.label }}</strong>
<em *ngIf="!matched">aucune entrée</em> @if (matched) {
</div> <strong>{{ matched.label }}</strong>
<div class="srt-detail-text" *ngIf="matched?.detail">{{ matched?.detail }}</div> }
@if (!matched) {
<div class="srt-actions"> <em>aucune entrée</em>
<button type="button" class="srt-act" (click)="addToJournal()" [disabled]="!canAddToJournal"> }
<lucide-icon [img]="BookmarkPlus" [size]="13"></lucide-icon> Journal </div>
</button> @if (matched?.detail) {
<button type="button" class="srt-act srt-act--ai" (click)="improvise()" <div class="srt-detail-text">{{ matched?.detail }}</div>
[disabled]="!matched || improvising || !canAddToJournal"> }
<lucide-icon [img]="Sparkles" [size]="13"></lucide-icon> <div class="srt-actions">
{{ improvising ? '…' : 'IA improvise' }} <button type="button" class="srt-act" (click)="addToJournal()" [disabled]="!canAddToJournal">
</button> <lucide-icon [img]="BookmarkPlus" [size]="13"></lucide-icon> Journal
</div> </button>
<button type="button" class="srt-act srt-act--ai" (click)="improvise()"
[disabled]="!matched || improvising || !canAddToJournal">
<lucide-icon [img]="Sparkles" [size]="13"></lucide-icon>
{{ improvising ? '…' : 'IA improvise' }}
</button>
</div>
</div>
}
</div> </div>
</div> }
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Dices, BookmarkPlus, Sparkles, ChevronLeft } from 'lucide-angular'; import { LucideAngularModule, Dices, BookmarkPlus, Sparkles, ChevronLeft } from 'lucide-angular';
import { catchError, of } from 'rxjs'; import { catchError, of } from 'rxjs';
import { RandomTableService } from '../../services/random-table.service'; import { RandomTableService } from '../../services/random-table.service';
@@ -18,7 +18,7 @@ import { DiceRollResult } from '../session-dice-panel/session-dice-panel.compone
*/ */
@Component({ @Component({
selector: 'app-session-random-tables-panel', selector: 'app-session-random-tables-panel',
imports: [CommonModule, LucideAngularModule], imports: [LucideAngularModule],
templateUrl: './session-random-tables-panel.component.html', templateUrl: './session-random-tables-panel.component.html',
styleUrls: ['./session-random-tables-panel.component.scss'] styleUrls: ['./session-random-tables-panel.component.scss']
}) })

View File

@@ -2,44 +2,44 @@
<nav class="ref-tabs"> <nav class="ref-tabs">
<button type="button" <button type="button"
class="ref-tab" class="ref-tab"
[class.ref-tab--active]="activeTab === 'ai'" [class.ref-tab--active]="activeTab === 'ai'"
(click)="selectTab('ai')"> (click)="selectTab('ai')">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
IA IA
</button> </button>
<button type="button" <button type="button"
class="ref-tab" class="ref-tab"
[class.ref-tab--active]="activeTab === 'dice'" [class.ref-tab--active]="activeTab === 'dice'"
(click)="selectTab('dice')"> (click)="selectTab('dice')">
<lucide-icon [img]="Dices" [size]="14"></lucide-icon> <lucide-icon [img]="Dices" [size]="14"></lucide-icon>
Dés Dés
</button> </button>
<button type="button" <button type="button"
class="ref-tab" class="ref-tab"
[class.ref-tab--active]="activeTab === 'tables'" [class.ref-tab--active]="activeTab === 'tables'"
(click)="selectTab('tables')"> (click)="selectTab('tables')">
<lucide-icon [img]="Table2" [size]="14"></lucide-icon> <lucide-icon [img]="Table2" [size]="14"></lucide-icon>
Tables Tables
</button> </button>
<button type="button" <button type="button"
class="ref-tab" class="ref-tab"
[class.ref-tab--active]="activeTab === 'objects'" [class.ref-tab--active]="activeTab === 'objects'"
(click)="selectTab('objects')"> (click)="selectTab('objects')">
<lucide-icon [img]="Package" [size]="14"></lucide-icon> <lucide-icon [img]="Package" [size]="14"></lucide-icon>
Objets Objets
</button> </button>
<button type="button" <button type="button"
class="ref-tab" class="ref-tab"
[class.ref-tab--active]="activeTab === 'characters'" [class.ref-tab--active]="activeTab === 'characters'"
(click)="selectTab('characters')"> (click)="selectTab('characters')">
<lucide-icon [img]="User" [size]="14"></lucide-icon> <lucide-icon [img]="User" [size]="14"></lucide-icon>
PJ/PNJ PJ/PNJ
</button> </button>
<button type="button" <button type="button"
class="ref-tab" class="ref-tab"
[class.ref-tab--active]="activeTab === 'scenes'" [class.ref-tab--active]="activeTab === 'scenes'"
(click)="selectTab('scenes')"> (click)="selectTab('scenes')">
<lucide-icon [img]="Swords" [size]="14"></lucide-icon> <lucide-icon [img]="Swords" [size]="14"></lucide-icon>
Scènes Scènes
</button> </button>
@@ -48,104 +48,130 @@
<div class="ref-content" [class.ref-content--fill]="activeTab === 'ai'"> <div class="ref-content" [class.ref-content--fill]="activeTab === 'ai'">
<!-- ====== IA ====== --> <!-- ====== IA ====== -->
<app-session-ai-chat-panel @if (activeTab === 'ai') {
*ngIf="activeTab === 'ai'" <app-session-ai-chat-panel
[sessionId]="sessionId" [sessionId]="sessionId"
[canSaveToJournal]="canAddToJournal" [canSaveToJournal]="canAddToJournal"
(saveToJournal)="onAiSaveToJournal($event)"> (saveToJournal)="onAiSaveToJournal($event)">
</app-session-ai-chat-panel> </app-session-ai-chat-panel>
}
<!-- ====== Dés ====== --> <!-- ====== Dés ====== -->
<app-session-dice-panel @if (activeTab === 'dice') {
*ngIf="activeTab === 'dice'" <app-session-dice-panel
[canAddToJournal]="canAddToJournal" [canAddToJournal]="canAddToJournal"
(rolled)="onDiceRolled($event)"> (rolled)="onDiceRolled($event)">
</app-session-dice-panel> </app-session-dice-panel>
}
<!-- ====== Tables aléatoires ====== --> <!-- ====== Tables aléatoires ====== -->
<app-session-random-tables-panel @if (activeTab === 'tables') {
*ngIf="activeTab === 'tables'" <app-session-random-tables-panel
[campaignId]="campaignId" [campaignId]="campaignId"
[canAddToJournal]="canAddToJournal" [canAddToJournal]="canAddToJournal"
(rolled)="onDiceRolled($event)" (rolled)="onDiceRolled($event)"
(aiReplyToJournal)="onAiSaveToJournal($event)"> (aiReplyToJournal)="onAiSaveToJournal($event)">
</app-session-random-tables-panel> </app-session-random-tables-panel>
}
<!-- ====== Catalogues d'objets ====== --> <!-- ====== Catalogues d'objets ====== -->
<app-session-item-catalogs-panel @if (activeTab === 'objects') {
*ngIf="activeTab === 'objects'" <app-session-item-catalogs-panel
[campaignId]="campaignId" [campaignId]="campaignId"
[canAddToJournal]="canAddToJournal" [canAddToJournal]="canAddToJournal"
(noteToJournal)="onItemNote($event)"> (noteToJournal)="onItemNote($event)">
</app-session-item-catalogs-panel> </app-session-item-catalogs-panel>
}
<!-- ====== Personnages (PJ + PNJ) ====== --> <!-- ====== Personnages (PJ + PNJ) ====== -->
<div *ngIf="activeTab === 'characters'" class="ref-list"> @if (activeTab === 'characters') {
<p class="loading-hint" *ngIf="loadingChars">Chargement…</p> <div class="ref-list">
@if (loadingChars) {
<div *ngIf="!loadingChars"> <p class="loading-hint">Chargement…</p>
<div class="ref-group" *ngIf="characters.length > 0"> }
<h4> @if (!loadingChars) {
<lucide-icon [img]="User" [size]="13"></lucide-icon> <div>
Personnages joueurs @if (characters.length > 0) {
</h4> <div class="ref-group">
<button *ngFor="let c of characters" <h4>
type="button" <lucide-icon [img]="User" [size]="13"></lucide-icon>
class="ref-item" Personnages joueurs
(click)="openInNewTab(['campaigns', campaignId, 'playthroughs', playthroughId!, 'characters', c.id!])"> </h4>
<span class="ref-item-name">{{ c.name }}</span> @for (c of characters; track c) {
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon> <button
</button> type="button"
</div> class="ref-item"
(click)="openInNewTab(['campaigns', campaignId, 'playthroughs', playthroughId!, 'characters', c.id!])">
<div class="ref-group" *ngIf="npcs.length > 0"> <span class="ref-item-name">{{ c.name }}</span>
<h4> <lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
<lucide-icon [img]="Drama" [size]="13"></lucide-icon> </button>
Personnages non-joueurs }
</h4> </div>
<button *ngFor="let n of npcs" }
type="button" @if (npcs.length > 0) {
class="ref-item" <div class="ref-group">
(click)="openInNewTab(['campaigns', campaignId, 'npcs', n.id!])"> <h4>
<span class="ref-item-name">{{ n.name }}</span> <lucide-icon [img]="Drama" [size]="13"></lucide-icon>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon> Personnages non-joueurs
</button> </h4>
</div> @for (n of npcs; track n) {
<button
<p class="empty-hint" *ngIf="characters.length === 0 && npcs.length === 0"> type="button"
Aucun personnage dans cette campagne. class="ref-item"
</p> (click)="openInNewTab(['campaigns', campaignId, 'npcs', n.id!])">
<span class="ref-item-name">{{ n.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button>
}
</div>
}
@if (characters.length === 0 && npcs.length === 0) {
<p class="empty-hint">
Aucun personnage dans cette campagne.
</p>
}
</div>
}
</div> </div>
</div> }
<!-- ====== Scènes (arborescence aplatie) ====== --> <!-- ====== Scènes (arborescence aplatie) ====== -->
<div *ngIf="activeTab === 'scenes'" class="ref-list"> @if (activeTab === 'scenes') {
<p class="loading-hint" *ngIf="loadingTree">Chargement…</p> <div class="ref-list">
@if (loadingTree) {
<ng-container *ngIf="!loadingTree && treeData"> <p class="loading-hint">Chargement…</p>
<p class="empty-hint" *ngIf="treeData.arcs.length === 0"> }
Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici. @if (!loadingTree && treeData) {
</p> @if (treeData.arcs.length === 0) {
<p class="empty-hint">
<div *ngFor="let arc of treeData.arcs" class="ref-group"> Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici.
<h4> </p>
<lucide-icon [img]="Swords" [size]="13"></lucide-icon> }
{{ arc.name }} @for (arc of treeData.arcs; track arc) {
</h4> <div class="ref-group">
<h4>
<div *ngFor="let chapter of chaptersOf(arc)" class="ref-subgroup"> <lucide-icon [img]="Swords" [size]="13"></lucide-icon>
<span class="ref-subgroup-title">{{ chapter.name }}</span> {{ arc.name }}
<button *ngFor="let scene of scenesOf(chapter)" </h4>
type="button" @for (chapter of chaptersOf(arc); track chapter) {
class="ref-item ref-item--nested" <div class="ref-subgroup">
(click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])"> <span class="ref-subgroup-title">{{ chapter.name }}</span>
<span class="ref-item-name">{{ scene.name }}</span> @for (scene of scenesOf(chapter); track scene) {
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon> <button
</button> type="button"
</div> class="ref-item ref-item--nested"
</div> (click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])">
</ng-container> <span class="ref-item-name">{{ scene.name }}</span>
</div> <lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button>
}
</div>
}
</div>
}
}
</div>
}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular'; import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular';
import { catchError, of } from 'rxjs'; import { catchError, of } from 'rxjs';
import { CampaignService } from '../../services/campaign.service'; import { CampaignService } from '../../services/campaign.service';
@@ -30,7 +30,7 @@ type TabId = 'dice' | 'tables' | 'objects' | 'characters' | 'scenes' | 'ai';
*/ */
@Component({ @Component({
selector: 'app-session-reference-panel', selector: 'app-session-reference-panel',
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent], imports: [LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent],
templateUrl: './session-reference-panel.component.html', templateUrl: './session-reference-panel.component.html',
styleUrls: ['./session-reference-panel.component.scss'] styleUrls: ['./session-reference-panel.component.scss']
}) })

View File

@@ -1,85 +1,109 @@
<!-- Messages locaux a la gestion des modeles (pull / suppression) --> <!-- Messages locaux a la gestion des modeles (pull / suppression) -->
<div *ngIf="errorMessage" class="alert alert-error"> @if (errorMessage) {
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <div class="alert alert-error">
<span>{{ errorMessage }}</span> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
</div> <span>{{ errorMessage }}</span>
<div *ngIf="successMessage" class="alert alert-success"> </div>
<lucide-icon [img]="Check" [size]="16"></lucide-icon> }
<span>{{ successMessage }}</span> @if (successMessage) {
</div> <div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ successMessage }}</span>
</div>
}
<!-- Liste des modeles installes avec bouton supprimer --> <!-- Liste des modeles installes avec bouton supprimer -->
<div class="form-row" *ngIf="models.length > 0"> @if (models.length > 0) {
<label>Modeles installes</label> <div class="form-row">
<ul class="installed-models"> <label>Modeles installes</label>
<li *ngFor="let m of models"> <ul class="installed-models">
<span class="model-name">{{ m }}</span> @for (m of models; track m) {
<button type="button" class="btn-icon btn-danger" <li>
(click)="deleteModel(m)" <span class="model-name">{{ m }}</span>
[disabled]="deletingModel === m" <button type="button" class="btn-icon btn-danger"
[title]="'Supprimer ' + m"> (click)="deleteModel(m)"
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> [disabled]="deletingModel === m"
</button> [title]="'Supprimer ' + m">
</li> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</ul> </button>
</div> </li>
}
</ul>
</div>
}
<!-- Dialog de telechargement de modele --> <!-- Dialog de telechargement de modele -->
<div class="modal-overlay" *ngIf="pullDialogOpen" (click)="closePullDialog()"> @if (pullDialogOpen) {
<div class="modal-content" (click)="$event.stopPropagation()"> <div class="modal-overlay" (click)="closePullDialog()">
<header class="modal-header"> <div class="modal-content" (click)="$event.stopPropagation()">
<h3>Telecharger un modele Ollama</h3> <header class="modal-header">
<button type="button" class="btn-icon" (click)="closePullDialog()" [disabled]="pullInProgress" title="Fermer"> <h3>Telecharger un modele Ollama</h3>
<lucide-icon [img]="X" [size]="18"></lucide-icon> <button type="button" class="btn-icon" (click)="closePullDialog()" [disabled]="pullInProgress" title="Fermer">
</button> <lucide-icon [img]="X" [size]="18"></lucide-icon>
</header> </button>
</header>
<div class="modal-body"> <div class="modal-body">
<div *ngIf="!pullInProgress"> @if (!pullInProgress) {
<label for="pull-name">Nom du modele</label> <div>
<input id="pull-name" type="text" [(ngModel)]="pullModelName" <label for="pull-name">Nom du modele</label>
placeholder="ex: gemma4:e4b" autocomplete="off" <input id="pull-name" type="text" [(ngModel)]="pullModelName"
(keydown.enter)="startPull()"> placeholder="ex: gemma4:e4b" autocomplete="off"
(keydown.enter)="startPull()">
<p class="hint">Suggestions :</p> <p class="hint">Suggestions :</p>
<div class="suggestions"> <div class="suggestions">
<button type="button" *ngFor="let s of pullSuggestions" @for (s of pullSuggestions; track s) {
class="suggestion-chip" (click)="selectSuggestion(s)">{{ s }}</button> <button type="button"
</div> class="suggestion-chip" (click)="selectSuggestion(s)">{{ s }}</button>
<p class="hint" style="margin-top: 0.75rem;"> }
La liste complete est sur <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>. </div>
</p> <p class="hint" style="margin-top: 0.75rem;">
</div> La liste complete est sur <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>.
</p>
<div *ngIf="pullInProgress" class="pull-progress"> </div>
<div class="pull-status">{{ pullStatus }}</div> }
<div class="progress-bar" *ngIf="pullTotal > 0"> @if (pullInProgress) {
<div class="progress-fill" [style.width.%]="pullPercent"></div> <div class="pull-progress">
</div> <div class="pull-status">{{ pullStatus }}</div>
<div class="progress-text" *ngIf="pullTotal > 0"> @if (pullTotal > 0) {
{{ formatBytes(pullCompleted) }} / {{ formatBytes(pullTotal) }} ({{ pullPercent }}%) <div class="progress-bar">
</div> <div class="progress-fill" [style.width.%]="pullPercent"></div>
<div class="progress-text" *ngIf="pullTotal === 0"> </div>
Preparation... }
</div> @if (pullTotal > 0) {
<div class="progress-text">
{{ formatBytes(pullCompleted) }} / {{ formatBytes(pullTotal) }} ({{ pullPercent }}%)
</div>
}
@if (pullTotal === 0) {
<div class="progress-text">
Preparation...
</div>
}
</div>
}
</div> </div>
<footer class="modal-footer">
@if (pullInProgress) {
<button type="button" class="btn-secondary"
(click)="cancelPull()">
Annuler
</button>
}
@if (!pullInProgress) {
<button type="button" class="btn-secondary"
(click)="closePullDialog()">
Fermer
</button>
}
@if (!pullInProgress) {
<button type="button" class="btn-primary"
(click)="startPull()"
[disabled]="pullInProgress || !pullModelName.trim()">
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
<span>Telecharger</span>
</button>
}
</footer>
</div> </div>
<footer class="modal-footer">
<button type="button" class="btn-secondary"
(click)="cancelPull()" *ngIf="pullInProgress">
Annuler
</button>
<button type="button" class="btn-secondary"
(click)="closePullDialog()" *ngIf="!pullInProgress">
Fermer
</button>
<button type="button" class="btn-primary"
(click)="startPull()"
[disabled]="pullInProgress || !pullModelName.trim()" *ngIf="!pullInProgress">
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
<span>Telecharger</span>
</button>
</footer>
</div> </div>
</div> }

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Check, AlertCircle, Download, Trash2, X } from 'lucide-angular'; import { LucideAngularModule, Check, AlertCircle, Download, Trash2, X } from 'lucide-angular';
import { SettingsService, OllamaPullEvent } from '../../services/settings.service'; import { SettingsService, OllamaPullEvent } from '../../services/settings.service';
@@ -19,7 +19,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
*/ */
@Component({ @Component({
selector: 'app-ollama-model-manager', selector: 'app-ollama-model-manager',
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [FormsModule, LucideAngularModule],
templateUrl: './ollama-model-manager.component.html', templateUrl: './ollama-model-manager.component.html',
// Reutilise la feuille de style de l'ecran Parametres (modal, suggestions, // Reutilise la feuille de style de l'ecran Parametres (modal, suggestions,
// progress-bar, installed-models) pour un rendu strictement identique. // progress-bar, installed-models) pour un rendu strictement identique.

View File

@@ -8,373 +8,417 @@
<h1>Parametres</h1> <h1>Parametres</h1>
</header> </header>
<div *ngIf="errorMessage" class="alert alert-error"> @if (errorMessage) {
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <div class="alert alert-error">
<span>{{ errorMessage }}</span> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
</div> <span>{{ errorMessage }}</span>
<div *ngIf="successMessage" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ successMessage }}</span>
</div>
<section class="card" *ngIf="settings">
<h2>Moteur IA</h2>
<p class="hint">Choix du fournisseur de modele de langage utilise par le chat et la generation de pages.</p>
<div class="form-row">
<label>Fournisseur</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="provider" value="ollama" [(ngModel)]="settings.llm_provider">
<span>Ollama (local)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
<span>OpenRouter (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="mistral" [(ngModel)]="settings.llm_provider">
<span>Mistral (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="gemini" [(ngModel)]="settings.llm_provider">
<span>Gemini (cloud)</span>
</label>
</div>
</div> </div>
</section> }
@if (successMessage) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ successMessage }}</span>
</div>
}
@if (settings) {
<section class="card">
<h2>Moteur IA</h2>
<p class="hint">Choix du fournisseur de modele de langage utilise par le chat et la generation de pages.</p>
<div class="form-row">
<label>Fournisseur</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="provider" value="ollama" [(ngModel)]="settings.llm_provider">
<span>Ollama (local)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
<span>OpenRouter (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="mistral" [(ngModel)]="settings.llm_provider">
<span>Mistral (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="gemini" [(ngModel)]="settings.llm_provider">
<span>Gemini (cloud)</span>
</label>
</div>
</div>
</section>
}
<!-- Bloc Ollama --> <!-- Bloc Ollama -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'"> @if (settings && settings.llm_provider === 'ollama') {
<h2>Configuration Ollama</h2> <section class="card">
<h2>Configuration Ollama</h2>
<div class="form-row"> <div class="form-row">
<label for="ollama-url">URL du serveur Ollama</label> <label for="ollama-url">URL du serveur Ollama</label>
<input id="ollama-url" type="text" [(ngModel)]="settings.ollama_base_url" placeholder="http://localhost:11434"> <input id="ollama-url" type="text" [(ngModel)]="settings.ollama_base_url" placeholder="http://localhost:11434">
</div>
<div class="form-row">
<label for="ollama-model">Modele</label>
<div class="inline-select">
<select id="ollama-model" [(ngModel)]="settings.llm_model" (ngModelChange)="fetchOllamaModelInfo()">
<option *ngIf="ollamaModels.length === 0" [value]="settings.llm_model">{{ settings.llm_model }}</option>
<option *ngFor="let m of ollamaModels" [value]="m">{{ m }}</option>
</select>
<button type="button" class="btn-secondary" (click)="refreshModels()" [disabled]="loadingModels">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
</button>
<button type="button" class="btn-secondary" (click)="modelManager.openPullDialog()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
<span>Telecharger</span>
</button>
</div> </div>
<p class="hint" *ngIf="ollamaModels.length === 0">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p> <div class="form-row">
</div> <label for="ollama-model">Modele</label>
<div class="inline-select">
<!-- Gestion des modeles installes (liste + suppression + dialog de pull) --> <select id="ollama-model" [(ngModel)]="settings.llm_model" (ngModelChange)="fetchOllamaModelInfo()">
<app-ollama-model-manager @if (ollamaModels.length === 0) {
#modelManager <option [value]="settings.llm_model">{{ settings.llm_model }}</option>
[models]="ollamaModels" }
(modelsChanged)="refreshModels()" @for (m of ollamaModels; track m) {
(modelPulled)="onModelPulled($event)" <option [value]="m">{{ m }}</option>
(modelDeleted)="onModelDeleted($event)"> }
</app-ollama-model-manager> </select>
</section> <button type="button" class="btn-secondary" (click)="refreshModels()" [disabled]="loadingModels">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
</button>
<button type="button" class="btn-secondary" (click)="modelManager.openPullDialog()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
<span>Telecharger</span>
</button>
</div>
@if (ollamaModels.length === 0) {
<p class="hint">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
}
</div>
<!-- Gestion des modeles installes (liste + suppression + dialog de pull) -->
<app-ollama-model-manager
#modelManager
[models]="ollamaModels"
(modelsChanged)="refreshModels()"
(modelPulled)="onModelPulled($event)"
(modelDeleted)="onModelDeleted($event)">
</app-ollama-model-manager>
</section>
}
<!-- Bloc 1min.ai --> <!-- Bloc 1min.ai -->
<section class="card" *ngIf="settings && settings.llm_provider === 'onemin'"> @if (settings && settings.llm_provider === 'onemin') {
<h2>Configuration 1min.ai</h2> <section class="card">
<h2>Configuration 1min.ai</h2>
<div class="form-row">
<label for="onemin-key">Cle API</label>
<input
id="onemin-key"
type="password"
[(ngModel)]="oneminApiKeyInput"
[placeholder]="settings.onemin_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API'">
<label class="checkbox" *ngIf="settings.onemin_api_key_set">
<input type="checkbox" [(ngModel)]="clearApiKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="onemin-provider">Fournisseur</label>
<select id="onemin-provider" [(ngModel)]="oneminProvider" (ngModelChange)="onProviderChange()">
<option *ngFor="let g of oneminGroups" [value]="g.provider">{{ g.provider }}</option>
</select>
</div>
<div class="form-row">
<label for="onemin-model">Modele</label>
<select id="onemin-model" [(ngModel)]="settings.onemin_model">
<option *ngFor="let m of currentProviderModels" [value]="m">{{ m }}</option>
</select>
</div>
</section>
<!-- Bloc OpenRouter -->
<section class="card" *ngIf="settings && settings.llm_provider === 'openrouter'">
<h2>Configuration OpenRouter</h2>
<div class="form-row">
<label for="openrouter-key">Cle API</label>
<input
id="openrouter-key"
type="password"
[(ngModel)]="openrouterApiKeyInput"
[placeholder]="settings.openrouter_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API (sk-or-...)'">
<label class="checkbox" *ngIf="settings.openrouter_api_key_set">
<input type="checkbox" [(ngModel)]="clearOpenrouterKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="openrouter-model">Modele</label>
<label class="checkbox">
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
<span>Gratuits seulement</span>
</label>
<select id="openrouter-model" [(ngModel)]="settings.openrouter_model">
<option *ngFor="let o of openrouterSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> :
un modele marque <code>· gratuit</code> ou le routeur <code>openrouter/free</code>
(choisit un modele gratuit tout seul). Pour les imports, prefere un gratuit a
<strong>grand contexte</strong> (valeur « ctx »).
</p>
</div>
</section>
<!-- Bloc Mistral -->
<section class="card" *ngIf="settings && settings.llm_provider === 'mistral'">
<h2>Configuration Mistral</h2>
<div class="form-row">
<label for="mistral-key">Cle API</label>
<input
id="mistral-key"
type="password"
[(ngModel)]="mistralApiKeyInput"
[placeholder]="settings.mistral_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Mistral'">
<label class="checkbox" *ngIf="settings.mistral_api_key_set">
<input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="mistral-model">Modele</label>
<select id="mistral-model" [(ngModel)]="settings.mistral_model">
<option *ngFor="let o of mistralSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Cle gratuite sur <code>console.mistral.ai</code> (tier « Experiment », sans CB).
Pour les imports, prefere <code>mistral-large-latest</code> (128k contexte, fidele
et bon en francais) ou <code>mistral-small-latest</code> (plus rapide).
</p>
</div>
</section>
<!-- Bloc Gemini -->
<section class="card" *ngIf="settings && settings.llm_provider === 'gemini'">
<h2>Configuration Gemini</h2>
<div class="form-row">
<label for="gemini-key">Cle API</label>
<input
id="gemini-key"
type="password"
[(ngModel)]="geminiApiKeyInput"
[placeholder]="settings.gemini_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Gemini'">
<label class="checkbox" *ngIf="settings.gemini_api_key_set">
<input type="checkbox" [(ngModel)]="clearGeminiKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="gemini-model">Modele</label>
<select id="gemini-model" [(ngModel)]="settings.gemini_model">
<option *ngFor="let o of geminiSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Cle gratuite sur <code>aistudio.google.com</code> (sans CB). Ideal pour les imports :
<code>gemini-2.0-flash</code> a un <strong>contexte ~1M</strong> → un livre entier tient
en 1-2 appels (plus de morceaux perdus). Pense a monter la <strong>taille des morceaux</strong>
d'import pour en profiter.
</p>
</div>
</section>
<!-- Bloc Embeddings (Atelier RAG) : indépendant du modèle de CHAT ci-dessus. -->
<section class="card" *ngIf="settings">
<h2>Embeddings (Atelier RAG)</h2>
<p class="hint">
Modèle utilisé pour <strong>indexer les sources PDF</strong> des ateliers et y faire
de la recherche. C'est un modèle <strong>séparé</strong> du modèle de chat ci-dessus.
</p>
<div class="form-row">
<label>Fournisseur d'embeddings</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="embprovider" value="ollama" [(ngModel)]="settings.embedding_provider">
<span>Ollama (local, gratuit)</span>
</label>
<label class="radio">
<input type="radio" name="embprovider" value="mistral" [(ngModel)]="settings.embedding_provider">
<span>Mistral (cloud, EU)</span>
</label>
</div>
</div>
<!-- Ollama embeddings -->
<div *ngIf="settings.embedding_provider === 'ollama'">
<div class="form-row"> <div class="form-row">
<label for="emb-ollama-model">Modèle d'embedding Ollama</label> <label for="onemin-key">Cle API</label>
<input id="emb-ollama-model" type="text" [(ngModel)]="settings.ollama_embedding_model" <input
placeholder="nomic-embed-text"> id="onemin-key"
<p class="hint"> type="password"
Recommandé : <code>nomic-embed-text</code> (léger). Gratuit et illimité (local). [(ngModel)]="oneminApiKeyInput"
</p> [placeholder]="settings.onemin_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API'">
</div> @if (settings.onemin_api_key_set) {
<div class="form-row"> <label class="checkbox">
<label class="checkbox"> <input type="checkbox" [(ngModel)]="clearApiKey">
<input type="checkbox" [(ngModel)]="settings.auto_pull_embedding_model"> <span>Effacer la cle enregistree</span>
<span>Installer automatiquement le modèle au démarrage s'il manque</span> </label>
</label> }
</div> </div>
</div> <div class="form-row">
<label for="onemin-provider">Fournisseur</label>
<select id="onemin-provider" [(ngModel)]="oneminProvider" (ngModelChange)="onProviderChange()">
@for (g of oneminGroups; track g) {
<option [value]="g.provider">{{ g.provider }}</option>
}
</select>
</div>
<div class="form-row">
<label for="onemin-model">Modele</label>
<select id="onemin-model" [(ngModel)]="settings.onemin_model">
@for (m of currentProviderModels; track m) {
<option [value]="m">{{ m }}</option>
}
</select>
</div>
</section>
}
<div class="form-row"> <!-- Bloc OpenRouter -->
<label for="rag-topk">Extraits récupérés par question (couverture RAG)</label> @if (settings && settings.llm_provider === 'openrouter') {
<input id="rag-topk" type="number" min="3" max="200" [(ngModel)]="settings.rag_top_k"> <section class="card">
<p class="hint"> <h2>Configuration OpenRouter</h2>
Plus haut = l'IA voit plus de passages par question (mieux pour les questions <div class="form-row">
larges « liste les… »), mais prompt plus long. Défaut 8. Avec un modèle <label for="openrouter-key">Cle API</label>
<strong>gros-contexte</strong> (Gemini), tu peux monter à <strong>50-150</strong> pour <input
une couverture quasi « document entier ». Pour les questions exhaustives, préfère id="openrouter-key"
le bouton <strong>« Analyse approfondie »</strong> de l'atelier. type="password"
</p> [(ngModel)]="openrouterApiKeyInput"
</div> [placeholder]="settings.openrouter_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API (sk-or-...)'">
@if (settings.openrouter_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearOpenrouterKey">
<span>Effacer la cle enregistree</span>
</label>
}
</div>
<div class="form-row">
<label for="openrouter-model">Modele</label>
<label class="checkbox">
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
<span>Gratuits seulement</span>
</label>
<select id="openrouter-model" [(ngModel)]="settings.openrouter_model">
@for (o of openrouterSelectOptions; track o) {
<option [value]="o.id">{{ o.label }}</option>
}
</select>
<p class="hint">
Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> :
un modele marque <code>· gratuit</code> ou le routeur <code>openrouter/free</code>
(choisit un modele gratuit tout seul). Pour les imports, prefere un gratuit a
<strong>grand contexte</strong> (valeur « ctx »).
</p>
</div>
</section>
}
<!-- Mistral embeddings --> <!-- Bloc Mistral -->
<div *ngIf="settings.embedding_provider === 'mistral'"> @if (settings && settings.llm_provider === 'mistral') {
<div class="form-row"> <section class="card">
<label for="emb-mistral-key"> <h2>Configuration Mistral</h2>
Clé API Mistral <div class="form-row">
<span class="key-state" *ngIf="settings.mistral_api_key_set">✓ configurée</span> <label for="mistral-key">Cle API</label>
</label> <input
<input id="emb-mistral-key" type="password" [(ngModel)]="mistralApiKeyInput" id="mistral-key"
[placeholder]="settings.mistral_api_key_set ? 'Clé configurée (laisser vide pour ne pas changer)' : 'Saisir votre clé API Mistral'"> type="password"
<label class="checkbox" *ngIf="settings.mistral_api_key_set"> [(ngModel)]="mistralApiKeyInput"
<input type="checkbox" [(ngModel)]="clearMistralKey"> [placeholder]="settings.mistral_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Mistral'">
<span>Effacer la clé enregistrée</span> @if (settings.mistral_api_key_set) {
</label> <label class="checkbox">
<p class="hint"> <input type="checkbox" [(ngModel)]="clearMistralKey">
C'est la <strong>même clé</strong> que pour le chat Mistral (une seule clé partagée). <span>Effacer la cle enregistree</span>
Soumis au rate limit du tier gratuit. </label>
</p> }
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="emb-mistral-model">Modèle d'embedding Mistral</label> <label for="mistral-model">Modele</label>
<input id="emb-mistral-model" type="text" [(ngModel)]="settings.mistral_embedding_model" <select id="mistral-model" [(ngModel)]="settings.mistral_model">
placeholder="mistral-embed"> @for (o of mistralSelectOptions; track o) {
</div> <option [value]="o.id">{{ o.label }}</option>
</div> }
</section> </select>
<p class="hint">
Cle gratuite sur <code>console.mistral.ai</code> (tier « Experiment », sans CB).
Pour les imports, prefere <code>mistral-large-latest</code> (128k contexte, fidele
et bon en francais) ou <code>mistral-small-latest</code> (plus rapide).
</p>
</div>
</section>
}
<!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai <!-- Bloc Gemini -->
parametre du modele local). Pour le cloud (1min/OpenRouter), on ne fixe rien : @if (settings && settings.llm_provider === 'gemini') {
on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. --> <section class="card">
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'"> <h2>Configuration Gemini</h2>
<h2>Fenetre de contexte</h2> <div class="form-row">
<label for="gemini-key">Cle API</label>
<input
id="gemini-key"
type="password"
[(ngModel)]="geminiApiKeyInput"
[placeholder]="settings.gemini_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Gemini'">
@if (settings.gemini_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearGeminiKey">
<span>Effacer la cle enregistree</span>
</label>
}
</div>
<div class="form-row">
<label for="gemini-model">Modele</label>
<select id="gemini-model" [(ngModel)]="settings.gemini_model">
@for (o of geminiSelectOptions; track o) {
<option [value]="o.id">{{ o.label }}</option>
}
</select>
<p class="hint">
Cle gratuite sur <code>aistudio.google.com</code> (sans CB). Ideal pour les imports :
<code>gemini-2.0-flash</code> a un <strong>contexte ~1M</strong> → un livre entier tient
en 1-2 appels (plus de morceaux perdus). Pense a monter la <strong>taille des morceaux</strong>
d'import pour en profiter.
</p>
</div>
</section>
}
<!-- Ollama : slider borne par le max du modele --> <!-- Bloc Embeddings (Atelier RAG) : indépendant du modèle de CHAT ci-dessus. -->
<div class="form-row" *ngIf="settings.llm_provider === 'ollama'"> @if (settings) {
<label for="llm-num-ctx"> <section class="card">
Tokens alloues au modele <h2>Embeddings (Atelier RAG)</h2>
<span class="ctx-value">{{ settings.llm_num_ctx | number }}</span> <p class="hint">
<span class="ctx-max" *ngIf="ollamaModelMaxContext > 0"> Modèle utilisé pour <strong>indexer les sources PDF</strong> des ateliers et y faire
/ {{ ollamaModelMaxContext | number }} max de la recherche. C'est un modèle <strong>séparé</strong> du modèle de chat ci-dessus.
</span> </p>
</label> <div class="form-row">
<input <label>Fournisseur d'embeddings</label>
id="llm-num-ctx" <div class="radio-group">
type="range" <label class="radio">
[min]="CTX_MIN" <input type="radio" name="embprovider" value="ollama" [(ngModel)]="settings.embedding_provider">
[max]="effectiveMaxContext" <span>Ollama (local, gratuit)</span>
step="1024" </label>
[(ngModel)]="settings.llm_num_ctx" <label class="radio">
class="ctx-slider"> <input type="radio" name="embprovider" value="mistral" [(ngModel)]="settings.embedding_provider">
<p class="hint" *ngIf="ollamaModelMaxContext > 0"> <span>Mistral (cloud, EU)</span>
Le modele <strong>{{ settings.llm_model }}</strong> accepte jusqu'a </label>
{{ ollamaModelMaxContext | number }} tokens. Plus la valeur est elevee, plus </div>
l'IA peut tenir d'historique et de contexte — au prix de VRAM et de latence. </div>
</p> <!-- Ollama embeddings -->
<p class="hint" *ngIf="ollamaModelMaxContext === 0"> @if (settings.embedding_provider === 'ollama') {
Impossible de determiner la fenetre max du modele (Ollama injoignable ou modele <div>
inconnu). Slider borne a {{ CTX_FALLBACK_MAX | number }} par securite. <div class="form-row">
</p> <label for="emb-ollama-model">Modèle d'embedding Ollama</label>
</div> <input id="emb-ollama-model" type="text" [(ngModel)]="settings.ollama_embedding_model"
placeholder="nomic-embed-text">
<p class="hint">
Recommandé : <code>nomic-embed-text</code> (léger). Gratuit et illimité (local).
</p>
</div>
<div class="form-row">
<label class="checkbox">
<input type="checkbox" [(ngModel)]="settings.auto_pull_embedding_model">
<span>Installer automatiquement le modèle au démarrage s'il manque</span>
</label>
</div>
</div>
}
<div class="form-row">
<label for="rag-topk">Extraits récupérés par question (couverture RAG)</label>
<input id="rag-topk" type="number" min="3" max="200" [(ngModel)]="settings.rag_top_k">
<p class="hint">
Plus haut = l'IA voit plus de passages par question (mieux pour les questions
larges « liste les… »), mais prompt plus long. Défaut 8. Avec un modèle
<strong>gros-contexte</strong> (Gemini), tu peux monter à <strong>50-150</strong> pour
une couverture quasi « document entier ». Pour les questions exhaustives, préfère
le bouton <strong>« Analyse approfondie »</strong> de l'atelier.
</p>
</div>
<!-- Mistral embeddings -->
@if (settings.embedding_provider === 'mistral') {
<div>
<div class="form-row">
<label for="emb-mistral-key">
Clé API Mistral
@if (settings.mistral_api_key_set) {
<span class="key-state">✓ configurée</span>
}
</label>
<input id="emb-mistral-key" type="password" [(ngModel)]="mistralApiKeyInput"
[placeholder]="settings.mistral_api_key_set ? 'Clé configurée (laisser vide pour ne pas changer)' : 'Saisir votre clé API Mistral'">
@if (settings.mistral_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la clé enregistrée</span>
</label>
}
<p class="hint">
C'est la <strong>même clé</strong> que pour le chat Mistral (une seule clé partagée).
Soumis au rate limit du tier gratuit.
</p>
</div>
<div class="form-row">
<label for="emb-mistral-model">Modèle d'embedding Mistral</label>
<input id="emb-mistral-model" type="text" [(ngModel)]="settings.mistral_embedding_model"
placeholder="mistral-embed">
</div>
</div>
}
</section>
}
</section> <!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai
parametre du modele local). Pour le cloud (1min/OpenRouter), on ne fixe rien :
on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. -->
@if (settings && settings.llm_provider === 'ollama') {
<section class="card">
<h2>Fenetre de contexte</h2>
<!-- Ollama : slider borne par le max du modele -->
@if (settings.llm_provider === 'ollama') {
<div class="form-row">
<label for="llm-num-ctx">
Tokens alloues au modele
<span class="ctx-value">{{ settings.llm_num_ctx | number }}</span>
@if (ollamaModelMaxContext > 0) {
<span class="ctx-max">
/ {{ ollamaModelMaxContext | number }} max
</span>
}
</label>
<input
id="llm-num-ctx"
type="range"
[min]="CTX_MIN"
[max]="effectiveMaxContext"
step="1024"
[(ngModel)]="settings.llm_num_ctx"
class="ctx-slider">
@if (ollamaModelMaxContext > 0) {
<p class="hint">
Le modele <strong>{{ settings.llm_model }}</strong> accepte jusqu'a
{{ ollamaModelMaxContext | number }} tokens. Plus la valeur est elevee, plus
l'IA peut tenir d'historique et de contexte — au prix de VRAM et de latence.
</p>
}
@if (ollamaModelMaxContext === 0) {
<p class="hint">
Impossible de determiner la fenetre max du modele (Ollama injoignable ou modele
inconnu). Slider borne a {{ CTX_FALLBACK_MAX | number }} par securite.
</p>
}
</div>
}
</section>
}
<!-- Bloc Import de PDF --> <!-- Bloc Import de PDF -->
<section class="card" *ngIf="settings"> @if (settings) {
<h2>Import de PDF</h2> <section class="card">
<div class="form-row"> <h2>Import de PDF</h2>
<label for="import-chunk-tokens">Taille des morceaux a l'import (tokens)</label> <div class="form-row">
<input <label for="import-chunk-tokens">Taille des morceaux a l'import (tokens)</label>
id="import-chunk-tokens" <input
type="number" id="import-chunk-tokens"
min="2000" type="number"
step="1000" min="2000"
[(ngModel)]="settings.import_chunk_tokens"> step="1000"
<p class="hint"> [(ngModel)]="settings.import_chunk_tokens">
Lors de l'import d'un PDF (regles ou campagne), le texte est decoupe en morceaux <p class="hint">
de cette taille avant d'etre analyse par l'IA. <strong>Plus gros = moins de morceaux</strong>, Lors de l'import d'un PDF (regles ou campagne), le texte est decoupe en morceaux
donc plus rapide et moins de fragmentation/doublons mais le morceau doit tenir dans de cette taille avant d'etre analyse par l'IA. <strong>Plus gros = moins de morceaux</strong>,
la fenetre du modele. donc plus rapide et moins de fragmentation/doublons — mais le morceau doit tenir dans
<br /> la fenetre du modele.
Repere : ~10 000 pour Ollama (16k de contexte) ; jusqu'a ~100 000 pour un modele a <br />
grand contexte (ex : GPT-5 mini, 400k) afin de traiter un livre entier en une seule passe. Repere : ~10 000 pour Ollama (16k de contexte) ; jusqu'a ~100 000 pour un modele a
Attention : un gros morceau = generation plus longue (voir le timeout ci-dessous). grand contexte (ex : GPT-5 mini, 400k) afin de traiter un livre entier en une seule passe.
</p> Attention : un gros morceau = generation plus longue (voir le timeout ci-dessous).
</div> </p>
</div>
<div class="form-row">
<label for="llm-timeout">Timeout des appels IA (secondes)</label>
<input
id="llm-timeout"
type="number"
min="30"
step="30"
[(ngModel)]="settings.llm_timeout_seconds">
<p class="hint">
Delai max d'attente d'une reponse du modele. Si un import lourd echoue avec
« delai depasse » (timeout), <strong>augmentez cette valeur</strong> (ex : 600)
ou reduisez la taille des morceaux ci-dessus. Defaut : 300 s.
</p>
</div>
</section>
}
<div class="form-row"> <!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
<label for="llm-timeout">Timeout des appels IA (secondes)</label> <app-settings-updates-section></app-settings-updates-section>
<input
id="llm-timeout"
type="number"
min="30"
step="30"
[(ngModel)]="settings.llm_timeout_seconds">
<p class="hint">
Delai max d'attente d'une reponse du modele. Si un import lourd echoue avec
« delai depasse » (timeout), <strong>augmentez cette valeur</strong> (ex : 600)
ou reduisez la taille des morceaux ci-dessus. Defaut : 300 s.
</p>
</div>
</section>
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) --> @if (settings) {
<app-settings-updates-section></app-settings-updates-section> <div class="actions">
<button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
</button>
</div>
}
<div class="actions" *ngIf="settings"> </div>
<button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
</button>
</div>
</div>

View File

@@ -1,241 +1,289 @@
<!-- Bloc Mises a jour (canal stable + canal beta Patreon fusionnes) --> <!-- Bloc Mises a jour (canal stable + canal beta Patreon fusionnes) -->
<section class="card" *ngIf="config.updateCheckEnabled || licenseStatus?.enabled"> @if (config.updateCheckEnabled || licenseStatus?.enabled) {
<h2>Mises a jour</h2> <section class="card">
<p class="hint">Verifie aupres du registry Docker si une nouvelle version <h2>Mises a jour</h2>
des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont <p class="hint">Verifie aupres du registry Docker si une nouvelle version
des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont
exclus — ils sont mis a jour manuellement.</p> exclus — ils sont mis a jour manuellement.</p>
@if (licenseSuccess) {
<div *ngIf="licenseSuccess" class="alert alert-success"> <div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ licenseSuccess }}</span>
</div>
<!-- ====================================================== -->
<!-- Sous-section : canal stable -->
<!-- ====================================================== -->
<div class="channel-block" *ngIf="config.updateCheckEnabled">
<h3 class="channel-title">Canal stable</h3>
<div class="form-row">
<button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span>
</button>
</div>
<div *ngIf="updateStatus && !updateStatus.enabled" class="hint">
Feature non configuree (WATCHTOWER_TOKEN absent).
</div>
<div *ngIf="updateStatus?.enabled">
<div *ngIf="updateStatus?.updateAvailable" class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span>
</div>
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible (baseline absente ou registry injoignable).</span>
</div>
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div>
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
</button>
</div>
<div *ngIf="updateMessage" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon> <lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ updateMessage }}</span> <span>{{ licenseSuccess }}</span>
</div> </div>
</div> }
</div> <!-- ====================================================== -->
<!-- Sous-section : canal stable -->
<!-- ====================================================== --> <!-- ====================================================== -->
<!-- Sous-section : canal beta (Patreon) --> @if (config.updateCheckEnabled) {
<!-- ====================================================== --> <div class="channel-block">
<div class="channel-block" *ngIf="licenseStatus?.enabled"> <h3 class="channel-title">Canal stable</h3>
<h3 class="channel-title"> <div class="form-row">
<lucide-icon [img]="Heart" [size]="16"></lucide-icon> <button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking">
Canal beta &mdash; reserve aux patrons <lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
</h3> <span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span>
<p class="hint">
Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere.
Le tier <strong>Compagnon</strong> (7&euro;/mois) ou superieur debloque ce canal.
</p>
<!-- Pas de licence installee -->
<ng-container *ngIf="licenseStatus?.status === 'NONE'">
<div class="form-row">
<button type="button" class="btn-primary" (click)="connectPatreon()">
<lucide-icon [img]="Link2" [size]="16"></lucide-icon>
<span>Connecter mon compte Patreon</span>
</button>
</div>
<p class="hint">
Une nouvelle fenetre va s'ouvrir vers Patreon. Apres autorisation, copie le token affiche
et colle-le ci-dessous.
</p>
<div class="form-row">
<label for="license-jwt">Token Patreon</label>
<input
id="license-jwt"
type="text"
[(ngModel)]="licenseJwtInput"
placeholder="eyJhbGciOiJFZERTQS..."
autocomplete="off"
>
</div>
<div class="form-row">
<button type="button" class="btn-primary" (click)="installLicense()" [disabled]="!licenseJwtInput.trim()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Activer la licence</span>
</button>
</div>
<div *ngIf="licenseError" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ licenseError }}</span>
</div>
</ng-container>
<!-- Licence installee (VALID / GRACE / EXPIRED / UNVERIFIABLE) -->
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
</div>
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Connexion Patreon expiree, mais acces beta maintenu pendant la periode de tolerance.
Verifie que ton abonnement Patreon est toujours actif et clique sur "Verifier maintenant".
</span>
</div>
<div *ngIf="licenseStatus.status === 'EXPIRED'" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta.
</span>
</div>
<div *ngIf="licenseStatus.status === 'UNVERIFIABLE'" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Le token installe ne peut plus etre verifie. Reconnecte-toi.</span>
</div>
<ul class="license-info">
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
<li *ngIf="licenseStatus.expiresAt">
<strong>Validite :</strong>
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
<span *ngIf="daysUntilExpiry !== null && daysUntilExpiry > 0">
(renouvellement dans {{ daysUntilExpiry }} jour<span *ngIf="daysUntilExpiry > 1">s</span>)
</span>
</li>
<li *ngIf="licenseStatus.lastRefreshAttemptAt">
<strong>Dernier refresh :</strong>
{{ formatDate(licenseStatus.lastRefreshAttemptAt) }}
<span *ngIf="licenseStatus.lastRefreshSucceeded === true" class="badge-ok">OK</span>
<span *ngIf="licenseStatus.lastRefreshSucceeded === false" class="badge-warn">echec</span>
</li>
</ul>
<div class="form-row form-row-inline">
<label class="checkbox">
<input
type="checkbox"
[checked]="licenseStatus.betaChannelEnabled"
(change)="toggleBetaChannel(!licenseStatus.betaChannelEnabled)"
[disabled]="licenseStatus.status !== 'VALID' && licenseStatus.status !== 'GRACE'"
>
<span>Activer le canal beta</span>
</label>
</div>
<div class="form-row form-row-actions">
<button type="button" class="btn-secondary" (click)="refreshLicense()" [disabled]="licenseLoading">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ licenseLoading ? 'Verification...' : 'Verifier maintenant' }}</span>
</button>
<button type="button" class="btn-secondary btn-danger" (click)="disconnectPatreon()">
<lucide-icon [img]="Unlink" [size]="14"></lucide-icon>
<span>Deconnecter Patreon</span>
</button>
</div>
<!-- Etat du canal beta -->
<div *ngIf="licenseStatus.betaChannelEnabled" class="beta-status">
<div *ngIf="betaChecking" class="hint">Verification des images beta...</div>
<div *ngIf="!betaChecking && betaStatus && !betaStatus.enabled" class="hint">
Indisponible : {{ betaStatus.disabledReason }}
</div>
<div *ngIf="!betaChecking && betaStatus?.enabled">
<div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
</div>
</div>
</div>
<!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
<div class="channel-switch" *ngIf="channelStatus">
<div class="channel-current">
<span class="channel-label">Canal actuel :</span>
<span class="channel-badge"
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
</span>
</div>
<!-- Sidecar dispo : boutons d'action -->
<ng-container *ngIf="channelStatus.switcherAvailable">
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
<button *ngIf="channelStatus.currentChannel === 'stable'"
type="button" class="btn-primary"
[disabled]="switchInFlight"
(click)="requestChannelSwitch('beta')">
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
</button> </button>
<!-- On beta -> proposer retour stable -->
<button *ngIf="channelStatus.currentChannel === 'beta'"
type="button" class="btn-secondary"
[disabled]="switchInFlight"
(click)="requestChannelSwitch('stable')">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
</button>
<!-- Switch en cours : on prévient que la page va se rendre injoignable -->
<div *ngIf="switchInFlight" class="alert alert-warn">
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
<span>Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.</span>
</div>
<!-- Erreur eventuelle remontee par le sidecar -->
<div *ngIf="switchError && !switchInFlight" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ switchError }}</span>
</div>
</ng-container>
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
<div *ngIf="!channelStatus.switcherAvailable" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Le sidecar de bascule n'est pas installe. Pour beneficier du switch
automatique, recupere le dernier <code>docker-compose.yml</code> du repo
et fais <code>docker compose pull &amp;&amp; docker compose up -d</code> une
fois. Sinon, bascule manuellement en editant <code>IMAGE_NAMESPACE</code>
dans ton <code>.env</code> (<code>igmlcreation/loremind-</code> pour stable,
<code>igmlcreation/loremind-beta-</code> pour beta).
</span>
</div> </div>
@if (updateStatus && !updateStatus.enabled) {
<div class="hint">
Feature non configuree (WATCHTOWER_TOKEN absent).
</div>
}
@if (updateStatus?.enabled) {
<div>
@if (updateStatus?.updateAvailable) {
<div class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span>
</div>
}
@if (updateStatus?.anyUnknown && !updateStatus?.updateAvailable) {
<div class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible (baseline absente ou registry injoignable).</span>
</div>
}
@if (!updateStatus?.updateAvailable && !updateStatus?.anyUnknown) {
<div class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div>
}
@if (updateStatus?.updateAvailable) {
<div class="form-row">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
</button>
</div>
}
@if (updateMessage) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ updateMessage }}</span>
</div>
}
</div>
}
</div> </div>
</ng-container> }
</div> <!-- ====================================================== -->
</section> <!-- Sous-section : canal beta (Patreon) -->
<!-- ====================================================== -->
@if (licenseStatus?.enabled) {
<div class="channel-block">
<h3 class="channel-title">
<lucide-icon [img]="Heart" [size]="16"></lucide-icon>
Canal beta &mdash; reserve aux patrons
</h3>
<p class="hint">
Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere.
Le tier <strong>Compagnon</strong> (7&euro;/mois) ou superieur debloque ce canal.
</p>
<!-- Pas de licence installee -->
@if (licenseStatus?.status === 'NONE') {
<div class="form-row">
<button type="button" class="btn-primary" (click)="connectPatreon()">
<lucide-icon [img]="Link2" [size]="16"></lucide-icon>
<span>Connecter mon compte Patreon</span>
</button>
</div>
<p class="hint">
Une nouvelle fenetre va s'ouvrir vers Patreon. Apres autorisation, copie le token affiche
et colle-le ci-dessous.
</p>
<div class="form-row">
<label for="license-jwt">Token Patreon</label>
<input
id="license-jwt"
type="text"
[(ngModel)]="licenseJwtInput"
placeholder="eyJhbGciOiJFZERTQS..."
autocomplete="off"
>
</div>
<div class="form-row">
<button type="button" class="btn-primary" (click)="installLicense()" [disabled]="!licenseJwtInput.trim()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Activer la licence</span>
</button>
</div>
@if (licenseError) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ licenseError }}</span>
</div>
}
}
<!-- Licence installee (VALID / GRACE / EXPIRED / UNVERIFIABLE) -->
@if (licenseStatus && licenseStatus.status !== 'NONE') {
@if (licenseStatus.status === 'VALID') {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
</div>
}
@if (licenseStatus.status === 'GRACE') {
<div class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Connexion Patreon expiree, mais acces beta maintenu pendant la periode de tolerance.
Verifie que ton abonnement Patreon est toujours actif et clique sur "Verifier maintenant".
</span>
</div>
}
@if (licenseStatus.status === 'EXPIRED') {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta.
</span>
</div>
}
@if (licenseStatus.status === 'UNVERIFIABLE') {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Le token installe ne peut plus etre verifie. Reconnecte-toi.</span>
</div>
}
<ul class="license-info">
@if (licenseStatus.tierId) {
<li><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
}
@if (licenseStatus.expiresAt) {
<li>
<strong>Validite :</strong>
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
@if (daysUntilExpiry !== null && daysUntilExpiry > 0) {
<span>
(renouvellement dans {{ daysUntilExpiry }} jour@if (daysUntilExpiry > 1) {
<span>s</span>
})
</span>
}
</li>
}
@if (licenseStatus.lastRefreshAttemptAt) {
<li>
<strong>Dernier refresh :</strong>
{{ formatDate(licenseStatus.lastRefreshAttemptAt) }}
@if (licenseStatus.lastRefreshSucceeded === true) {
<span class="badge-ok">OK</span>
}
@if (licenseStatus.lastRefreshSucceeded === false) {
<span class="badge-warn">echec</span>
}
</li>
}
</ul>
<div class="form-row form-row-inline">
<label class="checkbox">
<input
type="checkbox"
[checked]="licenseStatus.betaChannelEnabled"
(change)="toggleBetaChannel(!licenseStatus.betaChannelEnabled)"
[disabled]="licenseStatus.status !== 'VALID' && licenseStatus.status !== 'GRACE'"
>
<span>Activer le canal beta</span>
</label>
</div>
<div class="form-row form-row-actions">
<button type="button" class="btn-secondary" (click)="refreshLicense()" [disabled]="licenseLoading">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ licenseLoading ? 'Verification...' : 'Verifier maintenant' }}</span>
</button>
<button type="button" class="btn-secondary btn-danger" (click)="disconnectPatreon()">
<lucide-icon [img]="Unlink" [size]="14"></lucide-icon>
<span>Deconnecter Patreon</span>
</button>
</div>
<!-- Etat du canal beta -->
@if (licenseStatus.betaChannelEnabled) {
<div class="beta-status">
@if (betaChecking) {
<div class="hint">Verification des images beta...</div>
}
@if (!betaChecking && betaStatus && !betaStatus.enabled) {
<div class="hint">
Indisponible : {{ betaStatus.disabledReason }}
</div>
}
@if (!betaChecking && betaStatus?.enabled) {
<div>
@if (betaStatus?.anyUnknown) {
<div class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
</div>
}
</div>
}
</div>
}
<!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
@if (channelStatus) {
<div class="channel-switch">
<div class="channel-current">
<span class="channel-label">Canal actuel :</span>
<span class="channel-badge"
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
</span>
</div>
<!-- Sidecar dispo : boutons d'action -->
@if (channelStatus.switcherAvailable) {
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
@if (channelStatus.currentChannel === 'stable') {
<button
type="button" class="btn-primary"
[disabled]="switchInFlight"
(click)="requestChannelSwitch('beta')">
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
</button>
}
<!-- On beta -> proposer retour stable -->
@if (channelStatus.currentChannel === 'beta') {
<button
type="button" class="btn-secondary"
[disabled]="switchInFlight"
(click)="requestChannelSwitch('stable')">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
</button>
}
<!-- Switch en cours : on prévient que la page va se rendre injoignable -->
@if (switchInFlight) {
<div class="alert alert-warn">
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
<span>Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.</span>
</div>
}
<!-- Erreur eventuelle remontee par le sidecar -->
@if (switchError && !switchInFlight) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ switchError }}</span>
</div>
}
}
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
@if (!channelStatus.switcherAvailable) {
<div class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Le sidecar de bascule n'est pas installe. Pour beneficier du switch
automatique, recupere le dernier <code>docker-compose.yml</code> du repo
et fais <code>docker compose pull &amp;&amp; docker compose up -d</code> une
fois. Sinon, bascule manuellement en editant <code>IMAGE_NAMESPACE</code>
dans ton <code>.env</code> (<code>igmlcreation/loremind-</code> pour stable,
<code>igmlcreation/loremind-beta-</code> pour beta).
</span>
</div>
}
</div>
}
}
</div>
}
</section>
}

View File

@@ -7,66 +7,72 @@
aria-label="Assistant IA"> aria-label="Assistant IA">
<!-- Poignee de redimensionnement (desactivee en mode wide) --> <!-- Poignee de redimensionnement (desactivee en mode wide) -->
<div @if (!isWide) {
class="resize-handle" <div
*ngIf="!isWide" class="resize-handle"
(mousedown)="onResizeStart($event)" (mousedown)="onResizeStart($event)"
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"
aria-label="Redimensionner le panneau" aria-label="Redimensionner le panneau"
title="Glisser pour redimensionner"></div> title="Glisser pour redimensionner"></div>
}
<!-- Sidebar conversations (mode persistent uniquement) --> <!-- Sidebar conversations (mode persistent uniquement) -->
<section class="conv-sidebar" *ngIf="persistent && sidebarOpen" aria-label="Conversations"> @if (persistent && sidebarOpen) {
<div class="conv-sidebar-header"> <section class="conv-sidebar" aria-label="Conversations">
<span class="conv-sidebar-title">Conversations</span> <div class="conv-sidebar-header">
<button type="button" class="conv-new-btn" (click)="startNewConversation()" [disabled]="isStreaming" title="Nouvelle conversation"> <span class="conv-sidebar-title">Conversations</span>
<lucide-icon [img]="MessageSquarePlus" [size]="16"></lucide-icon> <button type="button" class="conv-new-btn" (click)="startNewConversation()" [disabled]="isStreaming" title="Nouvelle conversation">
</button> <lucide-icon [img]="MessageSquarePlus" [size]="16"></lucide-icon>
</div>
<ul class="conv-list">
<li *ngIf="conversations.length === 0" class="conv-empty">Aucune conversation</li>
<li
*ngFor="let c of conversations"
class="conv-item"
[class.active]="c.id === currentConversationId"
(click)="selectConversation(c)">
<span class="conv-item-title">{{ c.title }}</span>
<button
type="button"
class="conv-item-del"
(click)="deleteConversation(c, $event)"
[disabled]="isStreaming"
title="Supprimer">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button> </button>
</li> </div>
</ul> <ul class="conv-list">
</section> @if (conversations.length === 0) {
<li class="conv-empty">Aucune conversation</li>
}
@for (c of conversations; track c) {
<li
class="conv-item"
[class.active]="c.id === currentConversationId"
(click)="selectConversation(c)">
<span class="conv-item-title">{{ c.title }}</span>
<button
type="button"
class="conv-item-del"
(click)="deleteConversation(c, $event)"
[disabled]="isStreaming"
title="Supprimer">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</li>
}
</ul>
</section>
}
<section class="conv-main"> <section class="conv-main">
<header class="drawer-header"> <header class="drawer-header">
<button @if (persistent) {
*ngIf="persistent" <button
type="button" type="button"
class="sidebar-toggle" class="sidebar-toggle"
(click)="toggleSidebar()" (click)="toggleSidebar()"
[attr.aria-label]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'" [attr.aria-label]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'"
[title]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'"> [title]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'">
<lucide-icon [img]="sidebarOpen ? PanelLeftClose : PanelLeftOpen" [size]="16"></lucide-icon> <lucide-icon [img]="sidebarOpen ? PanelLeftClose : PanelLeftOpen" [size]="16"></lucide-icon>
</button> </button>
}
<div class="header-title-wrap"> <div class="header-title-wrap">
<ng-container *ngIf="persistent && currentConversationId; else defaultTitle"> @if (persistent && currentConversationId) {
<ng-container *ngIf="!editingTitle; else editingTpl"> @if (!editingTitle) {
<h2 class="header-title" [title]="currentTitle">{{ currentTitle || 'Nouvelle conversation' }}</h2> <h2 class="header-title" [title]="currentTitle">{{ currentTitle || 'Nouvelle conversation' }}</h2>
<button type="button" class="rename-btn" (click)="startRenameTitle()" title="Renommer" aria-label="Renommer"> <button type="button" class="rename-btn" (click)="startRenameTitle()" title="Renommer" aria-label="Renommer">
<lucide-icon [img]="Pencil" [size]="12"></lucide-icon> <lucide-icon [img]="Pencil" [size]="12"></lucide-icon>
</button> </button>
</ng-container> } @else {
<ng-template #editingTpl>
<input <input
class="rename-input" class="rename-input"
type="text" type="text"
@@ -75,101 +81,125 @@
(keyup.escape)="cancelRenameTitle()" (keyup.escape)="cancelRenameTitle()"
(blur)="submitRenameTitle()" (blur)="submitRenameTitle()"
autofocus /> autofocus />
</ng-template> }
</ng-container> } @else {
<ng-template #defaultTitle> <h2 class="header-title">Assistant IA</h2>
<h2 class="header-title">Assistant IA</h2> }
</ng-template> </div>
</div>
<button
type="button"
class="wide-toggle-btn"
(click)="toggleWide()"
[attr.aria-label]="isWide ? 'Reduire le panneau' : 'Agrandir le panneau'"
[title]="isWide ? 'Reduire' : 'Agrandir'">
<lucide-icon [img]="isWide ? Minimize2 : Maximize2" [size]="16"></lucide-icon>
</button>
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</header>
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel"
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
<!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
<div class="gauge-bar" *ngIf="usageHasMax">
<div class="gauge-fill" [style.width.%]="usagePercent"></div>
</div>
<div class="gauge-label">
<span class="gauge-text">Contexte : {{ usageTotal }}<ng-container *ngIf="usageHasMax"> / {{ usage.max }}</ng-container> tokens</span>
<span class="gauge-percent" *ngIf="usageHasMax">{{ usagePercent }}%</span>
</div>
</div>
<div #messagesContainer class="messages">
<div class="msg msg-assistant md" *ngIf="messages.length === 0 && !currentAssistantText"
[innerHTML]="welcomeMessage | markdown">
</div>
<ng-container *ngFor="let m of messages">
<div *ngIf="m.role === 'user'" class="msg msg-user">{{ m.content }}</div>
<div *ngIf="m.role === 'assistant'" class="msg msg-assistant md"
[innerHTML]="m.content | markdown"></div>
</ng-container>
<div class="msg msg-assistant msg-streaming md" *ngIf="currentAssistantText">
<span [innerHTML]="currentAssistantText | markdown"></span><span class="caret"></span>
</div>
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite">
<span></span><span></span><span></span>
</div>
<div class="msg msg-error" *ngIf="errorMessage" role="alert">
{{ errorMessage }}
</div>
</div>
<div class="primary-action" *ngIf="primaryAction">
<button
type="button"
class="primary-btn"
(click)="onPrimaryAction()"
[disabled]="isStreaming">
<lucide-icon [img]="Wand2" [size]="14"></lucide-icon>
{{ primaryAction.label }}
</button>
</div>
<div class="quick-suggestions" *ngIf="quickSuggestions.length">
<p class="quick-label">Suggestions rapides :</p>
<div class="quick-list">
<button <button
type="button" type="button"
class="quick-btn" class="wide-toggle-btn"
*ngFor="let s of quickSuggestions" (click)="toggleWide()"
(click)="useQuickSuggestion(s)" [attr.aria-label]="isWide ? 'Reduire le panneau' : 'Agrandir le panneau'"
[disabled]="isStreaming"> [title]="isWide ? 'Reduire' : 'Agrandir'">
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon> <lucide-icon [img]="isWide ? Minimize2 : Maximize2" [size]="16"></lucide-icon>
{{ s }}
</button> </button>
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</header>
@if (usage) {
<div class="context-gauge" [attr.data-level]="usageLevel"
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
<!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
@if (usageHasMax) {
<div class="gauge-bar">
<div class="gauge-fill" [style.width.%]="usagePercent"></div>
</div>
}
<div class="gauge-label">
<span class="gauge-text">Contexte : {{ usageTotal }}@if (usageHasMax) {
/ {{ usage.max }}
} tokens</span>
@if (usageHasMax) {
<span class="gauge-percent">{{ usagePercent }}%</span>
}
</div>
</div>
}
<div #messagesContainer class="messages">
@if (messages.length === 0 && !currentAssistantText) {
<div class="msg msg-assistant md"
[innerHTML]="welcomeMessage | markdown">
</div>
}
@for (m of messages; track m) {
@if (m.role === 'user') {
<div class="msg msg-user">{{ m.content }}</div>
}
@if (m.role === 'assistant') {
<div class="msg msg-assistant md"
[innerHTML]="m.content | markdown"></div>
}
}
@if (currentAssistantText) {
<div class="msg msg-assistant msg-streaming md">
<span [innerHTML]="currentAssistantText | markdown"></span><span class="caret"></span>
</div>
}
@if (isStreaming && !currentAssistantText) {
<div class="typing-indicator" aria-live="polite">
<span></span><span></span><span></span>
</div>
}
@if (errorMessage) {
<div class="msg msg-error" role="alert">
{{ errorMessage }}
</div>
}
</div> </div>
</div>
<form class="input-row" (ngSubmit)="send()"> @if (primaryAction) {
<input <div class="primary-action">
type="text" <button
[(ngModel)]="input" type="button"
name="chatInput" class="primary-btn"
placeholder="Posez une question..." (click)="onPrimaryAction()"
[disabled]="isStreaming" [disabled]="isStreaming">
autocomplete="off" /> <lucide-icon [img]="Wand2" [size]="14"></lucide-icon>
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer"> {{ primaryAction.label }}
<lucide-icon [img]="Send" [size]="16"></lucide-icon> </button>
</button> </div>
</form> }
</section> @if (quickSuggestions.length) {
</aside> <div class="quick-suggestions">
<p class="quick-label">Suggestions rapides :</p>
<div class="quick-list">
@for (s of quickSuggestions; track s) {
<button
type="button"
class="quick-btn"
(click)="useQuickSuggestion(s)"
[disabled]="isStreaming">
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon>
{{ s }}
</button>
}
</div>
</div>
}
<form class="input-row" (ngSubmit)="send()">
<input
type="text"
[(ngModel)]="input"
name="chatInput"
placeholder="Posez une question..."
[disabled]="isStreaming"
autocomplete="off" />
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</form>
</section>
</aside>

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