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

3387
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';
@@ -19,9 +19,8 @@ import { VersionCheckerService } from './services/version-checker.service';
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

@@ -145,7 +145,8 @@
</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) {
<div class="field">
<label>Pages Lore associées</label> <label>Pages Lore associées</label>
<app-lore-link-picker <app-lore-link-picker
[value]="relatedPageIds" [value]="relatedPageIds"
@@ -157,13 +158,16 @@
Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée. Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.
</small> </small>
</div> </div>
}
<div class="field lore-hint" *ngIf="!loreId"> @if (!loreId) {
<div class="field lore-hint">
<small class="field-hint"> <small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne 💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.). pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).
</small> </small>
</div> </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,9 +1,11 @@
<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) {
<lucide-icon [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
}
{{ arc.name }} {{ arc.name }}
</h1> </h1>
<p class="view-subtitle"> <p class="view-subtitle">
@@ -21,103 +23,128 @@
</button> </button>
</div> </div>
</header> </header>
<!-- Illustrations (rendu editorial magazine) --> <!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0"> @if ((arc.illustrationImageIds?.length ?? 0) > 0) {
<section class="view-section">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery> <app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section> </section>
}
<!-- Cartes & plans --> <!-- Cartes & plans -->
<section class="view-section" *ngIf="(arc.mapImageIds?.length ?? 0) > 0"> @if ((arc.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2> <h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery> <app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section> </section>
}
<!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut. <!-- 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. --> Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
<section class="view-section" *ngIf="arc.type === 'HUB'"> @if (arc.type === 'HUB') {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2> <h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
@if (hubQuests.length === 0) {
<p class="view-section-empty" *ngIf="hubQuests.length === 0"> <p class="view-section-empty">
Aucune quête pour ce hub. Créez-en une pour démarrer. Aucune quête pour ce hub. Créez-en une pour démarrer.
</p> </p>
}
<div class="hub-quest-grid" *ngIf="hubQuests.length > 0"> @if (hubQuests.length > 0) {
<div class="hub-quest-grid">
@for (q of hubQuests; track q) {
<button type="button" <button type="button"
class="hub-quest-card" class="hub-quest-card"
*ngFor="let q of hubQuests"
(click)="openQuest(q)"> (click)="openQuest(q)">
<div class="hub-quest-card-head"> <div class="hub-quest-card-head">
<span class="hub-quest-card-icon" *ngIf="q.icon"> @if (q.icon) {
<span class="hub-quest-card-icon">
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon> <lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon>
</span> </span>
}
<span class="hub-quest-card-name">{{ q.name }}</span> <span class="hub-quest-card-name">{{ q.name }}</span>
</div> </div>
@if (q.description?.trim()) {
<p class="hub-quest-card-desc" *ngIf="q.description?.trim()">{{ q.description }}</p> <p class="hub-quest-card-desc">{{ q.description }}</p>
}
<div class="hub-quest-card-locked-hint" *ngIf="(q.prerequisites?.length ?? 0) > 0"> @if ((q.prerequisites?.length ?? 0) > 0) {
<div class="hub-quest-card-locked-hint">
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span> <span>{{ q.prerequisites!.length }} condition(s) de déblocage</span>
<ul class="hub-quest-card-prereq-list"> <ul class="hub-quest-card-prereq-list">
<li *ngFor="let p of q.prerequisites">{{ describePrerequisite(p) }}</li> @for (p of q.prerequisites; track p) {
<li>{{ describePrerequisite(p) }}</li>
}
</ul> </ul>
</div> </div>
}
</button> </button>
}
</div> </div>
}
</section> </section>
}
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2> <h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
<p class="view-section-body" *ngIf="arc.description?.trim(); else emptyDesc">{{ arc.description }}</p> @if (arc.description?.trim()) {
<ng-template #emptyDesc><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"> <div class="view-row">
<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> Thèmes principaux</h2>
<p class="view-section-body" *ngIf="arc.themes?.trim(); else emptyThemes">{{ arc.themes }}</p> @if (arc.themes?.trim()) {
<ng-template #emptyThemes><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ arc.themes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section> </section>
<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> Enjeux globaux</h2>
<p class="view-section-body" *ngIf="arc.stakes?.trim(); else emptyStakes">{{ arc.stakes }}</p> @if (arc.stakes?.trim()) {
<ng-template #emptyStakes><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ arc.stakes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section> </section>
</div> </div>
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2> <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> @if (arc.rewards?.trim()) {
<ng-template #emptyRewards><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"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2> <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> @if (arc.resolution?.trim()) {
<ng-template #emptyResolution><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ arc.resolution }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section> </section>
<!-- Notes MJ (bloc privé rouge discret) --> <!-- Notes MJ (bloc privé rouge discret) -->
<section class="view-section view-section--private" *ngIf="arc.gmNotes?.trim()"> @if (arc.gmNotes?.trim()) {
<section class="view-section view-section--private">
<h2 class="view-section-title"> <h2 class="view-section-title">
<span class="view-section-icon">🔒</span> <span class="view-section-icon">🔒</span>
Notes et planification du MJ Notes et planification du MJ
</h2> </h2>
<p class="view-section-body">{{ arc.gmNotes }}</p> <p class="view-section-body">{{ arc.gmNotes }}</p>
</section> </section>
}
<!-- Pages Lore liées (chips cliquables) --> <!-- Pages Lore liées (chips cliquables) -->
<section class="view-section" *ngIf="loreId && (arc.relatedPageIds?.length ?? 0) > 0"> @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> <h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips"> <div class="view-chips">
@for (relId of arc.relatedPageIds; track relId) {
<a class="view-chip" <a class="view-chip"
*ngFor="let relId of arc.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]"> [routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }} {{ titleOfRelated(relId) }}
</a> </a>
}
</div> </div>
</section> </section>
}
</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, 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

@@ -40,7 +40,9 @@
<label for="campaign-lore">Univers associé</label> <label for="campaign-lore">Univers associé</label>
<select id="campaign-lore" formControlName="loreId"> <select id="campaign-lore" formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option> <option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option> @for (lore of availableLores; track lore) {
<option [value]="lore.id">{{ lore.name }}</option>
}
</select> </select>
<p class="hint"> <p class="hint">
Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore. Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
@@ -50,14 +52,19 @@
<div class="field"> <div class="field">
<label for="campaign-game-system">Système de JDR</label> <label for="campaign-game-system">Système de JDR</label>
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId"> @if (!creatingGameSystem) {
<select id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option> <option value="">— Aucun (campagne générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option> @for (gs of availableGameSystems; track gs) {
<option [value]="gs.id">{{ gs.name }}</option>
}
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option> <option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select> </select>
}
<!-- Mode creation inline : remplace temporairement le select. --> <!-- Mode creation inline : remplace temporairement le select. -->
<div *ngIf="creatingGameSystem" class="inline-create"> @if (creatingGameSystem) {
<div class="inline-create">
<input <input
type="text" type="text"
[(ngModel)]="newGameSystemName" [(ngModel)]="newGameSystemName"
@@ -83,17 +90,22 @@
et le reste depuis la section "Systèmes" plus tard. et le reste depuis la section "Systèmes" plus tard.
</p> </p>
</div> </div>
}
<p *ngIf="!creatingGameSystem" class="hint"> @if (!creatingGameSystem) {
<p class="hint">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...) 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. dans ses suggestions pour respecter les mécaniques du JDR.
</p> </p>
<p *ngIf="!creatingGameSystem" class="hint hint-warning"> }
@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 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 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). (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. Choisissez bien dès le départ si possible.
</p> </p>
}
</div> </div>
<div class="info-box"> <div class="info-box">
@@ -116,4 +128,4 @@
</form> </form>
</div> </div>
</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,27 +1,30 @@
<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="detail-header">
<div class="header-texts"> <div class="header-texts">
<h1>{{ campaign.name }}</h1> <h1>{{ campaign.name }}</h1>
<p class="description">{{ campaign.description }}</p> <p class="description">{{ campaign.description }}</p>
<div class="meta"> <div class="meta">
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span> <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) {
<a
class="badge badge-lore" class="badge badge-lore"
[routerLink]="['/lore', linkedLore.id]" [routerLink]="['/lore', linkedLore.id]"
title="Ouvrir l'univers associé"> title="Ouvrir l'univers associé">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon> <lucide-icon [img]="Globe" [size]="12"></lucide-icon>
{{ linkedLore.name }} {{ linkedLore.name }}
</a> </a>
}
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) --> <!-- 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"> @if (campaign.loreId && !linkedLore) {
<span class="badge badge-lore-missing" title="L'univers associé est introuvable">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon> <lucide-icon [img]="Globe" [size]="12"></lucide-icon>
Univers introuvable Univers introuvable
</span> </span>
}
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
@@ -35,9 +38,10 @@
</button> </button>
</div> </div>
</div> </div>
}
<!-- ============ Header : mode édition inline ============ --> <!-- ============ Header : mode édition inline ============ -->
<div class="detail-header edit-mode" *ngIf="editing"> @if (editing) {
<div class="detail-header edit-mode">
<div class="field"> <div class="field">
<label>Nom</label> <label>Nom</label>
<input type="text" [(ngModel)]="editName" name="editName" required /> <input type="text" [(ngModel)]="editName" name="editName" required />
@@ -50,21 +54,27 @@
<label>Univers associé</label> <label>Univers associé</label>
<select [(ngModel)]="editLoreId" name="editLoreId"> <select [(ngModel)]="editLoreId" name="editLoreId">
<option value="">— Aucun univers (campagne libre) —</option> <option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option> @for (lore of availableLores; track lore) {
<option [value]="lore.id">{{ lore.name }}</option>
}
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Système de JDR</label> <label>Système de JDR</label>
<select *ngIf="!creatingGameSystem" @if (!creatingGameSystem) {
<select
[(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]="gs.id">{{ gs.name }}</option>
}
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option> <option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select> </select>
}
<div *ngIf="creatingGameSystem" class="inline-create"> @if (creatingGameSystem) {
<div class="inline-create">
<input <input
type="text" type="text"
[(ngModel)]="newGameSystemName" [(ngModel)]="newGameSystemName"
@@ -86,6 +96,7 @@
</button> </button>
</div> </div>
</div> </div>
}
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()"> <button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
@@ -96,51 +107,57 @@
</button> </button>
</div> </div>
</div> </div>
}
<section class="detail-section personas-section" *ngIf="!editing"> @if (!editing) {
<section class="detail-section personas-section">
<div class="section-header"> <div class="section-header">
<h2>Personnages</h2> <h2>Personnages</h2>
</div> </div>
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) : <!-- 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 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). --> (donnée de scénario, partagée par toutes les Parties). -->
<!-- Sous-section : Personnages non-joueurs (PNJ) --> <!-- Sous-section : Personnages non-joueurs (PNJ) -->
<div class="persona-subsection"> <div class="persona-subsection">
<div class="subsection-header"> <div class="subsection-header">
<h3> <h3>
<lucide-icon [img]="Drama" [size]="16"></lucide-icon> <lucide-icon [img]="Drama" [size]="16"></lucide-icon>
Personnages non-joueurs Personnages non-joueurs
<span class="count-badge" *ngIf="npcs.length > 0">{{ npcs.length }}</span> @if (npcs.length > 0) {
<span class="count-badge">{{ npcs.length }}</span>
}
</h3> </h3>
<button class="btn-add" (click)="createNpc()"> <button class="btn-add" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PNJ Nouveau PNJ
</button> </button>
</div> </div>
@if (npcs.length > 0) {
<div class="characters-grid" *ngIf="npcs.length > 0"> <div class="characters-grid">
<div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)"> @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> <lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info"> <div class="character-info">
<span class="character-name">{{ npc.name }}</span> <span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span> <span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div> </div>
</div> </div>
}
</div> </div>
}
<div class="empty-state empty-state--compact" *ngIf="npcs.length === 0"> @if (npcs.length === 0) {
<div class="empty-state empty-state--compact">
<p>Aucun PNJ pour le moment.</p> <p>Aucun PNJ pour le moment.</p>
<button class="btn-add-first" (click)="createNpc()"> <button class="btn-add-first" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PNJ Créer votre premier PNJ
</button> </button>
</div> </div>
}
</div> </div>
</section> </section>
}
<section class="detail-section arcs-section" *ngIf="!editing"> @if (!editing) {
<section class="detail-section arcs-section">
<div class="section-header"> <div class="section-header">
<h2>Arcs narratifs</h2> <h2>Arcs narratifs</h2>
<div class="section-header-actions"> <div class="section-header-actions">
@@ -150,16 +167,19 @@
</button> </button>
</div> </div>
</div> </div>
@if (arcs.length > 0) {
<div class="arcs-grid" *ngIf="arcs.length > 0"> <div class="arcs-grid">
<div class="arc-card" *ngFor="let arc of arcs" (click)="openArc(arc)"> @for (arc of arcs; track arc) {
<div class="arc-card" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon> <lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span> <span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span> <span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
</div> </div>
}
</div> </div>
}
<div class="empty-state" *ngIf="arcs.length === 0"> @if (arcs.length === 0) {
<div class="empty-state">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon> <lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p> <p>Aucun arc narratif pour le moment.</p>
<button class="btn-add-first" (click)="createArc()"> <button class="btn-add-first" (click)="createArc()">
@@ -167,10 +187,12 @@
Créer votre premier arc Créer votre premier arc
</button> </button>
</div> </div>
}
</section> </section>
}
<!-- ============ Parties (Playthroughs) ============ --> <!-- ============ Parties (Playthroughs) ============ -->
<section class="detail-section playthroughs-section" *ngIf="!editing"> @if (!editing) {
<section class="detail-section playthroughs-section">
<div class="section-header"> <div class="section-header">
<h2> <h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon> <lucide-icon [img]="Dices" [size]="18"></lucide-icon>
@@ -181,26 +203,31 @@
Nouvelle partie Nouvelle partie
</button> </button>
</div> </div>
@if (playthroughs.length > 0) {
<div class="playthroughs-grid" *ngIf="playthroughs.length > 0"> <div class="playthroughs-grid">
@for (p of playthroughs; track p) {
<div class="playthrough-card" <div class="playthrough-card"
*ngFor="let p of playthroughs"
(click)="openPlaythrough(p)"> (click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon> <lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info"> <div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span> <span class="playthrough-name">{{ p.name }}</span>
<span class="playthrough-meta" *ngIf="p.description">{{ p.description }}</span> @if (p.description) {
<span class="playthrough-meta">{{ p.description }}</span>
}
</div> </div>
</div> </div>
}
</div> </div>
}
<div class="empty-state empty-state--compact" *ngIf="playthroughs.length === 0"> @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> <p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
</div> </div>
}
</section> </section>
}
<!-- Sessions retirées : elles vivent désormais dans une Partie. <!-- Sessions retirées : elles vivent désormais dans une Partie.
Les faits narratifs sont définis dans les conditions de chaque quête Les faits narratifs sont définis dans les conditions de chaque quête
(chapter-edit > « Conditions de déblocage »). --> (chapter-edit > « Conditions de déblocage »). -->
</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 } 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,31 +13,40 @@
</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) {
<section class="upload-area">
<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-primary big" [disabled]="importing" (click)="pdfInput.click()"> <button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="16"></lucide-icon> <lucide-icon [img]="Upload" [size]="16"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }} {{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
</button> </button>
<!-- Progression live --> <!-- Progression live -->
<div class="import-progress" *ngIf="importing"> @if (importing) {
<div class="import-progress">
<p class="import-phase">{{ importPhase }}</p> <p class="import-phase">{{ importPhase }}</p>
<div class="progress-bar" *ngIf="importProgress"> @if (importProgress) {
<div class="progress-bar">
<div class="progress-fill" <div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0"> [style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div> </div>
</div> </div>
<p class="import-counts" *ngIf="importCounts"> }
@if (importCounts) {
<p class="import-counts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s)
</p> </p>
}
</div> </div>
}
<p class="import-error" *ngIf="importError">{{ importError }}</p> @if (importError) {
<p class="import-error">{{ importError }}</p>
}
</section> </section>
}
<!-- Étape 2 : revue de l'arbre éditable --> <!-- Étape 2 : revue de l'arbre éditable -->
<section class="review-area" *ngIf="reviewing"> @if (reviewing) {
<section class="review-area">
<div class="review-header"> <div class="review-header">
<p class="review-summary"> <p class="review-summary">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s), À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
@@ -47,10 +56,10 @@
ils ne seront pas recréés. Modifiez les nouveaux, puis créez. ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
</p> </p>
</div> </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="arc-card">
<div class="node-head arc-head" [class.existing-node]="arc.existing"> <div class="node-head arc-head" [class.existing-node]="arc.existing">
<button type="button" class="btn-collapse" (click)="toggleArc(arc)"> <button type="button" class="btn-collapse" (click)="toggleArc(arc)">
<lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon> <lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
@@ -58,26 +67,33 @@
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></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" <input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" /> [readonly]="arc.existing" placeholder="Nom de l'arc" />
<span class="badge-existing" *ngIf="arc.existing">déjà présent</span> @if (arc.existing) {
<button type="button" class="btn-remove" *ngIf="!arc.existing" (click)="removeArc(ai)" title="Supprimer l'arc"> <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> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
}
</div> </div>
@if (!arc.collapsed) {
<div class="node-body" *ngIf="!arc.collapsed"> <div class="node-body">
<div class="arc-type-toggle" *ngIf="!arc.existing"> @if (!arc.existing) {
<div class="arc-type-toggle">
<span class="toggle-label">Type :</span> <span class="toggle-label">Type :</span>
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'" <button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
(click)="setArcType(arc, 'LINEAR')">Linéaire</button> (click)="setArcType(arc, 'LINEAR')">Linéaire</button>
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'" <button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button> (click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
</div> </div>
}
<textarea class="node-desc" *ngIf="!arc.existing" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai" @if (!arc.existing) {
<textarea class="node-desc" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea> rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
}
<!-- Chapitres --> <!-- Chapitres -->
<div class="chapter-card" *ngFor="let chapter of arc.chapters; let ci = index"> @for (chapter of arc.chapters; track chapter; let ci = $index) {
<div class="chapter-card">
<div class="node-head chapter-head" [class.existing-node]="chapter.existing"> <div class="node-head chapter-head" [class.existing-node]="chapter.existing">
<button type="button" class="btn-collapse" (click)="toggleChapter(chapter)"> <button type="button" class="btn-collapse" (click)="toggleChapter(chapter)">
<lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon> <lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
@@ -85,18 +101,24 @@
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon> <lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci" <input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[readonly]="chapter.existing" placeholder="Nom du chapitre" /> [readonly]="chapter.existing" placeholder="Nom du chapitre" />
<span class="badge-existing" *ngIf="chapter.existing">déjà présent</span> @if (chapter.existing) {
<button type="button" class="btn-remove" *ngIf="!chapter.existing" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre"> <span class="badge-existing">déjà présent</span>
}
@if (!chapter.existing) {
<button type="button" class="btn-remove" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon> <lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button> </button>
}
</div> </div>
@if (!chapter.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 (!chapter.existing) {
<textarea class="node-desc" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea> rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
}
<!-- Scènes --> <!-- Scènes -->
<div class="scene-card" *ngFor="let scene of chapter.scenes; let si = index" @for (scene of chapter.scenes; track scene; let si = $index) {
<div class="scene-card"
[class.existing-node]="scene.existing"> [class.existing-node]="scene.existing">
<div class="scene-row"> <div class="scene-row">
<lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon> <lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon>
@@ -104,35 +126,44 @@
<input type="text" class="node-name" [(ngModel)]="scene.name" <input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing" [name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" /> placeholder="Nom de la scène" />
<input type="text" class="node-desc-inline" *ngIf="!scene.existing" [(ngModel)]="scene.description" @if (!scene.existing) {
<input type="text" class="node-desc-inline" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" /> [name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
}
</div> </div>
<span class="badge-existing" *ngIf="scene.existing">déjà présent</span> @if (scene.existing) {
<button type="button" class="btn-details" *ngIf="!scene.existing" (click)="toggleDetails(scene)" <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"> title="Narration joueurs, notes MJ, pièces">
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon> <lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
Détails<span *ngIf="scene.rooms.length"> · {{ scene.rooms.length }} pièce(s)</span> Détails@if (scene.rooms.length) {
<span> · {{ scene.rooms.length }} pièce(s)</span>
}
</button> </button>
<button type="button" class="btn-remove" *ngIf="!scene.existing" (click)="removeScene(chapter, si)" title="Supprimer la scène"> }
@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> <lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button> </button>
}
</div> </div>
@if (scene.detailsOpen && !scene.existing) {
<div class="scene-details" *ngIf="scene.detailsOpen && !scene.existing"> <div class="scene-details">
<label class="field-label">À lire aux joueurs</label> <label class="field-label">À lire aux joueurs</label>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration" <textarea class="node-desc" [(ngModel)]="scene.playerNarration"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3" [name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea> placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
<label class="field-label">Notes MJ</label> <label class="field-label">Notes MJ</label>
<textarea class="node-desc" [(ngModel)]="scene.gmNotes" <textarea class="node-desc" [(ngModel)]="scene.gmNotes"
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3" [name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Secrets, développement, conséquences (optionnel)"></textarea> placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
<!-- Pièces (donjon) : vide = scène narrative classique --> <!-- Pièces (donjon) : vide = scène narrative classique -->
<span class="field-label">Pièces (lieu explorable)</span> <span class="field-label">Pièces (lieu explorable)</span>
<div class="rooms"> <div class="rooms">
<div class="room-row" *ngFor="let room of scene.rooms; let ri = index"> @for (room of scene.rooms; track room; let ri = $index) {
<div class="room-row">
<input type="text" class="room-name" [(ngModel)]="room.name" <input type="text" class="room-name" [(ngModel)]="room.name"
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" /> [name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
<input type="text" class="room-field" [(ngModel)]="room.description" <input type="text" class="room-field" [(ngModel)]="room.description"
@@ -145,32 +176,36 @@
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon> <lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button> </button>
</div> </div>
}
<button type="button" class="btn-add-inline" (click)="addRoom(scene)"> <button type="button" class="btn-add-inline" (click)="addRoom(scene)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce <lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
</button> </button>
</div> </div>
</div> </div>
}
</div> </div>
}
<button type="button" class="btn-add-inline" (click)="addScene(chapter)"> <button type="button" class="btn-add-inline" (click)="addScene(chapter)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène <lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
</button> </button>
</div> </div>
}
</div> </div>
}
<button type="button" class="btn-add-inline" (click)="addChapter(arc)"> <button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre <lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
</button> </button>
</div> </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,7 +8,8 @@
<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="campaign-card" (click)="navigateToDetail(campaign.id!)">
<div class="card-header"> <div class="card-header">
<span class="status-badge en-cours">En cours</span> <span class="status-badge en-cours">En cours</span>
<span class="card-date">{{ campaign.playerCount }} joueurs</span> <span class="card-date">{{ campaign.playerCount }} joueurs</span>
@@ -20,6 +21,7 @@
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span> <span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
</div> </div>
</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">

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

@@ -129,7 +129,8 @@
</div> </div>
<!-- ===== Pages Lore associées (B2 cross-context) ===== --> <!-- ===== Pages Lore associées (B2 cross-context) ===== -->
<div class="field" *ngIf="loreId"> @if (loreId) {
<div class="field">
<label>Pages Lore associées</label> <label>Pages Lore associées</label>
<app-lore-link-picker <app-lore-link-picker
[value]="relatedPageIds" [value]="relatedPageIds"
@@ -141,13 +142,16 @@
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent. Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
</small> </small>
</div> </div>
}
<div class="field" *ngIf="!loreId"> @if (!loreId) {
<div class="field">
<small class="field-hint"> <small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne 💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier ce chapitre à des pages du Lore. pour pouvoir lier ce chapitre à des pages du Lore.
</small> </small>
</div> </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,7 +34,6 @@ 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,
@@ -42,7 +41,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
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,11 +11,14 @@
</button> </button>
</div> </div>
<div class="graph-empty" *ngIf="scenes.length === 0"> @if (scenes.length === 0) {
<div class="graph-empty">
<p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p> <p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p>
</div> </div>
}
<div class="graph-container" *ngIf="scenes.length > 0"> @if (scenes.length > 0) {
<div class="graph-container">
<svg #svgEl <svg #svgEl
[attr.width]="svgWidth" [attr.height]="svgHeight" [attr.width]="svgWidth" [attr.height]="svgHeight"
class="graph-svg" class="graph-svg"
@@ -28,14 +31,15 @@
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" /> <path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" />
</marker> </marker>
</defs> </defs>
<g class="edges"> <g class="edges">
<g class="edge" *ngFor="let edge of edges"> @for (edge of edges; track edge) {
<g class="edge">
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1" <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) {
<text
[attr.x]="edge.labelX" [attr.x]="edge.labelX"
[attr.y]="edge.labelY" [attr.y]="edge.labelY"
text-anchor="middle" text-anchor="middle"
@@ -44,13 +48,14 @@
(pointerdown)="onLabelPointerDown($event, edge)"> (pointerdown)="onLabelPointerDown($event, edge)">
{{ edge.label }} {{ edge.label }}
</text> </text>
}
</g> </g>
}
</g> </g>
<g class="nodes"> <g class="nodes">
@for (node of nodes; track node) {
<g class="node" <g class="node"
[class.dragging]="draggingId === node.id" [class.dragging]="draggingId === node.id"
*ngFor="let node of nodes"
(pointerdown)="onPointerDown($event, node)"> (pointerdown)="onPointerDown($event, node)">
<title>{{ node.name }}</title> <title>{{ node.name }}</title>
<rect [attr.x]="node.x" [attr.y]="node.y" <rect [attr.x]="node.x" [attr.y]="node.y"
@@ -63,12 +68,13 @@
{{ node.displayName }} {{ node.displayName }}
</text> </text>
</g> </g>
}
</g> </g>
</svg> </svg>
<small class="graph-hint"> <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. 💡 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> </small>
</div> </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,18 +1,22 @@
<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) {
<lucide-icon [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
}
{{ chapter.name }} {{ chapter.name }}
</h1> </h1>
<p class="view-subtitle"> <p class="view-subtitle">
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }} {{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
<span class="cond-badge" *ngIf="(chapter.prerequisites?.length ?? 0) > 0" @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> </p>
</div> </div>
<div class="view-actions"> <div class="view-actions">
@@ -31,24 +35,23 @@
</button> </button>
</div> </div>
</header> </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) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2> <h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
@if ((chapter.prerequisites?.length ?? 0) > 0) {
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
<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) {
<li>{{ describePrerequisite(p) }}</li>
}
</ul> </ul>
<small class="view-section-empty"> <small class="view-section-empty">
Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
« Faits » de la Partie. « Faits » de la Partie.
</small> </small>
</ng-container> } @else {
<ng-template #noPrereqs>
<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>
@@ -56,57 +59,69 @@
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) {
<section class="view-section">
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery> <app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section> </section>
}
<!-- Cartes & plans --> <!-- Cartes & plans -->
<section class="view-section" *ngIf="(chapter.mapImageIds?.length ?? 0) > 0"> @if ((chapter.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2> <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> <app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section> </section>
}
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Synopsis</h2> <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> @if (chapter.description?.trim()) {
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ chapter.description }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section> </section>
<div class="view-row"> <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> Objectifs des joueurs</h2>
<p class="view-section-body" *ngIf="chapter.playerObjectives?.trim(); else emptyObj">{{ chapter.playerObjectives }}</p> @if (chapter.playerObjectives?.trim()) {
<ng-template #emptyObj><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ chapter.playerObjectives }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section> </section>
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Enjeux narratifs</h2> <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> @if (chapter.narrativeStakes?.trim()) {
<ng-template #emptyNs><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ chapter.narrativeStakes }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section> </section>
</div> </div>
@if (chapter.gmNotes?.trim()) {
<section class="view-section view-section--private" *ngIf="chapter.gmNotes?.trim()"> <section class="view-section view-section--private">
<h2 class="view-section-title"> <h2 class="view-section-title">
<span class="view-section-icon">🔒</span> <span class="view-section-icon">🔒</span>
Notes du Maître de Jeu Notes du Maître de Jeu
</h2> </h2>
<p class="view-section-body">{{ chapter.gmNotes }}</p> <p class="view-section-body">{{ chapter.gmNotes }}</p>
</section> </section>
}
<section class="view-section" *ngIf="loreId && (chapter.relatedPageIds?.length ?? 0) > 0"> @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> <h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips"> <div class="view-chips">
@for (relId of chapter.relatedPageIds; track relId) {
<a class="view-chip" <a class="view-chip"
*ngFor="let relId of chapter.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]"> [routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }} {{ titleOfRelated(relId) }}
</a> </a>
}
</div> </div>
</section> </section>
}
</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, 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,8 +10,8 @@
<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>
@if (characterId) {
<button <button
*ngIf="characterId"
type="button" type="button"
class="btn-ai" class="btn-ai"
(click)="toggleChat()" (click)="toggleChat()"
@@ -20,6 +20,7 @@
<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>
@@ -76,22 +77,23 @@
</button> </button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button> <button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span> <span class="spacer"></span>
@if (characterId) {
<button <button
*ngIf="characterId"
type="button" type="button"
class="btn-danger" class="btn-danger"
(click)="deleteCharacter()"> (click)="deleteCharacter()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer Supprimer
</button> </button>
}
</div> </div>
</div> </div>
</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"
@@ -99,4 +101,5 @@
welcomeMessage="Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels." welcomeMessage="Je vois cette fiche de personnage. Demande-moi de proposer stats, backstory, équipement ou objectifs personnels."
[quickSuggestions]="chatQuickSuggestions" [quickSuggestions]="chatQuickSuggestions"
(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 { 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) {
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </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>
@@ -35,7 +37,9 @@
<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,7 +58,8 @@
<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) {
<div class="item-row">
<input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet"> <input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet">
<input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po"> <input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po">
<input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes"> <input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes">
@@ -63,6 +68,9 @@
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
</div> </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,8 +18,11 @@
</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>
}
@for (c of catalogs; track c) {
<button class="icl-item" (click)="open(c)">
<lucide-icon [img]="Package" [size]="16"></lucide-icon> <lucide-icon [img]="Package" [size]="16"></lucide-icon>
<span class="icl-item-name">{{ c.name }}</span> <span class="icl-item-name">{{ c.name }}</span>
<span class="icl-item-count">{{ c.items.length }} objet(s)</span> <span class="icl-item-count">{{ c.items.length }} objet(s)</span>
@@ -27,5 +30,6 @@
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span> </span>
</button> </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,4 +1,5 @@
<div class="ic-page" *ngIf="catalog"> @if (catalog) {
<div class="ic-page">
<div class="ic-toolbar"> <div class="ic-toolbar">
<button class="btn-back" (click)="back()"> <button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
@@ -8,26 +9,34 @@
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> Éditer <lucide-icon [img]="Edit3" [size]="14"></lucide-icon> Éditer
</button> </button>
</div> </div>
<header class="ic-header"> <header class="ic-header">
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> {{ catalog.name }}</h1> <h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> {{ catalog.name }}</h1>
<p class="ic-desc" *ngIf="catalog.description">{{ catalog.description }}</p> @if (catalog.description) {
<p class="ic-desc">{{ catalog.description }}</p>
}
</header> </header>
@if (catalog.items.length === 0) {
<p class="ic-empty" *ngIf="catalog.items.length === 0"> <p class="ic-empty">
Aucun objet — édite le catalogue pour en ajouter. Aucun objet — édite le catalogue pour en ajouter.
</p> </p>
}
<section class="ic-group" *ngFor="let g of groups"> @for (g of groups; track g) {
<h2 *ngIf="g.category !== '—'">{{ g.category }}</h2> <section class="ic-group">
@if (g.category !== '—') {
<h2>{{ g.category }}</h2>
}
<table> <table>
<tbody> <tbody>
<tr *ngFor="let it of g.items"> @for (it of g.items; track it) {
<tr>
<td class="col-name">{{ it.name }}</td> <td class="col-name">{{ it.name }}</td>
<td class="col-price">{{ it.price }}</td> <td class="col-price">{{ it.price }}</td>
<td class="col-desc">{{ it.description }}</td> <td class="col-desc">{{ it.description }}</td>
</tr> </tr>
}
</tbody> </tbody>
</table> </table>
</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 { 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) {
<div class="nac-targets">
<label> <label>
Arc Arc
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()"> <select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
<option *ngFor="let a of arcs" [value]="a.id">{{ a.name }}</option> @for (a of arcs; track a) {
<option [value]="a.id">{{ a.name }}</option>
}
</select> </select>
</label> </label>
<label *ngIf="needsChapter"> @if (needsChapter) {
<label>
Chapitre Chapitre
<select [(ngModel)]="selectedChapterId"> <select [(ngModel)]="selectedChapterId">
<option *ngFor="let c of targetChapters" [value]="c.id">{{ c.name }}</option> @for (c of targetChapters; track c) {
<option [value]="c.id">{{ c.name }}</option>
}
</select> </select>
</label> </label>
}
</div> </div>
}
<p class="nac-warn" *ngIf="needsArc && arcs.length === 0"> @if (needsArc && arcs.length === 0) {
<p class="nac-warn">
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément. Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
</p> </p>
}
<div class="nac-foot"> <div class="nac-foot">
<button class="nac-create" *ngIf="status !== 'created'" (click)="create()" [disabled]="!canCreate"> @if (status !== 'created') {
<button class="nac-create" (click)="create()" [disabled]="!canCreate">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> <lucide-icon [img]="Plus" [size]="13"></lucide-icon>
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }} {{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
</button> </button>
<button class="nac-open" *ngIf="status === 'created'" (click)="openCreated()"> }
@if (status === 'created') {
<button class="nac-open" (click)="openCreated()">
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir <lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon> <lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
</button> </button>
<span class="nac-error" *ngIf="status === 'error'">{{ errorMsg }}</span> }
@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,11 +1,11 @@
<div class="nbd-page" *ngIf="detail"> @if (detail) {
<div class="nbd-page">
<div class="nbd-toolbar"> <div class="nbd-toolbar">
<button class="btn-back" (click)="back()"> <button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
</button> </button>
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()"> <input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
</div> </div>
<div class="nbd-grid"> <div class="nbd-grid">
<!-- Sources --> <!-- Sources -->
<aside class="nbd-sources"> <aside class="nbd-sources">
@@ -17,10 +17,11 @@
<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> </div>
@if (uploadError) {
<p class="nbd-upload-error" *ngIf="uploadError">{{ uploadError }}</p> <p class="nbd-upload-error">{{ uploadError }}</p>
}
<div class="nbd-source" *ngFor="let s of sources"> @for (s of sources; track s) {
<div class="nbd-source">
<lucide-icon <lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)" [img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
[size]="14" [size]="14"
@@ -29,57 +30,74 @@
<div class="nbd-source-info"> <div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div> <div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta"> <div class="nbd-source-meta">
<span *ngIf="s.status === 'READY'">{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span> @if (s.status === 'READY') {
<span *ngIf="s.status === 'INDEXING'">indexation en cours…</span> <span>{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
<span *ngIf="s.status === 'FAILED'">échec de l'indexation</span> }
@if (s.status === 'INDEXING') {
<span>indexation en cours…</span>
}
@if (s.status === 'FAILED') {
<span>échec de l'indexation</span>
}
</div> </div>
</div> </div>
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer"> <button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon> <lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button> </button>
</div> </div>
}
<p class="nbd-empty" *ngIf="sources.length === 0 && !uploading"> @if (sources.length === 0 && !uploading) {
<p class="nbd-empty">
Ajoute un PDF source pour commencer à discuter avec. Ajoute un PDF source pour commencer à discuter avec.
</p> </p>
}
</aside> </aside>
<!-- Chat --> <!-- Chat -->
<section class="nbd-chat"> <section class="nbd-chat">
<div class="nbd-messages"> <div class="nbd-messages">
<p class="nbd-empty" *ngIf="messages.length === 0"> @if (messages.length === 0) {
<p class="nbd-empty">
Pose une question sur ta source, ou demande une adaptation pour ta campagne. Pose une question sur ta source, ou demande une adaptation pour ta campagne.
<span *ngIf="!hasReadySource()"><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span> @if (!hasReadySource()) {
<span><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
}
</p> </p>
<div class="nbd-msg" *ngFor="let m of messages" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'"> }
@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"> <div class="nbd-msg-role">
<lucide-icon *ngIf="m.role === 'assistant'" [img]="Sparkles" [size]="12"></lucide-icon> @if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }} {{ m.role === 'user' ? 'Vous' : 'IA' }}
</div> </div>
@if (m.role === 'assistant') {
<ng-container *ngIf="m.role === 'assistant'; else userContent"> @if (parsedOf(m); as p) {
<ng-container *ngIf="parsedOf(m) as p"> @if (sending && deepProgress && !p.text) {
<div class="nbd-deep-progress" *ngIf="sending && deepProgress && !p.text"> <div class="nbd-deep-progress">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon> <lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }} 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 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 <app-notebook-action-card
*ngFor="let a of p.actions; trackBy: trackAction"
[action]="a" [action]="a"
[campaignId]="campaignId" [campaignId]="campaignId"
[arcs]="arcs" [arcs]="arcs"
[chaptersByArc]="chaptersByArc" [chaptersByArc]="chaptersByArc"
(created)="onActionCreated()"> (created)="onActionCreated()">
</app-notebook-action-card> </app-notebook-action-card>
</ng-container> }
</ng-container> }
<ng-template #userContent> } @else {
<div class="nbd-msg-content">{{ m.content }}</div> <div class="nbd-msg-content">{{ m.content }}</div>
</ng-template> }
</div> </div>
}
</div> </div>
<div class="nbd-input"> <div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2" <textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…" placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
@@ -95,4 +113,5 @@
</div> </div>
</section> </section>
</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, 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

@@ -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>
}
@for (nb of notebooks; track nb) {
<button class="nbl-item" (click)="open(nb)">
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon> <lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
<span class="nbl-item-name">{{ nb.name }}</span> <span class="nbl-item-name">{{ nb.name }}</span>
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer"> <span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span> </span>
</button> </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,8 +10,8 @@
<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>
@if (npcId) {
<button <button
*ngIf="npcId"
type="button" type="button"
class="btn-ai" class="btn-ai"
(click)="toggleChat()" (click)="toggleChat()"
@@ -20,6 +20,7 @@
<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>
@@ -47,7 +48,9 @@
placeholder="Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)" placeholder="Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)"
/> />
<datalist id="npc-folders"> <datalist id="npc-folders">
<option *ngFor="let f of existingFolders" [value]="f"></option> @for (f of existingFolders; track f) {
<option [value]="f"></option>
}
</datalist> </datalist>
</div> </div>
@@ -91,22 +94,23 @@
</button> </button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button> <button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span> <span class="spacer"></span>
@if (npcId) {
<button <button
*ngIf="npcId"
type="button" type="button"
class="btn-danger" class="btn-danger"
(click)="deleteNpc()"> (click)="deleteNpc()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer Supprimer
</button> </button>
}
</div> </div>
</div> </div>
</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"
@@ -114,4 +118,5 @@
welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures." welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures."
[quickSuggestions]="chatQuickSuggestions" [quickSuggestions]="chatQuickSuggestions"
(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 { 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) {
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </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,5 +1,5 @@
<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>
@@ -7,7 +7,9 @@
</button> </button>
<div class="header-info"> <div class="header-info">
<h1>{{ playthrough.name }}</h1> <h1>{{ playthrough.name }}</h1>
<p class="subtitle" *ngIf="playthrough.description">{{ playthrough.description }}</p> @if (playthrough.description) {
<p class="subtitle">{{ playthrough.description }}</p>
}
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-danger" (click)="delete()"> <button type="button" class="btn-danger" (click)="delete()">
@@ -16,30 +18,28 @@
</button> </button>
</div> </div>
</header> </header>
<!-- Bloc action principal : démarrer ou reprendre la session --> <!-- Bloc action principal : démarrer ou reprendre la session -->
<section class="play-action"> <section class="play-action">
@if (!activeOnThis) {
<button type="button" class="btn-primary big" <button type="button" class="btn-primary big"
*ngIf="!activeOnThis"
[disabled]="startingSession" [disabled]="startingSession"
(click)="startSession()"> (click)="startSession()">
<lucide-icon [img]="Play" [size]="16"></lucide-icon> <lucide-icon [img]="Play" [size]="16"></lucide-icon>
Lancer une session Lancer une session
</button> </button>
}
@if (activeOnThis) {
<button type="button" class="btn-primary big" <button type="button" class="btn-primary big"
*ngIf="activeOnThis"
(click)="openSession(activeOnThis)"> (click)="openSession(activeOnThis)">
<lucide-icon [img]="Play" [size]="16"></lucide-icon> <lucide-icon [img]="Play" [size]="16"></lucide-icon>
Reprendre la session en cours Reprendre la session en cours
</button> </button>
}
<button type="button" class="btn-secondary" (click)="openFlags()"> <button type="button" class="btn-secondary" (click)="openFlags()">
<lucide-icon [img]="Flag" [size]="14"></lucide-icon> <lucide-icon [img]="Flag" [size]="14"></lucide-icon>
Faits de la partie Faits de la partie
</button> </button>
</section> </section>
<!-- PJ --> <!-- PJ -->
<section class="block"> <section class="block">
<div class="block-header"> <div class="block-header">
@@ -49,24 +49,35 @@
Nouveau PJ Nouveau PJ
</button> </button>
</div> </div>
<p class="empty" *ngIf="characters.length === 0">Aucun PJ pour cette partie.</p> @if (characters.length === 0) {
<ul class="character-list" *ngIf="characters.length > 0"> <p class="empty">Aucun PJ pour cette partie.</p>
<li *ngFor="let c of characters"> }
@if (characters.length > 0) {
<ul class="character-list">
@for (c of characters; track c) {
<li>
<a [routerLink]="['/campaigns', campaignId, 'playthroughs', playthroughId, 'characters', c.id]">{{ c.name }}</a> <a [routerLink]="['/campaigns', campaignId, 'playthroughs', playthroughId, 'characters', c.id]">{{ c.name }}</a>
</li> </li>
}
</ul> </ul>
}
</section> </section>
<!-- Sessions --> <!-- Sessions -->
<section class="block"> <section class="block">
<h2>Sessions</h2> <h2>Sessions</h2>
<p class="empty" *ngIf="sessions.length === 0">Aucune session encore. Lancez la première !</p> @if (sessions.length === 0) {
<ul class="session-list" *ngIf="sessions.length > 0"> <p class="empty">Aucune session encore. Lancez la première !</p>
<li *ngFor="let s of sessions" (click)="openSession(s)"> }
@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-name">{{ s.name }}</span>
<span class="session-status" [class.active]="s.active">{{ s.active ? 'En cours' : 'Terminée' }}</span> <span class="session-status" [class.active]="s.active">{{ s.active ? 'En cours' : 'Terminée' }}</span>
</li> </li>
}
</ul> </ul>
}
</section> </section>
</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, 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,4 +1,5 @@
<div class="flags-page" *ngIf="playthrough"> @if (playthrough) {
<div class="flags-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>
@@ -12,9 +13,9 @@
</p> </p>
</div> </div>
</header> </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>
@@ -47,7 +49,9 @@
<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,7 +75,8 @@
<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) {
<div class="entry-row">
<input class="c-range" type="number" [(ngModel)]="e.minRoll"> <input class="c-range" type="number" [(ngModel)]="e.minRoll">
<input class="c-range" type="number" [(ngModel)]="e.maxRoll"> <input class="c-range" type="number" [(ngModel)]="e.maxRoll">
<input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat"> <input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat">
@@ -80,6 +85,9 @@
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
</div> </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,4 +1,5 @@
<div class="rt-page" *ngIf="table"> @if (table) {
<div class="rt-page">
<div class="rt-toolbar"> <div class="rt-toolbar">
<button class="btn-back" (click)="back()"> <button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
@@ -10,48 +11,65 @@
Éditer Éditer
</button> </button>
</div> </div>
<header class="rt-header"> <header class="rt-header">
<h1><lucide-icon [img]="Dices" [size]="22"></lucide-icon> {{ table.name }}</h1> <h1><lucide-icon [img]="Dices" [size]="22"></lucide-icon> {{ table.name }}</h1>
<p class="rt-desc" *ngIf="table.description">{{ table.description }}</p> @if (table.description) {
<p class="rt-desc">{{ table.description }}</p>
}
<span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span> <span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span>
</header> </header>
<!-- Zone de jet --> <!-- Zone de jet -->
<section class="rt-roll"> <section class="rt-roll">
<button class="btn-roll" (click)="roll()"> <button class="btn-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="18"></lucide-icon> <lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Lancer {{ table.diceFormula }} Lancer {{ table.diceFormula }}
</button> </button>
<div class="rt-result" *ngIf="lastRoll"> @if (lastRoll) {
<div class="rt-result">
<span class="rt-total">{{ lastRoll.total }}</span> <span class="rt-total">{{ lastRoll.total }}</span>
<span class="rt-rolls" *ngIf="lastRoll.rolls.length > 1">({{ lastRoll.rolls.join(' + ') }})</span> @if (lastRoll.rolls.length > 1) {
<span class="rt-rolls">({{ lastRoll.rolls.join(' + ') }})</span>
}
<span class="rt-arrow"></span> <span class="rt-arrow"></span>
<span class="rt-matched" *ngIf="matched">{{ matched.label }}</span> @if (matched) {
<span class="rt-nomatch" *ngIf="!matched">Aucune entrée pour ce résultat</span> <span class="rt-matched">{{ matched.label }}</span>
}
@if (!matched) {
<span class="rt-nomatch">Aucune entrée pour ce résultat</span>
}
</div> </div>
}
</section> </section>
@if (matched?.detail) {
<div class="rt-detail" *ngIf="matched?.detail">{{ matched?.detail }}</div> <div class="rt-detail">{{ matched?.detail }}</div>
}
<!-- Liste des entrées --> <!-- Liste des entrées -->
<section class="rt-entries"> <section class="rt-entries">
<div class="rt-empty" *ngIf="table.entries.length === 0"> @if (table.entries.length === 0) {
<div class="rt-empty">
Aucune entrée — édite la table pour en ajouter. Aucune entrée — édite la table pour en ajouter.
</div> </div>
<table *ngIf="table.entries.length > 0"> }
@if (table.entries.length > 0) {
<table>
<thead> <thead>
<tr><th class="col-range">Jet</th><th>Résultat</th></tr> <tr><th class="col-range">Jet</th><th>Résultat</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let e of table.entries" [class.matched]="isMatched(e)"> @for (e of table.entries; track e) {
<tr [class.matched]="isMatched(e)">
<td class="col-range">{{ rangeLabel(e) }}</td> <td class="col-range">{{ rangeLabel(e) }}</td>
<td> <td>
<div class="entry-label">{{ e.label }}</div> <div class="entry-label">{{ e.label }}</div>
<div class="entry-detail" *ngIf="e.detail">{{ e.detail }}</div> @if (e.detail) {
<div class="entry-detail">{{ e.detail }}</div>
}
</td> </td>
</tr> </tr>
}
</tbody> </tbody>
</table> </table>
}
</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 { 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">

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

@@ -136,15 +136,19 @@
<!-- 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) {
<div class="branches-hint">
<small class="field-hint"> <small class="field-hint">
💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches. 💡 Il faut au moins une autre scène dans ce chapitre pour créer des branches.
Créez d'abord d'autres scènes, puis revenez ici pour les connecter. Créez d'abord d'autres scènes, puis revenez ici pour les connecter.
</small> </small>
</div> </div>
}
<div class="branches-list" *ngIf="siblingScenes.length > 0"> @if (siblingScenes.length > 0) {
<div class="branch-item" *ngFor="let branch of branches; let i = index; trackBy: trackByIndex"> <div class="branches-list">
@for (branch of branches; track $index; let i = $index) {
<div class="branch-item">
<div class="field"> <div class="field">
<label>Libellé du choix</label> <label>Libellé du choix</label>
<input <input
@@ -153,18 +157,18 @@
(input)="updateBranchLabel(i, $any($event.target).value)" (input)="updateBranchLabel(i, $any($event.target).value)"
placeholder="Ex: Si les joueurs attaquent le garde" /> placeholder="Ex: Si les joueurs attaquent le garde" />
</div> </div>
<div class="field"> <div class="field">
<label>Scène de destination *</label> <label>Scène de destination *</label>
<select <select
(change)="updateBranchTarget(i, $any($event.target).value)"> (change)="updateBranchTarget(i, $any($event.target).value)">
<option value="" [selected]="!branch.targetSceneId">— Choisir une scène —</option> <option value="" [selected]="!branch.targetSceneId">— Choisir une scène —</option>
<option *ngFor="let s of siblingScenes" @for (s of siblingScenes; track s) {
<option
[value]="s.id" [value]="s.id"
[selected]="s.id === branch.targetSceneId">{{ s.name }}</option> [selected]="s.id === branch.targetSceneId">{{ s.name }}</option>
}
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Condition MJ (optionnel)</label> <label>Condition MJ (optionnel)</label>
<input <input
@@ -173,23 +177,22 @@
(input)="updateBranchCondition(i, $any($event.target).value)" (input)="updateBranchCondition(i, $any($event.target).value)"
placeholder="Ex: Jet de Persuasion DD 15 réussi" /> placeholder="Ex: Jet de Persuasion DD 15 réussi" />
</div> </div>
<button type="button" class="btn-remove-branch" (click)="removeBranch(i)" <button type="button" class="btn-remove-branch" (click)="removeBranch(i)"
title="Supprimer cette branche"> title="Supprimer cette branche">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Retirer Retirer
</button> </button>
</div> </div>
}
<button type="button" class="btn-add-branch" (click)="addBranch()"> <button type="button" class="btn-add-branch" (click)="addBranch()">
+ Ajouter une branche + Ajouter une branche
</button> </button>
<small class="field-hint"> <small class="field-hint">
Chaque branche représente une "sortie" possible depuis cette scène selon l'action des joueurs. 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. Les cibles sont limitées aux scènes du même chapitre.
</small> </small>
</div> </div>
}
</app-expandable-section> </app-expandable-section>
<!-- Section : Combat ou rencontre --> <!-- Section : Combat ou rencontre -->
@@ -210,7 +213,8 @@
</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"> <div class="field">
<app-lore-link-picker <app-lore-link-picker
[value]="relatedPageIds" [value]="relatedPageIds"
@@ -223,13 +227,16 @@
</small> </small>
</div> </div>
</app-expandable-section> </app-expandable-section>
}
<div class="field" *ngIf="!loreId"> @if (!loreId) {
<div class="field">
<small class="field-hint"> <small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne 💡 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. pour pouvoir épingler des pages du Lore à cette scène.
</small> </small>
</div> </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,9 +1,11 @@
<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) {
<lucide-icon [img]="resolveCampaignIcon(scene.icon)" [size]="22" class="title-icon"></lucide-icon>
}
{{ scene.name }} {{ scene.name }}
</h1> </h1>
<p class="view-subtitle">Scène</p> <p class="view-subtitle">Scène</p>
@@ -19,129 +21,168 @@
</button> </button>
</div> </div>
</header> </header>
<!-- Illustrations (rendu editorial magazine) --> <!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0"> @if ((scene.illustrationImageIds?.length ?? 0) > 0) {
<section class="view-section">
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery> <app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section> </section>
}
<!-- Cartes & plans --> <!-- Cartes & plans -->
<section class="view-section" *ngIf="(scene.mapImageIds?.length ?? 0) > 0"> @if ((scene.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2> <h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery> <app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section> </section>
}
<!-- Description courte --> <!-- Description courte -->
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📝</span> Description</h2> <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> @if (scene.description?.trim()) {
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template> <p class="view-section-body">{{ scene.description }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section> </section>
<!-- Contexte et ambiance --> <!-- Contexte et ambiance -->
<div class="view-row" *ngIf="scene.location?.trim() || scene.timing?.trim()"> @if (scene.location?.trim() || scene.timing?.trim()) {
<section class="view-section" *ngIf="scene.location?.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> <h2 class="view-section-title"><span class="view-section-icon">📍</span> Lieu</h2>
<p class="view-section-body">{{ scene.location }}</p> <p class="view-section-body">{{ scene.location }}</p>
</section> </section>
<section class="view-section" *ngIf="scene.timing?.trim()"> }
@if (scene.timing?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Moment</h2> <h2 class="view-section-title"><span class="view-section-icon"></span> Moment</h2>
<p class="view-section-body">{{ scene.timing }}</p> <p class="view-section-body">{{ scene.timing }}</p>
</section> </section>
}
</div> </div>
}
<section class="view-section" *ngIf="scene.atmosphere?.trim()"> @if (scene.atmosphere?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🌫️</span> Ambiance et atmosphère</h2> <h2 class="view-section-title"><span class="view-section-icon">🌫️</span> Ambiance et atmosphère</h2>
<p class="view-section-body">{{ scene.atmosphere }}</p> <p class="view-section-body">{{ scene.atmosphere }}</p>
</section> </section>
}
<!-- Narration pour les joueurs --> <!-- Narration pour les joueurs -->
<section class="view-section" *ngIf="scene.playerNarration?.trim()"> @if (scene.playerNarration?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Narration pour les joueurs</h2> <h2 class="view-section-title"><span class="view-section-icon">📖</span> Narration pour les joueurs</h2>
<p class="view-section-body">{{ scene.playerNarration }}</p> <p class="view-section-body">{{ scene.playerNarration }}</p>
</section> </section>
}
<!-- Choix et conséquences --> <!-- Choix et conséquences -->
<section class="view-section" *ngIf="scene.choicesConsequences?.trim()"> @if (scene.choicesConsequences?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🔀</span> Choix et conséquences</h2> <h2 class="view-section-title"><span class="view-section-icon">🔀</span> Choix et conséquences</h2>
<p class="view-section-body">{{ scene.choicesConsequences }}</p> <p class="view-section-body">{{ scene.choicesConsequences }}</p>
</section> </section>
}
<!-- Combat ou rencontre --> <!-- Combat ou rencontre -->
<ng-container *ngIf="scene.combatDifficulty?.trim() || scene.enemies?.trim()"> @if (scene.combatDifficulty?.trim() || scene.enemies?.trim()) {
<section class="view-section" *ngIf="scene.combatDifficulty?.trim()"> @if (scene.combatDifficulty?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2> <h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2>
<p class="view-section-body">{{ scene.combatDifficulty }}</p> <p class="view-section-body">{{ scene.combatDifficulty }}</p>
</section> </section>
<section class="view-section" *ngIf="scene.enemies?.trim()"> }
@if (scene.enemies?.trim()) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2> <h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2>
<p class="view-section-body">{{ scene.enemies }}</p> <p class="view-section-body">{{ scene.enemies }}</p>
</section> </section>
</ng-container> }
}
<!-- Notes et secrets du MJ (privé) --> <!-- Notes et secrets du MJ (privé) -->
<section class="view-section view-section--private" *ngIf="scene.gmSecretNotes?.trim()"> @if (scene.gmSecretNotes?.trim()) {
<section class="view-section view-section--private">
<h2 class="view-section-title"> <h2 class="view-section-title">
<span class="view-section-icon">🔒</span> <span class="view-section-icon">🔒</span>
Notes et secrets du MJ Notes et secrets du MJ
</h2> </h2>
<p class="view-section-body">{{ scene.gmSecretNotes }}</p> <p class="view-section-body">{{ scene.gmSecretNotes }}</p>
</section> </section>
}
<!-- Pages Lore liées --> <!-- Pages Lore liées -->
<section class="view-section" *ngIf="loreId && (scene.relatedPageIds?.length ?? 0) > 0"> @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> <h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips"> <div class="view-chips">
@for (relId of scene.relatedPageIds; track relId) {
<a class="view-chip" <a class="view-chip"
*ngFor="let relId of scene.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]"> [routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }} {{ titleOfRelated(relId) }}
</a> </a>
}
</div> </div>
</section> </section>
}
<!-- Lieu explorable : pièces (mode donjon) --> <!-- Lieu explorable : pièces (mode donjon) -->
<section class="view-section" *ngIf="(scene.rooms?.length ?? 0) > 0"> @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> <h2 class="view-section-title"><span class="view-section-icon">🏰</span> Pièces du lieu</h2>
<div class="rooms-readonly"> <div class="rooms-readonly">
<article class="room-readonly" *ngFor="let r of scene.rooms; let i = index"> @for (r of scene.rooms; track r; let i = $index) {
<article class="room-readonly">
<header class="room-readonly-head"> <header class="room-readonly-head">
<span class="room-readonly-index">#{{ i + 1 }}</span> <span class="room-readonly-index">#{{ i + 1 }}</span>
<span class="room-readonly-name">{{ r.name }}</span> <span class="room-readonly-name">{{ r.name }}</span>
<span class="room-readonly-floor" *ngIf="r.floor !== null && r.floor !== undefined"> @if (r.floor !== null && r.floor !== undefined) {
<span class="room-readonly-floor">
Étage {{ r.floor }} Étage {{ r.floor }}
</span> </span>
}
</header> </header>
<p class="room-readonly-desc" *ngIf="r.description?.trim()">{{ r.description }}</p> @if (r.description?.trim()) {
<p class="room-readonly-desc">{{ r.description }}</p>
}
<div class="room-readonly-grid"> <div class="room-readonly-grid">
<div *ngIf="r.enemies?.trim()"> @if (r.enemies?.trim()) {
<div>
<strong>⚔️ Ennemis</strong> <strong>⚔️ Ennemis</strong>
<p>{{ r.enemies }}</p> <p>{{ r.enemies }}</p>
</div> </div>
<div *ngIf="r.loot?.trim()"> }
@if (r.loot?.trim()) {
<div>
<strong>💰 Loot</strong> <strong>💰 Loot</strong>
<p>{{ r.loot }}</p> <p>{{ r.loot }}</p>
</div> </div>
<div *ngIf="r.traps?.trim()"> }
@if (r.traps?.trim()) {
<div>
<strong>⚠️ Pièges</strong> <strong>⚠️ Pièges</strong>
<p>{{ r.traps }}</p> <p>{{ r.traps }}</p>
</div> </div>
<div *ngIf="r.gmNotes?.trim()" class="room-readonly-private"> }
@if (r.gmNotes?.trim()) {
<div class="room-readonly-private">
<strong>🔒 Notes MJ</strong> <strong>🔒 Notes MJ</strong>
<p>{{ r.gmNotes }}</p> <p>{{ r.gmNotes }}</p>
</div> </div>
}
</div> </div>
<div class="room-readonly-branches" *ngIf="(r.branches?.length ?? 0) > 0"> @if ((r.branches?.length ?? 0) > 0) {
<div class="room-readonly-branches">
<strong>→ Sorties</strong> <strong>→ Sorties</strong>
<ul> <ul>
<li *ngFor="let b of r.branches"> @for (b of r.branches; track b) {
<li>
<em>{{ b.label }}</em> → {{ roomNameById(scene, b.targetRoomId) }} <em>{{ b.label }}</em> → {{ roomNameById(scene, b.targetRoomId) }}
<span class="branch-cond" *ngIf="b.condition?.trim()">(si : {{ b.condition }})</span> @if (b.condition?.trim()) {
<span class="branch-cond">(si : {{ b.condition }})</span>
}
</li> </li>
}
</ul> </ul>
</div> </div>
}
</article> </article>
}
</div> </div>
</section> </section>
}
</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, 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

@@ -44,28 +44,40 @@
<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) {
<div class="import-progress">
<p class="import-phase">{{ importPhase }}</p> <p class="import-phase">{{ importPhase }}</p>
<div class="progress-bar" *ngIf="importProgress"> @if (importProgress) {
<div class="progress-bar">
<div class="progress-fill" <div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0"> [style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div> </div>
</div> </div>
<p class="import-found" *ngIf="importFound.length"> }
@if (importFound.length) {
<p class="import-found">
Sections trouvées : {{ importFound.join(' · ') }} Sections trouvées : {{ importFound.join(' · ') }}
</p> </p>
}
</div> </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>
@@ -81,28 +93,30 @@
<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> </div>
}
<div *ngIf="sections.length === 0" class="empty-hint"> @if (sections.length === 0) {
<div class="empty-hint">
Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous. Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous.
</div> </div>
}
</div> </div>
<div class="add-row"> <div class="add-row">
<span class="add-label">Ajouter une section :</span> <span class="add-label">Ajouter une section :</span>
@for (name of suggestedSections; track name) {
<button <button
type="button" type="button"
*ngFor="let name of suggestedSections"
class="chip" class="chip"
[class.disabled]="isSectionUsed(name)" [class.disabled]="isSectionUsed(name)"
(click)="addSuggested(name)" (click)="addSuggested(name)"
@@ -110,6 +124,7 @@
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> <lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ name }} {{ name }}
</button> </button>
}
<button type="button" class="chip chip-custom" (click)="addBlank()"> <button type="button" class="chip chip-custom" (click)="addBlank()">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> <lucide-icon [img]="Plus" [size]="12"></lucide-icon>
Autre… Autre…
@@ -153,4 +168,4 @@
</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,7 +8,8 @@
<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="gs-card" (click)="edit(gs.id!)">
<div class="card-header"> <div class="card-header">
<h2>{{ gs.name }}</h2> <h2>{{ gs.name }}</h2>
<div class="card-actions"> <div class="card-actions">
@@ -19,10 +20,15 @@
</div> </div>
<p class="card-description">{{ gs.description || '(Pas de description)' }}</p> <p class="card-description">{{ gs.description || '(Pas de description)' }}</p>
<div class="card-footer"> <div class="card-footer">
<span *ngIf="gs.author" class="author">par {{ gs.author }}</span> @if (gs.author) {
<span *ngIf="gs.isPublic" class="badge-public">public</span> <span class="author">par {{ gs.author }}</span>
}
@if (gs.isPublic) {
<span class="badge-public">public</span>
}
</div> </div>
</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,20 +1,21 @@
<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) {
<button type="button" class="crumb" (click)="navigateToLoreRoot()">
{{ lore.name }} {{ lore.name }}
</button> </button>
<ng-container *ngFor="let ancestor of ancestors"> }
@for (ancestor of ancestors; track ancestor) {
<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!)"> <button type="button" class="crumb" (click)="navigateToSubfolder(ancestor.id!)">
{{ ancestor.name }} {{ ancestor.name }}
</button> </button>
</ng-container> }
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon> <lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon>
<span class="crumb current">{{ node.name }}</span> <span class="crumb current">{{ node.name }}</span>
</nav> </nav>
<!-- Header : icône + nom + actions --> <!-- Header : icône + nom + actions -->
<div class="detail-header"> <div class="detail-header">
<div class="header-texts"> <div class="header-texts">
@@ -37,7 +38,6 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Sous-dossiers --> <!-- Sous-dossiers -->
<section class="detail-section"> <section class="detail-section">
<div class="section-header"> <div class="section-header">
@@ -47,19 +47,22 @@
Nouveau sous-dossier Nouveau sous-dossier
</button> </button>
</div> </div>
@if (subfolders.length > 0) {
<div class="items-grid" *ngIf="subfolders.length > 0"> <div class="items-grid">
<div class="node-card" *ngFor="let sub of subfolders" (click)="navigateToSubfolder(sub.id!)"> @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> <lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ sub.name }}</span> <span class="node-name">{{ sub.name }}</span>
</div> </div>
}
</div> </div>
}
<div class="empty-state" *ngIf="subfolders.length === 0"> @if (subfolders.length === 0) {
<div class="empty-state">
<p>Aucun sous-dossier.</p> <p>Aucun sous-dossier.</p>
</div> </div>
}
</section> </section>
<!-- Pages --> <!-- Pages -->
<section class="detail-section"> <section class="detail-section">
<div class="section-header"> <div class="section-header">
@@ -69,17 +72,21 @@
Nouvelle page Nouvelle page
</button> </button>
</div> </div>
@if (pages.length > 0) {
<div class="items-grid" *ngIf="pages.length > 0"> <div class="items-grid">
<div class="node-card" *ngFor="let page of pages" (click)="navigateToPage(page.id!)"> @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> <lucide-icon [img]="FileText" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ page.title }}</span> <span class="node-name">{{ page.title }}</span>
</div> </div>
}
</div> </div>
}
<div class="empty-state" *ngIf="pages.length === 0"> @if (pages.length === 0) {
<div class="empty-state">
<p>Aucune page dans ce dossier.</p> <p>Aucune page dans ce dossier.</p>
</div> </div>
}
</section> </section>
</div>
</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,7 +1,8 @@
<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="detail-header">
<div class="header-texts"> <div class="header-texts">
<h1>{{ lore.name }}</h1> <h1>{{ lore.name }}</h1>
<p class="description">{{ lore.description }}</p> <p class="description">{{ lore.description }}</p>
@@ -17,9 +18,10 @@
</button> </button>
</div> </div>
</div> </div>
}
<!-- ============ Header : mode édition inline ============ --> <!-- ============ Header : mode édition inline ============ -->
<div class="detail-header edit-mode" *ngIf="editing"> @if (editing) {
<div class="detail-header edit-mode">
<div class="field"> <div class="field">
<label for="lore-detail-edit-name">Nom</label> <label for="lore-detail-edit-name">Nom</label>
<input id="lore-detail-edit-name" type="text" [(ngModel)]="editName" name="editName" required /> <input id="lore-detail-edit-name" type="text" [(ngModel)]="editName" name="editName" required />
@@ -37,9 +39,10 @@
</button> </button>
</div> </div>
</div> </div>
}
<!-- ============ Grille des dossiers racine ============ --> <!-- ============ Grille des dossiers racine ============ -->
<section class="detail-section nodes-section" *ngIf="!editing"> @if (!editing) {
<section class="detail-section nodes-section">
<div class="section-header"> <div class="section-header">
<h2>Dossiers</h2> <h2>Dossiers</h2>
<button class="btn-add" (click)="navigateToCreateNode()"> <button class="btn-add" (click)="navigateToCreateNode()">
@@ -47,17 +50,20 @@
Nouveau dossier Nouveau dossier
</button> </button>
</div> </div>
<!-- rootNodes : uniquement les dossiers racine (pas les sous-dossiers, <!-- rootNodes : uniquement les dossiers racine (pas les sous-dossiers,
qui sont visibles dans l'arbre de la sidebar). --> qui sont visibles dans l'arbre de la sidebar). -->
<div class="nodes-grid" *ngIf="rootNodes.length > 0"> @if (rootNodes.length > 0) {
<div class="node-card" *ngFor="let node of rootNodes" (click)="navigateToFolder(node.id!)"> <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> <lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ node.name }}</span> <span class="node-name">{{ node.name }}</span>
</div> </div>
}
</div> </div>
}
<div class="empty-state" *ngIf="rootNodes.length === 0"> @if (rootNodes.length === 0) {
<div class="empty-state">
<lucide-icon [img]="Folder" [size]="40" class="empty-icon"></lucide-icon> <lucide-icon [img]="Folder" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun dossier pour le moment.</p> <p>Aucun dossier pour le moment.</p>
<button class="btn-add-first" (click)="navigateToCreateNode()"> <button class="btn-add-first" (click)="navigateToCreateNode()">
@@ -65,6 +71,8 @@
Créer votre premier dossier Créer votre premier dossier
</button> </button>
</div> </div>
}
</section> </section>
}
</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 { 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

@@ -21,7 +21,9 @@
<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">
@for (option of iconOptions; track option) {
<button <button
type="button" type="button"
class="icon-btn" class="icon-btn"
*ngFor="let option of iconOptions"
[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,5 +1,5 @@
<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>
@@ -18,37 +18,35 @@
</button> </button>
</div> </div>
</header> </header>
<form [formGroup]="form" class="edit-form"> <form [formGroup]="form" class="edit-form">
<div class="field"> <div class="field">
<label>Nom du dossier *</label> <label>Nom du dossier *</label>
<input type="text" formControlName="name" /> <input type="text" formControlName="name" />
</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">Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)</p> <p class="hint">Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)</p>
</div> </div>
<div class="field"> <div class="field">
<label>Icône</label> <label>Icône</label>
<div class="icon-grid"> <div class="icon-grid">
@for (option of iconOptions; track option) {
<button <button
type="button" type="button"
class="icon-btn" class="icon-btn"
*ngFor="let option of iconOptions"
[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>
</form> </form>
</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 { 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,7 +8,8 @@
<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="lore-card" (click)="navigateToDetail(lore.id!)">
<div class="card-header"> <div class="card-header">
<lucide-icon [img]="Folder" [size]="20" class="card-icon"></lucide-icon> <lucide-icon [img]="Folder" [size]="20" class="card-icon"></lucide-icon>
<span class="card-date">Il y a 2h</span> <span class="card-date">Il y a 2h</span>
@@ -20,6 +21,7 @@
<span>🌳 {{ lore.nodeCount || 0 }} dossiers</span> <span>🌳 {{ lore.nodeCount || 0 }} dossiers</span>
</div> </div>
</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,11 +17,12 @@
<div class="field"> <div class="field">
<label>Template *</label> <label>Template *</label>
<div class="templates-grid" *ngIf="templates.length; else emptyTemplates"> @if (templates.length) {
<div class="templates-grid">
@for (t of templates; track t) {
<button <button
type="button" type="button"
class="template-card" class="template-card"
*ngFor="let t of templates"
[class.selected]="selectedTemplateId === t.id" [class.selected]="selectedTemplateId === t.id"
(click)="selectTemplate(t)"> (click)="selectTemplate(t)">
<div class="template-card-head"> <div class="template-card-head">
@@ -30,7 +31,7 @@
</div> </div>
<p class="template-description">{{ t.description || '—' }}</p> <p class="template-description">{{ t.description || '—' }}</p>
</button> </button>
}
<!-- Carte "+" : sauvegarde le brouillon et part creer un nouveau template ; <!-- Carte "+" : sauvegarde le brouillon et part creer un nouveau template ;
template-create renverra ici via le mecanisme returnTo. --> template-create renverra ici via le mecanisme returnTo. -->
<a <a
@@ -48,33 +49,34 @@
</p> </p>
</a> </a>
</div> </div>
} @else {
<ng-template #emptyTemplates>
<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,7 +86,9 @@
</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">

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,7 +1,6 @@
<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>
@@ -23,34 +22,34 @@
</button> </button>
</div> </div>
</header> </header>
@if (aiError) {
<div *ngIf="aiError" class="ai-error-banner" role="alert"> <div 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> </div>
}
<form class="edit-form"> <form class="edit-form">
<!-- Identité ----------------------------------------------------- --> <!-- Identité ----------------------------------------------------- -->
<div class="field"> <div class="field">
<label>Nom</label> <label>Nom</label>
<input type="text" [(ngModel)]="title" name="title" required /> <input type="text" [(ngModel)]="title" name="title" required />
</div> </div>
<div class="field"> <div class="field">
<label>Dossier</label> <label>Dossier</label>
<select [(ngModel)]="nodeId" name="nodeId"> <select [(ngModel)]="nodeId" name="nodeId">
<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">Déplacez cette page dans un autre dossier</p> <p class="hint">Déplacez cette page dans un autre dossier</p>
</div> </div>
<!-- Champs dynamiques du template -------------------------------- --> <!-- Champs dynamiques du template -------------------------------- -->
<ng-container *ngIf="template?.fields?.length"> @if (template?.fields?.length) {
<h2 class="section-title">Champs</h2> <h2 class="section-title">Champs</h2>
<ng-container *ngFor="let field of template!.fields"> @for (field of template!.fields; track field) {
<!-- Champ TEXT : textarea editable --> <!-- Champ TEXT : textarea editable -->
<div class="field" *ngIf="field.type === 'TEXT'"> @if (field.type === 'TEXT') {
<div class="field">
<label>{{ field.name }}</label> <label>{{ field.name }}</label>
<textarea <textarea
[(ngModel)]="values[field.name]" [(ngModel)]="values[field.name]"
@@ -59,8 +58,10 @@
[placeholder]="'Valeur pour ' + field.name + '...'"> [placeholder]="'Valeur pour ' + field.name + '...'">
</textarea> </textarea>
</div> </div>
}
<!-- Champ IMAGE : galerie editable. --> <!-- Champ IMAGE : galerie editable. -->
<div class="field" *ngIf="field.type === 'IMAGE'"> @if (field.type === 'IMAGE') {
<div class="field">
<label>{{ field.name }}</label> <label>{{ field.name }}</label>
<app-image-gallery <app-image-gallery
[imageIds]="imageValues[field.name] || []" [imageIds]="imageValues[field.name] || []"
@@ -69,9 +70,9 @@
(imageIdsChange)="imageValues[field.name] = $event"> (imageIdsChange)="imageValues[field.name] = $event">
</app-image-gallery> </app-image-gallery>
</div> </div>
</ng-container> }
</ng-container> }
}
<!-- Tags --------------------------------------------------------- --> <!-- Tags --------------------------------------------------------- -->
<h2 class="section-title">Tags</h2> <h2 class="section-title">Tags</h2>
<div class="field"> <div class="field">
@@ -82,7 +83,6 @@
</app-chips-input> </app-chips-input>
<p class="hint">Mots-clés libres pour classer et retrouver cette page</p> <p class="hint">Mots-clés libres pour classer et retrouver cette page</p>
</div> </div>
<!-- Liens vers d'autres pages ----------------------------------- --> <!-- Liens vers d'autres pages ----------------------------------- -->
<h2 class="section-title">Pages liées</h2> <h2 class="section-title">Pages liées</h2>
<div class="field"> <div class="field">
@@ -95,7 +95,6 @@
</app-lore-link-picker> </app-lore-link-picker>
<p class="hint">Cliquez sur un lien pour ouvrir la page associée</p> <p class="hint">Cliquez sur un lien pour ouvrir la page associée</p>
</div> </div>
<!-- Notes privées ----------------------------------------------- --> <!-- Notes privées ----------------------------------------------- -->
<h2 class="section-title">Notes privées</h2> <h2 class="section-title">Notes privées</h2>
<div class="field"> <div class="field">
@@ -107,10 +106,9 @@
</textarea> </textarea>
<p class="hint">Visibles uniquement par vous. Utiles pour préparer vos sessions.</p> <p class="hint">Visibles uniquement par vous. Utiles pour préparer vos sessions.</p>
</div> </div>
</form> </form>
</div>
</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,7 +1,6 @@
<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>
@@ -18,55 +17,65 @@
</button> </button>
</div> </div>
</header> </header>
<!-- Champs dynamiques du template (seuls les champs TEXT sont rendus ici ; <!-- Champs dynamiques du template (seuls les champs TEXT sont rendus ici ;
le support complet des champs IMAGE arrive a l'etape 5). --> le support complet des champs IMAGE arrive a l'etape 5). -->
<ng-container *ngIf="template?.fields?.length"> @if (template?.fields?.length) {
<ng-container *ngFor="let field of template!.fields"> @for (field of template!.fields; track field) {
<section class="view-section" *ngIf="field.type === 'TEXT'"> @if (field.type === 'TEXT') {
<section class="view-section">
<h2 class="view-section-title">{{ field.name }}</h2> <h2 class="view-section-title">{{ field.name }}</h2>
<p class="view-section-body" *ngIf="valueOf(field.name); else emptyField">{{ valueOf(field.name) }}</p> @if (valueOf(field.name)) {
<ng-template #emptyField> <p class="view-section-body">{{ valueOf(field.name) }}</p>
} @else {
<p class="view-section-empty">Non renseigné</p> <p class="view-section-empty">Non renseigné</p>
</ng-template> }
</section> </section>
<section class="view-section" *ngIf="field.type === 'IMAGE'"> }
@if (field.type === 'IMAGE') {
<section class="view-section">
<h2 class="view-section-title">{{ field.name }}</h2> <h2 class="view-section-title">{{ field.name }}</h2>
<app-image-gallery <app-image-gallery
[imageIds]="imageIdsOf(field.name)" [imageIds]="imageIdsOf(field.name)"
[layout]="field.layout ?? 'GALLERY'"> [layout]="field.layout ?? 'GALLERY'">
</app-image-gallery> </app-image-gallery>
</section> </section>
</ng-container> }
</ng-container> }
}
<!-- Tags --> <!-- Tags -->
<section class="view-section" *ngIf="(page.tags?.length ?? 0) > 0"> @if ((page.tags?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title">Tags</h2> <h2 class="view-section-title">Tags</h2>
<div class="view-chips"> <div class="view-chips">
<span class="view-chip view-chip--tag" *ngFor="let tag of page.tags">{{ tag }}</span> @for (tag of page.tags; track tag) {
<span class="view-chip view-chip--tag">{{ tag }}</span>
}
</div> </div>
</section> </section>
}
<!-- Pages liées --> <!-- Pages liées -->
<section class="view-section" *ngIf="(page.relatedPageIds?.length ?? 0) > 0"> @if ((page.relatedPageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title">Pages liées</h2> <h2 class="view-section-title">Pages liées</h2>
<div class="view-chips"> <div class="view-chips">
@for (relId of page.relatedPageIds; track relId) {
<a class="view-chip" <a class="view-chip"
*ngFor="let relId of page.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]"> [routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }} {{ titleOfRelated(relId) }}
</a> </a>
}
</div> </div>
</section> </section>
}
<!-- Notes privées MJ --> <!-- Notes privées MJ -->
<section class="view-section view-section--private" *ngIf="page.notes?.trim()"> @if (page.notes?.trim()) {
<section class="view-section view-section--private">
<h2 class="view-section-title"> <h2 class="view-section-title">
<span class="view-section-icon">🔒</span> <span class="view-section-icon">🔒</span>
Notes privées Notes privées
</h2> </h2>
<p class="view-section-body">{{ page.notes }}</p> <p class="view-section-body">{{ page.notes }}</p>
</section> </section>
}
</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, 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,7 +50,8 @@
<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) {
<li class="field-row">
<div class="reorder-stack"> <div class="reorder-stack">
<button type="button" class="btn-icon btn-reorder" <button type="button" class="btn-icon btn-reorder"
(click)="moveField(i, -1)" (click)="moveField(i, -1)"
@@ -75,7 +77,8 @@
[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 }"
@@ -86,10 +89,12 @@
<option value="MASONRY">Mosaique</option> <option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option> <option value="CAROUSEL">Carrousel</option>
</select> </select>
}
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer"> <button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
</li> </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,5 +1,5 @@
<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>
@@ -10,40 +10,34 @@
<button type="button" class="btn-primary" (click)="save()" [disabled]="form.invalid">Sauvegarder</button> <button type="button" class="btn-primary" (click)="save()" [disabled]="form.invalid">Sauvegarder</button>
</div> </div>
</header> </header>
<form [formGroup]="form" class="template-form"> <form [formGroup]="form" class="template-form">
<!-- Colonne gauche ---------------------------------------------- --> <!-- Colonne gauche ---------------------------------------------- -->
<div class="col-left"> <div class="col-left">
<div class="field"> <div class="field">
<label for="template-edit-name">Nom</label> <label for="template-edit-name">Nom</label>
<input id="template-edit-name" type="text" formControlName="name" /> <input id="template-edit-name" type="text" formControlName="name" />
</div> </div>
<div class="field"> <div class="field">
<label for="template-edit-default-node">Dossier par défaut</label> <label for="template-edit-default-node">Dossier par défaut</label>
<select id="template-edit-default-node" formControlName="defaultNodeId"> <select id="template-edit-default-node" formControlName="defaultNodeId">
<option value="">-- Aucun --</option> <option value="">-- Aucun --</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 par défaut</p> <p class="hint">Les pages créées avec ce template seront placées dans ce dossier par défaut</p>
</div> </div>
<div class="field"> <div class="field">
<label for="template-edit-description">Description</label> <label for="template-edit-description">Description</label>
<textarea id="template-edit-description" formControlName="description" rows="6"></textarea> <textarea id="template-edit-description" formControlName="description" rows="6"></textarea>
</div> </div>
</div> </div>
<!-- Colonne droite --------------------------------------------- --> <!-- Colonne droite --------------------------------------------- -->
<div class="col-right"> <div class="col-right">
<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) {
<li class="field-row">
<div class="reorder-stack"> <div class="reorder-stack">
<button type="button" class="btn-icon-ghost btn-reorder" <button type="button" class="btn-icon-ghost btn-reorder"
(click)="moveField(i, -1)" (click)="moveField(i, -1)"
@@ -71,7 +65,8 @@
[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 }"
@@ -82,12 +77,13 @@
<option value="MASONRY">Mosaique</option> <option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option> <option value="CAROUSEL">Carrousel</option>
</select> </select>
}
<button type="button" class="btn-icon-danger" (click)="removeField(i)" aria-label="Supprimer"> <button type="button" class="btn-icon-danger" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
</li> </li>
}
</ul> </ul>
<div class="field-row add-row"> <div class="field-row add-row">
<input <input
type="text" type="text"
@@ -107,11 +103,8 @@
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button> </button>
</div> </div>
<p class="hint">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p> <p class="hint">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p>
</div> </div>
</form> </form>
</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 { 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,17 +2,21 @@
<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) {
<div class="welcome-hint">
<lucide-icon [img]="Sparkles" [size]="18"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="18"></lucide-icon>
<p>Pose une question à l'IA pendant la partie.</p> <p>Pose une question à l'IA pendant la partie.</p>
<p class="welcome-sub"> <p class="welcome-sub">
Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal. Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal.
</p> </p>
</div> </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" [class.msg--user]="m.role === 'user'" [class.msg--assistant]="m.role === 'assistant'">
<div class="msg-content">{{ m.content }}</div> <div class="msg-content">{{ m.content }}</div>
<button *ngIf="m.role === 'assistant'" @if (m.role === 'assistant') {
<button
type="button" type="button"
class="msg-action" class="msg-action"
[disabled]="!canSaveToJournal" [disabled]="!canSaveToJournal"
@@ -21,14 +25,20 @@
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon> <lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
Au journal Au journal
</button> </button>
}
</div> </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 msg--assistant msg--streaming">
<div class="msg-content">{{ currentAssistantText }}<span class="cursor"></span></div> <div class="msg-content">{{ currentAssistantText }}<span class="cursor"></span></div>
</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">
@@ -50,7 +60,8 @@
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon> <lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button> </button>
<button *ngIf="!isStreaming" @if (!isStreaming) {
<button
type="button" type="button"
class="btn-primary btn-send" class="btn-primary btn-send"
[disabled]="!input.trim()" [disabled]="!input.trim()"
@@ -58,14 +69,17 @@
<lucide-icon [img]="Send" [size]="14"></lucide-icon> <lucide-icon [img]="Send" [size]="14"></lucide-icon>
Envoyer Envoyer
</button> </button>
}
<button *ngIf="isStreaming" @if (isStreaming) {
<button
type="button" type="button"
class="btn-secondary btn-send" class="btn-secondary btn-send"
(click)="cancelStream()"> (click)="cancelStream()">
<lucide-icon [img]="Square" [size]="14"></lucide-icon> <lucide-icon [img]="Square" [size]="14"></lucide-icon>
Stop Stop
</button> </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,13 +1,13 @@
<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">
<div class="title-row" *ngIf="!editingName"> @if (!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 }}
@@ -16,8 +16,9 @@
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> <lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
</button> </button>
</div> </div>
}
<div class="title-row edit-mode" *ngIf="editingName"> @if (editingName) {
<div class="title-row edit-mode">
<input type="text" <input type="text"
[(ngModel)]="editName" [(ngModel)]="editName"
name="editName" name="editName"
@@ -31,40 +32,42 @@
Annuler Annuler
</button> </button>
</div> </div>
}
<div class="meta"> <div class="meta">
<span class="badge" [class.badge-active]="session.active"> <span class="badge" [class.badge-active]="session.active">
{{ session.active ? 'En cours' : 'Terminée' }} {{ session.active ? 'En cours' : 'Terminée' }}
</span> </span>
<span class="badge badge-muted">Démarrée le {{ session.startedAt | date:'dd/MM/yyyy HH:mm' }}</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"> @if (session.endedAt) {
<span class="badge badge-muted">
Terminée le {{ session.endedAt | date:'dd/MM/yyyy HH:mm' }} Terminée le {{ session.endedAt | date:'dd/MM/yyyy HH:mm' }}
</span> </span>
}
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button *ngIf="session.active" type="button" class="btn-secondary" (click)="endSession()"> @if (session.active) {
<button type="button" class="btn-secondary" (click)="endSession()">
<lucide-icon [img]="Square" [size]="14"></lucide-icon> <lucide-icon [img]="Square" [size]="14"></lucide-icon>
Terminer la session Terminer la session
</button> </button>
}
<button type="button" class="btn-danger" (click)="deleteSession()"> <button type="button" class="btn-danger" (click)="deleteSession()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer Supprimer
</button> </button>
</div> </div>
</div> </div>
<!-- ============ Mode jeu : 2 colonnes (journal + panneau référence) ============ --> <!-- ============ Mode jeu : 2 colonnes (journal + panneau référence) ============ -->
<div class="play-grid"> <div class="play-grid">
<!-- Colonne gauche : journal --> <!-- Colonne gauche : journal -->
<div class="play-main"> <div class="play-main">
<!-- Ajouter une entrée --> <!-- Ajouter une entrée -->
<section class="detail-section add-entry-section" *ngIf="session.active"> @if (session.active) {
<section class="detail-section add-entry-section">
<div class="type-selector"> <div class="type-selector">
<button *ngFor="let type of entryTypes" @for (type of entryTypes; track type) {
<button
type="button" type="button"
class="type-chip" class="type-chip"
[class.type-chip--active]="newEntryType === type" [class.type-chip--active]="newEntryType === type"
@@ -73,15 +76,14 @@
<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> </div>
<textarea class="entry-input" <textarea class="entry-input"
[(ngModel)]="newEntryContent" [(ngModel)]="newEntryContent"
name="newEntryContent" name="newEntryContent"
rows="3" rows="3"
[placeholder]="'Ajouter une ' + entryTypeMeta[newEntryType].label.toLowerCase() + ''" [placeholder]="'Ajouter une ' + entryTypeMeta[newEntryType].label.toLowerCase() + ''"
(keydown.control.enter)="submitNewEntry()"></textarea> (keydown.control.enter)="submitNewEntry()"></textarea>
<div class="entry-input-footer"> <div class="entry-input-footer">
<span class="hint">Ctrl + Entrée pour ajouter</span> <span class="hint">Ctrl + Entrée pour ajouter</span>
<button type="button" <button type="button"
@@ -93,30 +95,31 @@
</button> </button>
</div> </div>
</section> </section>
}
<!-- Timeline --> <!-- Timeline -->
<section class="detail-section timeline-section"> <section class="detail-section timeline-section">
<h2>Journal de session</h2> <h2>Journal de session</h2>
@if (entries.length === 0) {
<div class="empty-state" *ngIf="entries.length === 0"> <div class="empty-state">
<p>Aucune entrée pour le moment.</p> <p>Aucune entrée pour le moment.</p>
<p class="hint" *ngIf="session.active"> @if (session.active) {
<p class="hint">
Saisis une note, un évènement ou un jet ci-dessus pour commencer le journal. Saisis une note, un évènement ou un jet ci-dessus pour commencer le journal.
</p> </p>
}
</div> </div>
}
<ul class="timeline" *ngIf="entries.length > 0"> @if (entries.length > 0) {
<ul class="timeline">
@for (entry of entries; track entry) {
<li class="timeline-entry" <li class="timeline-entry"
*ngFor="let entry of entries"
[style.--type-color]="entryTypeMeta[entry.type].color"> [style.--type-color]="entryTypeMeta[entry.type].color">
<div class="entry-marker"> <div class="entry-marker">
<lucide-icon [img]="typeIcons[entry.type]" [size]="14"></lucide-icon> <lucide-icon [img]="typeIcons[entry.type]" [size]="14"></lucide-icon>
</div> </div>
<div class="entry-body"> <div class="entry-body">
<!-- Mode lecture --> <!-- Mode lecture -->
<ng-container *ngIf="editingEntryId !== entry.id"> @if (editingEntryId !== entry.id) {
<div class="entry-header"> <div class="entry-header">
<span class="entry-type">{{ entryTypeMeta[entry.type].label }}</span> <span class="entry-type">{{ entryTypeMeta[entry.type].label }}</span>
<span class="entry-time">{{ entry.occurredAt | date:'HH:mm — dd/MM/yyyy' }}</span> <span class="entry-time">{{ entry.occurredAt | date:'HH:mm — dd/MM/yyyy' }}</span>
@@ -130,12 +133,12 @@
</div> </div>
</div> </div>
<p class="entry-content">{{ entry.content }}</p> <p class="entry-content">{{ entry.content }}</p>
</ng-container> }
<!-- Mode édition --> <!-- Mode édition -->
<ng-container *ngIf="editingEntryId === entry.id"> @if (editingEntryId === entry.id) {
<div class="type-selector type-selector--compact"> <div class="type-selector type-selector--compact">
<button *ngFor="let type of entryTypes" @for (type of entryTypes; track type) {
<button
type="button" type="button"
class="type-chip" class="type-chip"
[class.type-chip--active]="editEntryType === type" [class.type-chip--active]="editEntryType === type"
@@ -144,6 +147,7 @@
<lucide-icon [img]="typeIcons[type]" [size]="12"></lucide-icon> <lucide-icon [img]="typeIcons[type]" [size]="12"></lucide-icon>
{{ entryTypeMeta[type].label }} {{ entryTypeMeta[type].label }}
</button> </button>
}
</div> </div>
<textarea class="entry-input" <textarea class="entry-input"
[(ngModel)]="editEntryContent" [(ngModel)]="editEntryContent"
@@ -164,14 +168,14 @@
Sauvegarder Sauvegarder
</button> </button>
</div> </div>
</ng-container> }
</div> </div>
</li> </li>
}
</ul> </ul>
}
</section> </section>
</div> </div>
<!-- Colonne droite : panneau référence (Dés / Personnages / Scènes) --> <!-- Colonne droite : panneau référence (Dés / Personnages / Scènes) -->
<aside class="play-aside"> <aside class="play-aside">
<app-session-reference-panel <app-session-reference-panel
@@ -184,7 +188,6 @@
(noteToJournal)="onItemNoteToJournal($event)"> (noteToJournal)="onItemNoteToJournal($event)">
</app-session-reference-panel> </app-session-reference-panel>
</aside> </aside>
</div> </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) {
<button
type="button" type="button"
class="face-chip" class="face-chip"
[class.face-chip--active]="selectedFace === f" [class.face-chip--active]="selectedFace === f"
(click)="selectedFace = f"> (click)="selectedFace = f">
d{{ f }} d{{ f }}
</button> </button>
}
</div> </div>
<div class="dice-inputs"> <div class="dice-inputs">
@@ -28,19 +30,22 @@
</button> </button>
</div> </div>
<div class="dice-history" *ngIf="history.length > 0"> @if (history.length > 0) {
<div class="dice-history">
<div class="history-header"> <div class="history-header">
<span>Derniers jets</span> <span>Derniers jets</span>
<button type="button" class="btn-link" (click)="clearHistory()" title="Vider l'historique local"> <button type="button" class="btn-link" (click)="clearHistory()" title="Vider l'historique local">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon> <lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button> </button>
</div> </div>
<ul class="history-list"> <ul class="history-list">
<li *ngFor="let r of history" class="history-item"> @for (r of history; track r) {
<li class="history-item">
<div class="history-text"> <div class="history-text">
<span class="history-notation">{{ r.notation }}</span> <span class="history-notation">{{ r.notation }}</span>
<span class="history-detail" *ngIf="r.rolls.length > 1">[{{ r.rolls.join(', ') }}]</span> @if (r.rolls.length > 1) {
<span class="history-detail">[{{ r.rolls.join(', ') }}]</span>
}
<span class="history-total">= {{ r.total }}</span> <span class="history-total">= {{ r.total }}</span>
</div> </div>
<button type="button" <button type="button"
@@ -51,11 +56,15 @@
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon> <lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
</button> </button>
</li> </li>
}
</ul> </ul>
</div> </div>
}
<p class="placeholder-hint" *ngIf="history.length === 0"> @if (history.length === 0) {
<p class="placeholder-hint">
Choisis un dé et lance. Choisis un dé et lance.
</p> </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">
@if (catalogs.length === 0) {
<p class="empty-hint">
Aucun catalogue d'objets dans cette campagne. Aucun catalogue d'objets dans cette campagne.
</p> </p>
<button *ngFor="let c of catalogs" type="button" class="sic-item" (click)="select(c)"> }
@for (c of catalogs; track c) {
<button type="button" class="sic-item" (click)="select(c)">
<lucide-icon [img]="Package" [size]="13"></lucide-icon> <lucide-icon [img]="Package" [size]="13"></lucide-icon>
<span class="sic-item-name">{{ c.name }}</span> <span class="sic-item-name">{{ c.name }}</span>
<span class="sic-item-count">{{ c.items.length }}</span> <span class="sic-item-count">{{ c.items.length }}</span>
</button> </button>
}
</div> </div>
}
<!-- Catalogue sélectionné --> <!-- Catalogue sélectionné -->
<div *ngIf="selected" class="sic-detail"> @if (selected) {
<div class="sic-detail">
<button type="button" class="sic-back" (click)="backToList()"> <button type="button" class="sic-back" (click)="backToList()">
<lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Catalogues <lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Catalogues
</button> </button>
<h4>{{ selected.name }}</h4> <h4>{{ selected.name }}</h4>
@for (g of groups; track g) {
<div class="sic-group" *ngFor="let g of groups"> <div class="sic-group">
<div class="sic-group-title" *ngIf="g.category !== '—'">{{ g.category }}</div> @if (g.category !== '—') {
<div class="sic-obj" *ngFor="let it of g.items"> <div class="sic-group-title">{{ g.category }}</div>
}
@for (it of g.items; track it) {
<div class="sic-obj">
<div class="sic-obj-main"> <div class="sic-obj-main">
<span class="sic-obj-name">{{ it.name }}</span> <span class="sic-obj-name">{{ it.name }}</span>
<span class="sic-obj-price" *ngIf="it.price">{{ it.price }}</span> @if (it.price) {
<span class="sic-obj-price">{{ it.price }}</span>
}
</div> </div>
<div class="sic-obj-desc" *ngIf="it.description">{{ it.description }}</div> @if (it.description) {
<div class="sic-obj-desc">{{ it.description }}</div>
}
<button type="button" class="sic-note" (click)="note(it)" [disabled]="!canAddToJournal" <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,41 +1,55 @@
<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">
@if (tables.length === 0) {
<p class="empty-hint">
Aucune table aléatoire dans cette campagne. Crée-en une depuis la sidebar. Aucune table aléatoire dans cette campagne. Crée-en une depuis la sidebar.
</p> </p>
<button *ngFor="let t of tables" type="button" class="srt-item" (click)="select(t)"> }
@for (t of tables; track t) {
<button type="button" class="srt-item" (click)="select(t)">
<lucide-icon [img]="Dices" [size]="13"></lucide-icon> <lucide-icon [img]="Dices" [size]="13"></lucide-icon>
<span class="srt-item-name">{{ t.name }}</span> <span class="srt-item-name">{{ t.name }}</span>
<span class="srt-item-formula">{{ t.diceFormula }}</span> <span class="srt-item-formula">{{ t.diceFormula }}</span>
</button> </button>
}
</div> </div>
}
<!-- Table sélectionnée : jet --> <!-- Table sélectionnée : jet -->
<div *ngIf="selected" class="srt-detail"> @if (selected) {
<div class="srt-detail">
<button type="button" class="srt-back" (click)="backToList()"> <button type="button" class="srt-back" (click)="backToList()">
<lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Tables <lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Tables
</button> </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" *ngIf="lastRoll"> <div class="srt-result">
<div class="srt-total"> <div class="srt-total">
{{ lastRoll.total }} {{ lastRoll.total }}
<span class="srt-rolls" *ngIf="lastRoll.rolls.length > 1">({{ lastRoll.rolls.join(' + ') }})</span> @if (lastRoll.rolls.length > 1) {
<span class="srt-rolls">({{ lastRoll.rolls.join(' + ') }})</span>
}
<strong *ngIf="matched">{{ matched.label }}</strong> @if (matched) {
<em *ngIf="!matched">aucune entrée</em> <strong>{{ matched.label }}</strong>
}
@if (!matched) {
<em>aucune entrée</em>
}
</div> </div>
<div class="srt-detail-text" *ngIf="matched?.detail">{{ matched?.detail }}</div> @if (matched?.detail) {
<div class="srt-detail-text">{{ matched?.detail }}</div>
}
<div class="srt-actions"> <div class="srt-actions">
<button type="button" class="srt-act" (click)="addToJournal()" [disabled]="!canAddToJournal"> <button type="button" class="srt-act" (click)="addToJournal()" [disabled]="!canAddToJournal">
<lucide-icon [img]="BookmarkPlus" [size]="13"></lucide-icon> Journal <lucide-icon [img]="BookmarkPlus" [size]="13"></lucide-icon> Journal
@@ -47,5 +61,7 @@
</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, 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

@@ -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 ====== -->
@if (activeTab === 'ai') {
<app-session-ai-chat-panel <app-session-ai-chat-panel
*ngIf="activeTab === 'ai'"
[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 ====== -->
@if (activeTab === 'dice') {
<app-session-dice-panel <app-session-dice-panel
*ngIf="activeTab === 'dice'"
[canAddToJournal]="canAddToJournal" [canAddToJournal]="canAddToJournal"
(rolled)="onDiceRolled($event)"> (rolled)="onDiceRolled($event)">
</app-session-dice-panel> </app-session-dice-panel>
}
<!-- ====== Tables aléatoires ====== --> <!-- ====== Tables aléatoires ====== -->
@if (activeTab === 'tables') {
<app-session-random-tables-panel <app-session-random-tables-panel
*ngIf="activeTab === 'tables'"
[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 ====== -->
@if (activeTab === 'objects') {
<app-session-item-catalogs-panel <app-session-item-catalogs-panel
*ngIf="activeTab === 'objects'"
[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"> }
@if (!loadingChars) {
<div>
@if (characters.length > 0) {
<div class="ref-group">
<h4> <h4>
<lucide-icon [img]="User" [size]="13"></lucide-icon> <lucide-icon [img]="User" [size]="13"></lucide-icon>
Personnages joueurs Personnages joueurs
</h4> </h4>
<button *ngFor="let c of characters" @for (c of characters; track c) {
<button
type="button" type="button"
class="ref-item" class="ref-item"
(click)="openInNewTab(['campaigns', campaignId, 'playthroughs', playthroughId!, 'characters', c.id!])"> (click)="openInNewTab(['campaigns', campaignId, 'playthroughs', playthroughId!, 'characters', c.id!])">
<span class="ref-item-name">{{ c.name }}</span> <span class="ref-item-name">{{ c.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon> <lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button> </button>
}
</div> </div>
}
<div class="ref-group" *ngIf="npcs.length > 0"> @if (npcs.length > 0) {
<div class="ref-group">
<h4> <h4>
<lucide-icon [img]="Drama" [size]="13"></lucide-icon> <lucide-icon [img]="Drama" [size]="13"></lucide-icon>
Personnages non-joueurs Personnages non-joueurs
</h4> </h4>
<button *ngFor="let n of npcs" @for (n of npcs; track n) {
<button
type="button" type="button"
class="ref-item" class="ref-item"
(click)="openInNewTab(['campaigns', campaignId, 'npcs', n.id!])"> (click)="openInNewTab(['campaigns', campaignId, 'npcs', n.id!])">
<span class="ref-item-name">{{ n.name }}</span> <span class="ref-item-name">{{ n.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon> <lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button> </button>
}
</div> </div>
}
<p class="empty-hint" *ngIf="characters.length === 0 && npcs.length === 0"> @if (characters.length === 0 && npcs.length === 0) {
<p class="empty-hint">
Aucun personnage dans cette campagne. Aucun personnage dans cette campagne.
</p> </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"> }
@if (!loadingTree && treeData) {
@if (treeData.arcs.length === 0) {
<p class="empty-hint">
Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici. Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici.
</p> </p>
}
<div *ngFor="let arc of treeData.arcs" class="ref-group"> @for (arc of treeData.arcs; track arc) {
<div class="ref-group">
<h4> <h4>
<lucide-icon [img]="Swords" [size]="13"></lucide-icon> <lucide-icon [img]="Swords" [size]="13"></lucide-icon>
{{ arc.name }} {{ arc.name }}
</h4> </h4>
@for (chapter of chaptersOf(arc); track chapter) {
<div *ngFor="let chapter of chaptersOf(arc)" class="ref-subgroup"> <div class="ref-subgroup">
<span class="ref-subgroup-title">{{ chapter.name }}</span> <span class="ref-subgroup-title">{{ chapter.name }}</span>
<button *ngFor="let scene of scenesOf(chapter)" @for (scene of scenesOf(chapter); track scene) {
<button
type="button" type="button"
class="ref-item ref-item--nested" class="ref-item ref-item--nested"
(click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])"> (click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])">
<span class="ref-item-name">{{ scene.name }}</span> <span class="ref-item-name">{{ scene.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon> <lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button> </button>
}
</div> </div>
}
</div> </div>
</ng-container> }
}
</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,18 +1,24 @@
<!-- 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) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ errorMessage }}</span> <span>{{ errorMessage }}</span>
</div> </div>
<div *ngIf="successMessage" class="alert alert-success"> }
@if (successMessage) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon> <lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ successMessage }}</span> <span>{{ successMessage }}</span>
</div> </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) {
<div class="form-row">
<label>Modeles installes</label> <label>Modeles installes</label>
<ul class="installed-models"> <ul class="installed-models">
<li *ngFor="let m of models"> @for (m of models; track m) {
<li>
<span class="model-name">{{ m }}</span> <span class="model-name">{{ m }}</span>
<button type="button" class="btn-icon btn-danger" <button type="button" class="btn-icon btn-danger"
(click)="deleteModel(m)" (click)="deleteModel(m)"
@@ -21,11 +27,14 @@
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
</li> </li>
}
</ul> </ul>
</div> </div>
}
<!-- Dialog de telechargement de modele --> <!-- Dialog de telechargement de modele -->
<div class="modal-overlay" *ngIf="pullDialogOpen" (click)="closePullDialog()"> @if (pullDialogOpen) {
<div class="modal-overlay" (click)="closePullDialog()">
<div class="modal-content" (click)="$event.stopPropagation()"> <div class="modal-content" (click)="$event.stopPropagation()">
<header class="modal-header"> <header class="modal-header">
<h3>Telecharger un modele Ollama</h3> <h3>Telecharger un modele Ollama</h3>
@@ -33,53 +42,68 @@
<lucide-icon [img]="X" [size]="18"></lucide-icon> <lucide-icon [img]="X" [size]="18"></lucide-icon>
</button> </button>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div *ngIf="!pullInProgress"> @if (!pullInProgress) {
<div>
<label for="pull-name">Nom du modele</label> <label for="pull-name">Nom du modele</label>
<input id="pull-name" type="text" [(ngModel)]="pullModelName" <input id="pull-name" type="text" [(ngModel)]="pullModelName"
placeholder="ex: gemma4:e4b" autocomplete="off" placeholder="ex: gemma4:e4b" autocomplete="off"
(keydown.enter)="startPull()"> (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) {
<button type="button"
class="suggestion-chip" (click)="selectSuggestion(s)">{{ s }}</button> class="suggestion-chip" (click)="selectSuggestion(s)">{{ s }}</button>
}
</div> </div>
<p class="hint" style="margin-top: 0.75rem;"> <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>. La liste complete est sur <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>.
</p> </p>
</div> </div>
}
<div *ngIf="pullInProgress" class="pull-progress"> @if (pullInProgress) {
<div class="pull-progress">
<div class="pull-status">{{ pullStatus }}</div> <div class="pull-status">{{ pullStatus }}</div>
<div class="progress-bar" *ngIf="pullTotal > 0"> @if (pullTotal > 0) {
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="pullPercent"></div> <div class="progress-fill" [style.width.%]="pullPercent"></div>
</div> </div>
<div class="progress-text" *ngIf="pullTotal > 0"> }
@if (pullTotal > 0) {
<div class="progress-text">
{{ formatBytes(pullCompleted) }} / {{ formatBytes(pullTotal) }} ({{ pullPercent }}%) {{ formatBytes(pullCompleted) }} / {{ formatBytes(pullTotal) }} ({{ pullPercent }}%)
</div> </div>
<div class="progress-text" *ngIf="pullTotal === 0"> }
@if (pullTotal === 0) {
<div class="progress-text">
Preparation... Preparation...
</div> </div>
}
</div> </div>
}
</div> </div>
<footer class="modal-footer"> <footer class="modal-footer">
@if (pullInProgress) {
<button type="button" class="btn-secondary" <button type="button" class="btn-secondary"
(click)="cancelPull()" *ngIf="pullInProgress"> (click)="cancelPull()">
Annuler Annuler
</button> </button>
}
@if (!pullInProgress) {
<button type="button" class="btn-secondary" <button type="button" class="btn-secondary"
(click)="closePullDialog()" *ngIf="!pullInProgress"> (click)="closePullDialog()">
Fermer Fermer
</button> </button>
}
@if (!pullInProgress) {
<button type="button" class="btn-primary" <button type="button" class="btn-primary"
(click)="startPull()" (click)="startPull()"
[disabled]="pullInProgress || !pullModelName.trim()" *ngIf="!pullInProgress"> [disabled]="pullInProgress || !pullModelName.trim()">
<lucide-icon [img]="Download" [size]="14"></lucide-icon> <lucide-icon [img]="Download" [size]="14"></lucide-icon>
<span>Telecharger</span> <span>Telecharger</span>
</button> </button>
}
</footer> </footer>
</div> </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,19 +8,23 @@
<h1>Parametres</h1> <h1>Parametres</h1>
</header> </header>
<div *ngIf="errorMessage" class="alert alert-error"> @if (errorMessage) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ errorMessage }}</span> <span>{{ errorMessage }}</span>
</div> </div>
<div *ngIf="successMessage" class="alert alert-success"> }
@if (successMessage) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon> <lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ successMessage }}</span> <span>{{ successMessage }}</span>
</div> </div>
}
<section class="card" *ngIf="settings"> @if (settings) {
<section class="card">
<h2>Moteur IA</h2> <h2>Moteur IA</h2>
<p class="hint">Choix du fournisseur de modele de langage utilise par le chat et la generation de pages.</p> <p class="hint">Choix du fournisseur de modele de langage utilise par le chat et la generation de pages.</p>
<div class="form-row"> <div class="form-row">
<label>Fournisseur</label> <label>Fournisseur</label>
<div class="radio-group"> <div class="radio-group">
@@ -47,22 +51,26 @@
</div> </div>
</div> </div>
</section> </section>
}
<!-- Bloc Ollama --> <!-- Bloc Ollama -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'"> @if (settings && settings.llm_provider === 'ollama') {
<section class="card">
<h2>Configuration Ollama</h2> <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>
<div class="form-row"> <div class="form-row">
<label for="ollama-model">Modele</label> <label for="ollama-model">Modele</label>
<div class="inline-select"> <div class="inline-select">
<select id="ollama-model" [(ngModel)]="settings.llm_model" (ngModelChange)="fetchOllamaModelInfo()"> <select id="ollama-model" [(ngModel)]="settings.llm_model" (ngModelChange)="fetchOllamaModelInfo()">
<option *ngIf="ollamaModels.length === 0" [value]="settings.llm_model">{{ settings.llm_model }}</option> @if (ollamaModels.length === 0) {
<option *ngFor="let m of ollamaModels" [value]="m">{{ m }}</option> <option [value]="settings.llm_model">{{ settings.llm_model }}</option>
}
@for (m of ollamaModels; track m) {
<option [value]="m">{{ m }}</option>
}
</select> </select>
<button type="button" class="btn-secondary" (click)="refreshModels()" [disabled]="loadingModels"> <button type="button" class="btn-secondary" (click)="refreshModels()" [disabled]="loadingModels">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon> <lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
@@ -73,9 +81,10 @@
<span>Telecharger</span> <span>Telecharger</span>
</button> </button>
</div> </div>
<p class="hint" *ngIf="ollamaModels.length === 0">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p> @if (ollamaModels.length === 0) {
<p class="hint">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
}
</div> </div>
<!-- Gestion des modeles installes (liste + suppression + dialog de pull) --> <!-- Gestion des modeles installes (liste + suppression + dialog de pull) -->
<app-ollama-model-manager <app-ollama-model-manager
#modelManager #modelManager
@@ -85,11 +94,12 @@
(modelDeleted)="onModelDeleted($event)"> (modelDeleted)="onModelDeleted($event)">
</app-ollama-model-manager> </app-ollama-model-manager>
</section> </section>
}
<!-- Bloc 1min.ai --> <!-- Bloc 1min.ai -->
<section class="card" *ngIf="settings && settings.llm_provider === 'onemin'"> @if (settings && settings.llm_provider === 'onemin') {
<section class="card">
<h2>Configuration 1min.ai</h2> <h2>Configuration 1min.ai</h2>
<div class="form-row"> <div class="form-row">
<label for="onemin-key">Cle API</label> <label for="onemin-key">Cle API</label>
<input <input
@@ -97,31 +107,36 @@
type="password" type="password"
[(ngModel)]="oneminApiKeyInput" [(ngModel)]="oneminApiKeyInput"
[placeholder]="settings.onemin_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API'"> [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"> @if (settings.onemin_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearApiKey"> <input type="checkbox" [(ngModel)]="clearApiKey">
<span>Effacer la cle enregistree</span> <span>Effacer la cle enregistree</span>
</label> </label>
}
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="onemin-provider">Fournisseur</label> <label for="onemin-provider">Fournisseur</label>
<select id="onemin-provider" [(ngModel)]="oneminProvider" (ngModelChange)="onProviderChange()"> <select id="onemin-provider" [(ngModel)]="oneminProvider" (ngModelChange)="onProviderChange()">
<option *ngFor="let g of oneminGroups" [value]="g.provider">{{ g.provider }}</option> @for (g of oneminGroups; track g) {
<option [value]="g.provider">{{ g.provider }}</option>
}
</select> </select>
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="onemin-model">Modele</label> <label for="onemin-model">Modele</label>
<select id="onemin-model" [(ngModel)]="settings.onemin_model"> <select id="onemin-model" [(ngModel)]="settings.onemin_model">
<option *ngFor="let m of currentProviderModels" [value]="m">{{ m }}</option> @for (m of currentProviderModels; track m) {
<option [value]="m">{{ m }}</option>
}
</select> </select>
</div> </div>
</section> </section>
}
<!-- Bloc OpenRouter --> <!-- Bloc OpenRouter -->
<section class="card" *ngIf="settings && settings.llm_provider === 'openrouter'"> @if (settings && settings.llm_provider === 'openrouter') {
<section class="card">
<h2>Configuration OpenRouter</h2> <h2>Configuration OpenRouter</h2>
<div class="form-row"> <div class="form-row">
<label for="openrouter-key">Cle API</label> <label for="openrouter-key">Cle API</label>
<input <input
@@ -129,12 +144,13 @@
type="password" type="password"
[(ngModel)]="openrouterApiKeyInput" [(ngModel)]="openrouterApiKeyInput"
[placeholder]="settings.openrouter_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API (sk-or-...)'"> [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"> @if (settings.openrouter_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearOpenrouterKey"> <input type="checkbox" [(ngModel)]="clearOpenrouterKey">
<span>Effacer la cle enregistree</span> <span>Effacer la cle enregistree</span>
</label> </label>
}
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="openrouter-model">Modele</label> <label for="openrouter-model">Modele</label>
<label class="checkbox"> <label class="checkbox">
@@ -142,7 +158,9 @@
<span>Gratuits seulement</span> <span>Gratuits seulement</span>
</label> </label>
<select id="openrouter-model" [(ngModel)]="settings.openrouter_model"> <select id="openrouter-model" [(ngModel)]="settings.openrouter_model">
<option *ngFor="let o of openrouterSelectOptions" [value]="o.id">{{ o.label }}</option> @for (o of openrouterSelectOptions; track o) {
<option [value]="o.id">{{ o.label }}</option>
}
</select> </select>
<p class="hint"> <p class="hint">
Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> : Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> :
@@ -152,11 +170,12 @@
</p> </p>
</div> </div>
</section> </section>
}
<!-- Bloc Mistral --> <!-- Bloc Mistral -->
<section class="card" *ngIf="settings && settings.llm_provider === 'mistral'"> @if (settings && settings.llm_provider === 'mistral') {
<section class="card">
<h2>Configuration Mistral</h2> <h2>Configuration Mistral</h2>
<div class="form-row"> <div class="form-row">
<label for="mistral-key">Cle API</label> <label for="mistral-key">Cle API</label>
<input <input
@@ -164,16 +183,19 @@
type="password" type="password"
[(ngModel)]="mistralApiKeyInput" [(ngModel)]="mistralApiKeyInput"
[placeholder]="settings.mistral_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Mistral'"> [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"> @if (settings.mistral_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearMistralKey"> <input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la cle enregistree</span> <span>Effacer la cle enregistree</span>
</label> </label>
}
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="mistral-model">Modele</label> <label for="mistral-model">Modele</label>
<select id="mistral-model" [(ngModel)]="settings.mistral_model"> <select id="mistral-model" [(ngModel)]="settings.mistral_model">
<option *ngFor="let o of mistralSelectOptions" [value]="o.id">{{ o.label }}</option> @for (o of mistralSelectOptions; track o) {
<option [value]="o.id">{{ o.label }}</option>
}
</select> </select>
<p class="hint"> <p class="hint">
Cle gratuite sur <code>console.mistral.ai</code> (tier « Experiment », sans CB). Cle gratuite sur <code>console.mistral.ai</code> (tier « Experiment », sans CB).
@@ -182,11 +204,12 @@
</p> </p>
</div> </div>
</section> </section>
}
<!-- Bloc Gemini --> <!-- Bloc Gemini -->
<section class="card" *ngIf="settings && settings.llm_provider === 'gemini'"> @if (settings && settings.llm_provider === 'gemini') {
<section class="card">
<h2>Configuration Gemini</h2> <h2>Configuration Gemini</h2>
<div class="form-row"> <div class="form-row">
<label for="gemini-key">Cle API</label> <label for="gemini-key">Cle API</label>
<input <input
@@ -194,16 +217,19 @@
type="password" type="password"
[(ngModel)]="geminiApiKeyInput" [(ngModel)]="geminiApiKeyInput"
[placeholder]="settings.gemini_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Gemini'"> [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"> @if (settings.gemini_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearGeminiKey"> <input type="checkbox" [(ngModel)]="clearGeminiKey">
<span>Effacer la cle enregistree</span> <span>Effacer la cle enregistree</span>
</label> </label>
}
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="gemini-model">Modele</label> <label for="gemini-model">Modele</label>
<select id="gemini-model" [(ngModel)]="settings.gemini_model"> <select id="gemini-model" [(ngModel)]="settings.gemini_model">
<option *ngFor="let o of geminiSelectOptions" [value]="o.id">{{ o.label }}</option> @for (o of geminiSelectOptions; track o) {
<option [value]="o.id">{{ o.label }}</option>
}
</select> </select>
<p class="hint"> <p class="hint">
Cle gratuite sur <code>aistudio.google.com</code> (sans CB). Ideal pour les imports : Cle gratuite sur <code>aistudio.google.com</code> (sans CB). Ideal pour les imports :
@@ -213,15 +239,16 @@
</p> </p>
</div> </div>
</section> </section>
}
<!-- Bloc Embeddings (Atelier RAG) : indépendant du modèle de CHAT ci-dessus. --> <!-- Bloc Embeddings (Atelier RAG) : indépendant du modèle de CHAT ci-dessus. -->
<section class="card" *ngIf="settings"> @if (settings) {
<section class="card">
<h2>Embeddings (Atelier RAG)</h2> <h2>Embeddings (Atelier RAG)</h2>
<p class="hint"> <p class="hint">
Modèle utilisé pour <strong>indexer les sources PDF</strong> des ateliers et y faire 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. de la recherche. C'est un modèle <strong>séparé</strong> du modèle de chat ci-dessus.
</p> </p>
<div class="form-row"> <div class="form-row">
<label>Fournisseur d'embeddings</label> <label>Fournisseur d'embeddings</label>
<div class="radio-group"> <div class="radio-group">
@@ -235,9 +262,9 @@
</label> </label>
</div> </div>
</div> </div>
<!-- Ollama embeddings --> <!-- Ollama embeddings -->
<div *ngIf="settings.embedding_provider === 'ollama'"> @if (settings.embedding_provider === 'ollama') {
<div>
<div class="form-row"> <div class="form-row">
<label for="emb-ollama-model">Modèle d'embedding Ollama</label> <label for="emb-ollama-model">Modèle d'embedding Ollama</label>
<input id="emb-ollama-model" type="text" [(ngModel)]="settings.ollama_embedding_model" <input id="emb-ollama-model" type="text" [(ngModel)]="settings.ollama_embedding_model"
@@ -253,7 +280,7 @@
</label> </label>
</div> </div>
</div> </div>
}
<div class="form-row"> <div class="form-row">
<label for="rag-topk">Extraits récupérés par question (couverture RAG)</label> <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"> <input id="rag-topk" type="number" min="3" max="200" [(ngModel)]="settings.rag_top_k">
@@ -265,20 +292,24 @@
le bouton <strong>« Analyse approfondie »</strong> de l'atelier. le bouton <strong>« Analyse approfondie »</strong> de l'atelier.
</p> </p>
</div> </div>
<!-- Mistral embeddings --> <!-- Mistral embeddings -->
<div *ngIf="settings.embedding_provider === 'mistral'"> @if (settings.embedding_provider === 'mistral') {
<div>
<div class="form-row"> <div class="form-row">
<label for="emb-mistral-key"> <label for="emb-mistral-key">
Clé API Mistral Clé API Mistral
<span class="key-state" *ngIf="settings.mistral_api_key_set">✓ configurée</span> @if (settings.mistral_api_key_set) {
<span class="key-state">✓ configurée</span>
}
</label> </label>
<input id="emb-mistral-key" type="password" [(ngModel)]="mistralApiKeyInput" <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'"> [placeholder]="settings.mistral_api_key_set ? 'Clé configurée (laisser vide pour ne pas changer)' : 'Saisir votre clé API Mistral'">
<label class="checkbox" *ngIf="settings.mistral_api_key_set"> @if (settings.mistral_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearMistralKey"> <input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la clé enregistrée</span> <span>Effacer la clé enregistrée</span>
</label> </label>
}
<p class="hint"> <p class="hint">
C'est la <strong>même clé</strong> que pour le chat Mistral (une seule clé partagée). 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. Soumis au rate limit du tier gratuit.
@@ -290,22 +321,27 @@
placeholder="mistral-embed"> placeholder="mistral-embed">
</div> </div>
</div> </div>
}
</section> </section>
}
<!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai <!-- 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 : 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. --> on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'"> @if (settings && settings.llm_provider === 'ollama') {
<section class="card">
<h2>Fenetre de contexte</h2> <h2>Fenetre de contexte</h2>
<!-- Ollama : slider borne par le max du modele --> <!-- Ollama : slider borne par le max du modele -->
<div class="form-row" *ngIf="settings.llm_provider === 'ollama'"> @if (settings.llm_provider === 'ollama') {
<div class="form-row">
<label for="llm-num-ctx"> <label for="llm-num-ctx">
Tokens alloues au modele Tokens alloues au modele
<span class="ctx-value">{{ settings.llm_num_ctx | number }}</span> <span class="ctx-value">{{ settings.llm_num_ctx | number }}</span>
<span class="ctx-max" *ngIf="ollamaModelMaxContext > 0"> @if (ollamaModelMaxContext > 0) {
<span class="ctx-max">
/ {{ ollamaModelMaxContext | number }} max / {{ ollamaModelMaxContext | number }} max
</span> </span>
}
</label> </label>
<input <input
id="llm-num-ctx" id="llm-num-ctx"
@@ -315,21 +351,27 @@
step="1024" step="1024"
[(ngModel)]="settings.llm_num_ctx" [(ngModel)]="settings.llm_num_ctx"
class="ctx-slider"> class="ctx-slider">
<p class="hint" *ngIf="ollamaModelMaxContext > 0"> @if (ollamaModelMaxContext > 0) {
<p class="hint">
Le modele <strong>{{ settings.llm_model }}</strong> accepte jusqu'a Le modele <strong>{{ settings.llm_model }}</strong> accepte jusqu'a
{{ ollamaModelMaxContext | number }} tokens. Plus la valeur est elevee, plus {{ 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. l'IA peut tenir d'historique et de contexte — au prix de VRAM et de latence.
</p> </p>
<p class="hint" *ngIf="ollamaModelMaxContext === 0"> }
@if (ollamaModelMaxContext === 0) {
<p class="hint">
Impossible de determiner la fenetre max du modele (Ollama injoignable ou modele Impossible de determiner la fenetre max du modele (Ollama injoignable ou modele
inconnu). Slider borne a {{ CTX_FALLBACK_MAX | number }} par securite. inconnu). Slider borne a {{ CTX_FALLBACK_MAX | number }} par securite.
</p> </p>
}
</div> </div>
}
</section> </section>
}
<!-- Bloc Import de PDF --> <!-- Bloc Import de PDF -->
<section class="card" *ngIf="settings"> @if (settings) {
<section class="card">
<h2>Import de PDF</h2> <h2>Import de PDF</h2>
<div class="form-row"> <div class="form-row">
<label for="import-chunk-tokens">Taille des morceaux a l'import (tokens)</label> <label for="import-chunk-tokens">Taille des morceaux a l'import (tokens)</label>
@@ -350,7 +392,6 @@
Attention : un gros morceau = generation plus longue (voir le timeout ci-dessous). Attention : un gros morceau = generation plus longue (voir le timeout ci-dessous).
</p> </p>
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="llm-timeout">Timeout des appels IA (secondes)</label> <label for="llm-timeout">Timeout des appels IA (secondes)</label>
<input <input
@@ -366,15 +407,18 @@
</p> </p>
</div> </div>
</section> </section>
}
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) --> <!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
<app-settings-updates-section></app-settings-updates-section> <app-settings-updates-section></app-settings-updates-section>
<div class="actions" *ngIf="settings"> @if (settings) {
<div class="actions">
<button class="btn-primary" (click)="save()" [disabled]="saving"> <button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon> <lucide-icon [img]="Save" [size]="16"></lucide-icon>
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span> <span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
</button> </button>
</div> </div>
}
</div> </div>

View File

@@ -1,63 +1,75 @@
<!-- 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) {
<section class="card">
<h2>Mises a jour</h2> <h2>Mises a jour</h2>
<p class="hint">Verifie aupres du registry Docker si une nouvelle version <p class="hint">Verifie aupres du registry Docker si une nouvelle version
des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont 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> <lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ licenseSuccess }}</span> <span>{{ licenseSuccess }}</span>
</div> </div>
}
<!-- ====================================================== --> <!-- ====================================================== -->
<!-- Sous-section : canal stable --> <!-- Sous-section : canal stable -->
<!-- ====================================================== --> <!-- ====================================================== -->
<div class="channel-block" *ngIf="config.updateCheckEnabled"> @if (config.updateCheckEnabled) {
<div class="channel-block">
<h3 class="channel-title">Canal stable</h3> <h3 class="channel-title">Canal stable</h3>
<div class="form-row"> <div class="form-row">
<button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking"> <button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon> <lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span> <span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span>
</button> </button>
</div> </div>
@if (updateStatus && !updateStatus.enabled) {
<div *ngIf="updateStatus && !updateStatus.enabled" class="hint"> <div class="hint">
Feature non configuree (WATCHTOWER_TOKEN absent). Feature non configuree (WATCHTOWER_TOKEN absent).
</div> </div>
}
<div *ngIf="updateStatus?.enabled"> @if (updateStatus?.enabled) {
<div *ngIf="updateStatus?.updateAvailable" class="alert alert-success"> <div>
@if (updateStatus?.updateAvailable) {
<div class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon> <lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span> <span>Une mise a jour est disponible.</span>
</div> </div>
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn"> }
@if (updateStatus?.anyUnknown && !updateStatus?.updateAvailable) {
<div class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon> <lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible (baseline absente ou registry injoignable).</span> <span>Verification impossible (baseline absente ou registry injoignable).</span>
</div> </div>
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint"> }
@if (!updateStatus?.updateAvailable && !updateStatus?.anyUnknown) {
<div class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}). Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div> </div>
}
<div class="form-row" *ngIf="updateStatus?.updateAvailable"> @if (updateStatus?.updateAvailable) {
<div class="form-row">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying"> <button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon> <lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span> <span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
</button> </button>
</div> </div>
}
<div *ngIf="updateMessage" class="alert alert-success"> @if (updateMessage) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon> <lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ updateMessage }}</span> <span>{{ updateMessage }}</span>
</div> </div>
}
</div> </div>
}
</div> </div>
}
<!-- ====================================================== --> <!-- ====================================================== -->
<!-- Sous-section : canal beta (Patreon) --> <!-- Sous-section : canal beta (Patreon) -->
<!-- ====================================================== --> <!-- ====================================================== -->
<div class="channel-block" *ngIf="licenseStatus?.enabled"> @if (licenseStatus?.enabled) {
<div class="channel-block">
<h3 class="channel-title"> <h3 class="channel-title">
<lucide-icon [img]="Heart" [size]="16"></lucide-icon> <lucide-icon [img]="Heart" [size]="16"></lucide-icon>
Canal beta &mdash; reserve aux patrons Canal beta &mdash; reserve aux patrons
@@ -66,9 +78,8 @@
Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere. 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. Le tier <strong>Compagnon</strong> (7&euro;/mois) ou superieur debloque ce canal.
</p> </p>
<!-- Pas de licence installee --> <!-- Pas de licence installee -->
<ng-container *ngIf="licenseStatus?.status === 'NONE'"> @if (licenseStatus?.status === 'NONE') {
<div class="form-row"> <div class="form-row">
<button type="button" class="btn-primary" (click)="connectPatreon()"> <button type="button" class="btn-primary" (click)="connectPatreon()">
<lucide-icon [img]="Link2" [size]="16"></lucide-icon> <lucide-icon [img]="Link2" [size]="16"></lucide-icon>
@@ -95,53 +106,74 @@
<span>Activer la licence</span> <span>Activer la licence</span>
</button> </button>
</div> </div>
<div *ngIf="licenseError" class="alert alert-error"> @if (licenseError) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ licenseError }}</span> <span>{{ licenseError }}</span>
</div> </div>
</ng-container> }
}
<!-- Licence installee (VALID / GRACE / EXPIRED / UNVERIFIABLE) --> <!-- Licence installee (VALID / GRACE / EXPIRED / UNVERIFIABLE) -->
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'"> @if (licenseStatus && licenseStatus.status !== 'NONE') {
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success"> @if (licenseStatus.status === 'VALID') {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon> <lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span> <span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
</div> </div>
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn"> }
@if (licenseStatus.status === 'GRACE') {
<div class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span> <span>
Connexion Patreon expiree, mais acces beta maintenu pendant la periode de tolerance. 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". Verifie que ton abonnement Patreon est toujours actif et clique sur "Verifier maintenant".
</span> </span>
</div> </div>
<div *ngIf="licenseStatus.status === 'EXPIRED'" class="alert alert-error"> }
@if (licenseStatus.status === 'EXPIRED') {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span> <span>
Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta. Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta.
</span> </span>
</div> </div>
<div *ngIf="licenseStatus.status === 'UNVERIFIABLE'" class="alert alert-error"> }
@if (licenseStatus.status === 'UNVERIFIABLE') {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Le token installe ne peut plus etre verifie. Reconnecte-toi.</span> <span>Le token installe ne peut plus etre verifie. Reconnecte-toi.</span>
</div> </div>
}
<ul class="license-info"> <ul class="license-info">
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li> @if (licenseStatus.tierId) {
<li *ngIf="licenseStatus.expiresAt"> <li><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
}
@if (licenseStatus.expiresAt) {
<li>
<strong>Validite :</strong> <strong>Validite :</strong>
jusqu'au {{ formatDate(licenseStatus.expiresAt) }} jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
<span *ngIf="daysUntilExpiry !== null && daysUntilExpiry > 0"> @if (daysUntilExpiry !== null && daysUntilExpiry > 0) {
(renouvellement dans {{ daysUntilExpiry }} jour<span *ngIf="daysUntilExpiry > 1">s</span>) <span>
(renouvellement dans {{ daysUntilExpiry }} jour@if (daysUntilExpiry > 1) {
<span>s</span>
})
</span> </span>
}
</li> </li>
<li *ngIf="licenseStatus.lastRefreshAttemptAt"> }
@if (licenseStatus.lastRefreshAttemptAt) {
<li>
<strong>Dernier refresh :</strong> <strong>Dernier refresh :</strong>
{{ formatDate(licenseStatus.lastRefreshAttemptAt) }} {{ formatDate(licenseStatus.lastRefreshAttemptAt) }}
<span *ngIf="licenseStatus.lastRefreshSucceeded === true" class="badge-ok">OK</span> @if (licenseStatus.lastRefreshSucceeded === true) {
<span *ngIf="licenseStatus.lastRefreshSucceeded === false" class="badge-warn">echec</span> <span class="badge-ok">OK</span>
}
@if (licenseStatus.lastRefreshSucceeded === false) {
<span class="badge-warn">echec</span>
}
</li> </li>
}
</ul> </ul>
<div class="form-row form-row-inline"> <div class="form-row form-row-inline">
<label class="checkbox"> <label class="checkbox">
<input <input
@@ -153,7 +185,6 @@
<span>Activer le canal beta</span> <span>Activer le canal beta</span>
</label> </label>
</div> </div>
<div class="form-row form-row-actions"> <div class="form-row form-row-actions">
<button type="button" class="btn-secondary" (click)="refreshLicense()" [disabled]="licenseLoading"> <button type="button" class="btn-secondary" (click)="refreshLicense()" [disabled]="licenseLoading">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon> <lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
@@ -164,23 +195,32 @@
<span>Deconnecter Patreon</span> <span>Deconnecter Patreon</span>
</button> </button>
</div> </div>
<!-- Etat du canal beta --> <!-- Etat du canal beta -->
<div *ngIf="licenseStatus.betaChannelEnabled" class="beta-status"> @if (licenseStatus.betaChannelEnabled) {
<div *ngIf="betaChecking" class="hint">Verification des images beta...</div> <div class="beta-status">
<div *ngIf="!betaChecking && betaStatus && !betaStatus.enabled" class="hint"> @if (betaChecking) {
<div class="hint">Verification des images beta...</div>
}
@if (!betaChecking && betaStatus && !betaStatus.enabled) {
<div class="hint">
Indisponible : {{ betaStatus.disabledReason }} Indisponible : {{ betaStatus.disabledReason }}
</div> </div>
<div *ngIf="!betaChecking && betaStatus?.enabled"> }
<div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn"> @if (!betaChecking && betaStatus?.enabled) {
<div>
@if (betaStatus?.anyUnknown) {
<div class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Verification beta impossible (registry beta injoignable ou baseline absente).</span> <span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
</div> </div>
}
</div> </div>
}
</div> </div>
}
<!-- Bascule de canal (stable <-> beta) via sidecar switcher --> <!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
<div class="channel-switch" *ngIf="channelStatus"> @if (channelStatus) {
<div class="channel-switch">
<div class="channel-current"> <div class="channel-current">
<span class="channel-label">Canal actuel :</span> <span class="channel-label">Canal actuel :</span>
<span class="channel-badge" <span class="channel-badge"
@@ -189,42 +229,46 @@
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }} {{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
</span> </span>
</div> </div>
<!-- Sidecar dispo : boutons d'action --> <!-- Sidecar dispo : boutons d'action -->
<ng-container *ngIf="channelStatus.switcherAvailable"> @if (channelStatus.switcherAvailable) {
<!-- On stable -> proposer passage beta (uniquement si licence active) --> <!-- On stable -> proposer passage beta (uniquement si licence active) -->
<button *ngIf="channelStatus.currentChannel === 'stable'" @if (channelStatus.currentChannel === 'stable') {
<button
type="button" class="btn-primary" type="button" class="btn-primary"
[disabled]="switchInFlight" [disabled]="switchInFlight"
(click)="requestChannelSwitch('beta')"> (click)="requestChannelSwitch('beta')">
<lucide-icon [img]="Download" [size]="14"></lucide-icon> <lucide-icon [img]="Download" [size]="14"></lucide-icon>
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span> <span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
</button> </button>
}
<!-- On beta -> proposer retour stable --> <!-- On beta -> proposer retour stable -->
<button *ngIf="channelStatus.currentChannel === 'beta'" @if (channelStatus.currentChannel === 'beta') {
<button
type="button" class="btn-secondary" type="button" class="btn-secondary"
[disabled]="switchInFlight" [disabled]="switchInFlight"
(click)="requestChannelSwitch('stable')"> (click)="requestChannelSwitch('stable')">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> <lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span> <span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
</button> </button>
}
<!-- Switch en cours : on prévient que la page va se rendre injoignable --> <!-- Switch en cours : on prévient que la page va se rendre injoignable -->
<div *ngIf="switchInFlight" class="alert alert-warn"> @if (switchInFlight) {
<div class="alert alert-warn">
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon> <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> <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> </div>
}
<!-- Erreur eventuelle remontee par le sidecar --> <!-- Erreur eventuelle remontee par le sidecar -->
<div *ngIf="switchError && !switchInFlight" class="alert alert-error"> @if (switchError && !switchInFlight) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ switchError }}</span> <span>{{ switchError }}</span>
</div> </div>
</ng-container> }
}
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) --> <!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
<div *ngIf="!channelStatus.switcherAvailable" class="alert alert-warn"> @if (!channelStatus.switcherAvailable) {
<div class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span> <span>
Le sidecar de bascule n'est pas installe. Pour beneficier du switch Le sidecar de bascule n'est pas installe. Pour beneficier du switch
@@ -235,7 +279,11 @@
<code>igmlcreation/loremind-beta-</code> pour beta). <code>igmlcreation/loremind-beta-</code> pour beta).
</span> </span>
</div> </div>
}
</div> </div>
</ng-container> }
}
</div> </div>
</section> }
</section>
}

View File

@@ -7,18 +7,20 @@
aria-label="Assistant IA"> aria-label="Assistant IA">
<!-- Poignee de redimensionnement (desactivee en mode wide) --> <!-- Poignee de redimensionnement (desactivee en mode wide) -->
@if (!isWide) {
<div <div
class="resize-handle" class="resize-handle"
*ngIf="!isWide"
(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) {
<section class="conv-sidebar" aria-label="Conversations">
<div class="conv-sidebar-header"> <div class="conv-sidebar-header">
<span class="conv-sidebar-title">Conversations</span> <span class="conv-sidebar-title">Conversations</span>
<button type="button" class="conv-new-btn" (click)="startNewConversation()" [disabled]="isStreaming" title="Nouvelle conversation"> <button type="button" class="conv-new-btn" (click)="startNewConversation()" [disabled]="isStreaming" title="Nouvelle conversation">
@@ -26,9 +28,11 @@
</button> </button>
</div> </div>
<ul class="conv-list"> <ul class="conv-list">
<li *ngIf="conversations.length === 0" class="conv-empty">Aucune conversation</li> @if (conversations.length === 0) {
<li class="conv-empty">Aucune conversation</li>
}
@for (c of conversations; track c) {
<li <li
*ngFor="let c of conversations"
class="conv-item" class="conv-item"
[class.active]="c.id === currentConversationId" [class.active]="c.id === currentConversationId"
(click)="selectConversation(c)"> (click)="selectConversation(c)">
@@ -42,14 +46,16 @@
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon> <lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button> </button>
</li> </li>
}
</ul> </ul>
</section> </section>
}
<section class="conv-main"> <section class="conv-main">
<header class="drawer-header"> <header class="drawer-header">
@if (persistent) {
<button <button
*ngIf="persistent"
type="button" type="button"
class="sidebar-toggle" class="sidebar-toggle"
(click)="toggleSidebar()" (click)="toggleSidebar()"
@@ -57,16 +63,16 @@
[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,11 +81,10 @@
(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 <button
@@ -96,43 +101,64 @@
</button> </button>
</header> </header>
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel" @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'"> [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. --> <!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
<div class="gauge-bar" *ngIf="usageHasMax"> @if (usageHasMax) {
<div class="gauge-bar">
<div class="gauge-fill" [style.width.%]="usagePercent"></div> <div class="gauge-fill" [style.width.%]="usagePercent"></div>
</div> </div>
}
<div class="gauge-label"> <div class="gauge-label">
<span class="gauge-text">Contexte : {{ usageTotal }}<ng-container *ngIf="usageHasMax"> / {{ usage.max }}</ng-container> tokens</span> <span class="gauge-text">Contexte : {{ usageTotal }}@if (usageHasMax) {
<span class="gauge-percent" *ngIf="usageHasMax">{{ usagePercent }}%</span> / {{ usage.max }}
} tokens</span>
@if (usageHasMax) {
<span class="gauge-percent">{{ usagePercent }}%</span>
}
</div> </div>
</div> </div>
}
<div #messagesContainer class="messages"> <div #messagesContainer class="messages">
<div class="msg msg-assistant md" *ngIf="messages.length === 0 && !currentAssistantText" @if (messages.length === 0 && !currentAssistantText) {
<div class="msg msg-assistant md"
[innerHTML]="welcomeMessage | markdown"> [innerHTML]="welcomeMessage | markdown">
</div> </div>
}
<ng-container *ngFor="let m of messages"> @for (m of messages; track m) {
<div *ngIf="m.role === 'user'" class="msg msg-user">{{ m.content }}</div> @if (m.role === 'user') {
<div *ngIf="m.role === 'assistant'" class="msg msg-assistant md" <div class="msg msg-user">{{ m.content }}</div>
}
@if (m.role === 'assistant') {
<div class="msg msg-assistant md"
[innerHTML]="m.content | markdown"></div> [innerHTML]="m.content | markdown"></div>
</ng-container> }
}
<div class="msg msg-assistant msg-streaming md" *ngIf="currentAssistantText"> @if (currentAssistantText) {
<div class="msg msg-assistant msg-streaming md">
<span [innerHTML]="currentAssistantText | markdown"></span><span class="caret"></span> <span [innerHTML]="currentAssistantText | markdown"></span><span class="caret"></span>
</div> </div>
}
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite"> @if (isStreaming && !currentAssistantText) {
<div class="typing-indicator" aria-live="polite">
<span></span><span></span><span></span> <span></span><span></span><span></span>
</div> </div>
}
<div class="msg msg-error" *ngIf="errorMessage" role="alert"> @if (errorMessage) {
<div class="msg msg-error" role="alert">
{{ errorMessage }} {{ errorMessage }}
</div> </div>
}
</div> </div>
<div class="primary-action" *ngIf="primaryAction"> @if (primaryAction) {
<div class="primary-action">
<button <button
type="button" type="button"
class="primary-btn" class="primary-btn"
@@ -142,21 +168,25 @@
{{ primaryAction.label }} {{ primaryAction.label }}
</button> </button>
</div> </div>
}
<div class="quick-suggestions" *ngIf="quickSuggestions.length"> @if (quickSuggestions.length) {
<div class="quick-suggestions">
<p class="quick-label">Suggestions rapides :</p> <p class="quick-label">Suggestions rapides :</p>
<div class="quick-list"> <div class="quick-list">
@for (s of quickSuggestions; track s) {
<button <button
type="button" type="button"
class="quick-btn" class="quick-btn"
*ngFor="let s of quickSuggestions"
(click)="useQuickSuggestion(s)" (click)="useQuickSuggestion(s)"
[disabled]="isStreaming"> [disabled]="isStreaming">
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon> <lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon>
{{ s }} {{ s }}
</button> </button>
}
</div> </div>
</div> </div>
}
<form class="input-row" (ngSubmit)="send()"> <form class="input-row" (ngSubmit)="send()">
<input <input
@@ -172,4 +202,4 @@
</form> </form>
</section> </section>
</aside> </aside>

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