Ajout d'un mode "jeu" (possibilité de lancer des sessions dans une campagne). Cela permet de faire de prendre des notes en live au cours d'une partie et d'avoir plusieurs outils sous la main pour aider le mj :
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m20s
Build & Push Images / build (core) (push) Successful in 1m50s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (web) (push) Successful in 1m47s

- Possibilité de parler à une IA pour règle de jeu ou élément de lore / campagne au cours d'une partie comme aide mémoire
- Onglet dédié aux personnages de la campagne
- Onglet dédié aux scènes
- Onglet avec dès pour ceux qui souhaitent ;

Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
This commit is contained in:
2026-05-20 14:59:26 +02:00
parent 87865338a0
commit 694f687fec
53 changed files with 3614 additions and 17 deletions

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.8.7-beta",
"version": "0.9.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.8.7-beta",
"version": "0.9.0-beta",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.8.7-beta",
"version": "0.9.0-beta",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -32,6 +32,7 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
{ path: 'sessions/:id', loadComponent: () => import('./sessions/session-detail/session-detail.component').then(m => m.SessionDetailComponent) },
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },

View File

@@ -196,4 +196,60 @@
</div>
</section>
<!-- ============ Sessions de jeu ============ -->
<section class="detail-section sessions-section" *ngIf="!editing">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Sessions de jeu
</h2>
<!-- Cas 1 : aucune session active dans l'app → on peut lancer -->
<button *ngIf="!activeSessionGlobal"
class="btn-add"
[disabled]="startingSession"
(click)="startSession()">
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
Lancer une nouvelle session
</button>
<!-- Cas 2 : session active sur cette campagne → reprendre -->
<button *ngIf="activeSessionOnCurrentCampaign"
class="btn-add"
(click)="openSession(activeSessionOnCurrentCampaign)">
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
Reprendre la session en cours
</button>
<!-- Cas 3 : session active sur une autre campagne → bloqué -->
<button *ngIf="isLaunchBlockedByOtherCampaign"
class="btn-add"
disabled
title="Une session est déjà en cours sur une autre campagne. Termine-la d'abord.">
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
Session en cours ailleurs
</button>
</div>
<div class="sessions-grid" *ngIf="sessions.length > 0">
<div class="session-card"
*ngFor="let session of sessions"
[class.session-card--active]="session.active"
(click)="openSession(session)">
<lucide-icon [img]="Dices" [size]="20" class="session-icon"></lucide-icon>
<div class="session-info">
<span class="session-name">{{ session.name }}</span>
<span class="session-meta">
<span class="session-status" *ngIf="session.active">● En cours</span>
<span *ngIf="!session.active">Terminée le {{ session.endedAt | date:'dd/MM/yyyy' }}</span>
</span>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="sessions.length === 0">
<p>Aucune session de jeu pour le moment.</p>
</div>
</section>
</div>

View File

@@ -382,3 +382,57 @@
&:hover { background: #5b52e0; }
}
// ─────────────── Sessions de jeu (Play Context) ───────────────
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.session-card {
display: flex;
align-items: center;
gap: 0.75rem;
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 0.9rem 1rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover { border-color: #6c63ff; transform: translateY(-1px); }
&--active {
border-color: #10b981;
background: linear-gradient(180deg, #0d1f1a 0%, #111827 100%);
}
.session-icon { color: #6c63ff; flex-shrink: 0; }
.session-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.session-name {
color: white;
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
color: #6b7280;
font-size: 0.75rem;
}
.session-status {
color: #10b981;
font-weight: 600;
}
}

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check } from 'lucide-angular';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check, Play } from 'lucide-angular';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
@@ -12,6 +12,8 @@ import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { SessionService } from '../../../services/session.service';
import { Session } from '../../../services/session.model';
import { Character } from '../../../services/character.model';
import { Npc } from '../../../services/npc.model';
import { LayoutService } from '../../../services/layout.service';
@@ -38,6 +40,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Dices = Dices;
readonly Drama = Drama;
readonly Check = Check;
readonly Play = Play;
campaign: Campaign | null = null;
arcs: Arc[] = [];
@@ -55,6 +58,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
characters: Character[] = [];
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
npcs: Npc[] = [];
/** Sessions de jeu (passées et en cours) liées à cette campagne. */
sessions: Session[] = [];
/**
* Session active globale (toutes campagnes confondues).
* Sert à désactiver le bouton "Lancer" si une session tourne déjà ailleurs.
* Null si aucune session active dans l'app.
*/
activeSessionGlobal: Session | null = null;
/** Indicateur de lancement en cours pour éviter les double-clics. */
startingSession = false;
/** Mode édition inline. */
editing = false;
@@ -78,6 +91,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private gameSystemService: GameSystemService,
private characterService: CharacterService,
private npcService: NpcService,
private sessionService: SessionService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
@@ -104,6 +118,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
@@ -138,6 +153,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
@@ -184,6 +200,55 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
).subscribe(list => this.npcs = list);
}
/**
* Charge les sessions de cette campagne ET la session active globale.
* La session globale conditionne si le bouton "Lancer" est activable
* (règle métier : une seule session active simultanément dans l'app).
*/
private loadSessions(campaignId: string): void {
this.sessionService.getSessions(campaignId).pipe(
catchError(() => of([] as Session[]))
).subscribe(list => this.sessions = list);
this.sessionService.getActiveSession().pipe(
catchError(() => of(null))
).subscribe(active => this.activeSessionGlobal = active);
}
/** True si une session est active mais sur une AUTRE campagne (lancement bloqué). */
get isLaunchBlockedByOtherCampaign(): boolean {
return !!this.activeSessionGlobal
&& !!this.campaign
&& this.activeSessionGlobal.campaignId !== this.campaign.id;
}
/** Session active sur la campagne courante (le MJ joue déjà ici). */
get activeSessionOnCurrentCampaign(): Session | null {
if (!this.activeSessionGlobal || !this.campaign) return null;
return this.activeSessionGlobal.campaignId === this.campaign.id
? this.activeSessionGlobal
: null;
}
startSession(): void {
if (!this.campaign || this.startingSession || this.isLaunchBlockedByOtherCampaign) return;
this.startingSession = true;
this.sessionService.startSession(this.campaign.id!).subscribe({
next: session => {
this.startingSession = false;
this.router.navigate(['/sessions', session.id]);
},
error: () => {
this.startingSession = false;
console.error('Erreur lors du lancement de la session');
}
});
}
openSession(session: Session): void {
this.router.navigate(['/sessions', session.id]);
}
createCharacter(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);

View File

@@ -47,6 +47,7 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'n
export class AiChatService {
private readonly loreEndpoint = '/api/ai/chat/stream';
private readonly campaignEndpoint = '/api/ai/chat/stream-campaign';
private readonly sessionEndpoint = '/api/ai/chat/stream-session';
/**
* Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore).
@@ -89,7 +90,16 @@ export class AiChatService {
return this.streamSse(this.campaignEndpoint, body);
}
/** Plumbing SSE mutualisé entre les 2 endpoints (Lore et Campaign). */
/**
* Streame la réponse de l'IA pour un chat pendant une Session de jeu.
* Le backend reconstitue automatiquement le contexte complet (lore +
* campagne + système de JDR + journal de session).
*/
streamChatForSession(sessionId: string, messages: ChatMessage[]): Observable<ChatStreamEvent> {
return this.streamSse(this.sessionEndpoint, { sessionId, messages });
}
/** Plumbing SSE mutualisé entre les endpoints (Lore / Campaign / Session). */
private streamSse(endpoint: string, body: Record<string, unknown>): Observable<ChatStreamEvent> {
return new Observable<ChatStreamEvent>((subscriber) => {
const controller = new AbortController();

View File

@@ -0,0 +1,35 @@
/** Type d'une entrée de journal de session — miroir de l'enum Java EntryType. */
export type EntryType = 'NOTE' | 'EVENT' | 'DICE_ROLL' | 'PLAYER_ACTION';
/** Entrée du journal d'une Session (note, évènement, jet, action joueur). */
export interface SessionEntry {
id: string;
sessionId: string;
type: EntryType;
content: string;
occurredAt: string;
createdAt: string;
updatedAt: string;
}
/** Payload de création/édition d'une entrée. */
export interface SessionEntryInput {
type: EntryType;
content: string;
/** Optionnel : si absent, le backend utilisera "maintenant". */
occurredAt?: string;
}
/** Métadonnées d'affichage par type — utilisées par la timeline. */
export interface EntryTypeMeta {
label: string;
icon: 'StickyNote' | 'Sparkles' | 'Dices' | 'UserCheck';
color: string;
}
export const ENTRY_TYPE_META: Record<EntryType, EntryTypeMeta> = {
NOTE: { label: 'Note', icon: 'StickyNote', color: '#9ca3af' },
EVENT: { label: 'Évènement', icon: 'Sparkles', color: '#f59e0b' },
DICE_ROLL: { label: 'Jet de dés', icon: 'Dices', color: '#6c63ff' },
PLAYER_ACTION: { label: 'Action joueur', icon: 'UserCheck', color: '#10b981' },
};

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { SessionEntry, SessionEntryInput } from './session-entry.model';
/**
* Service HTTP pour le journal d'une Session.
* Endpoints imbriqués : /api/sessions/{sessionId}/entries.
*/
@Injectable({
providedIn: 'root'
})
export class SessionEntryService {
private base(sessionId: string): string {
return `/api/sessions/${sessionId}/entries`;
}
constructor(private http: HttpClient) {}
getEntries(sessionId: string): Observable<SessionEntry[]> {
return this.http.get<SessionEntry[]>(this.base(sessionId));
}
createEntry(sessionId: string, input: SessionEntryInput): Observable<SessionEntry> {
return this.http.post<SessionEntry>(this.base(sessionId), input);
}
updateEntry(sessionId: string, entryId: string, input: SessionEntryInput): Observable<SessionEntry> {
return this.http.put<SessionEntry>(`${this.base(sessionId)}/${entryId}`, input);
}
deleteEntry(sessionId: string, entryId: string): Observable<void> {
return this.http.delete<void>(`${this.base(sessionId)}/${entryId}`);
}
}

View File

@@ -0,0 +1,15 @@
/**
* Modèle Session côté Frontend.
* Miroir du SessionDTO Java exposé par /api/sessions.
*/
export interface Session {
id: string;
name: string;
campaignId: string;
startedAt: string;
/** Null/undefined = session en cours. */
endedAt: string | null;
createdAt: string;
updatedAt: string;
active: boolean;
}

View File

@@ -0,0 +1,51 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Session } from './session.model';
/**
* Service HTTP pour le Play Context (gestion des Sessions de jeu).
* Port de sortie vers le Backend Java (Architecture Hexagonale).
*/
@Injectable({
providedIn: 'root'
})
export class SessionService {
private apiUrl = '/api/sessions';
constructor(private http: HttpClient) {}
/** Lance une nouvelle session sur la campagne donnée. */
startSession(campaignId: string): Observable<Session> {
return this.http.post<Session>(this.apiUrl, { campaignId });
}
/** Récupère la session active (204 No Content si aucune). */
getActiveSession(): Observable<Session | null> {
return this.http.get<Session | null>(`${this.apiUrl}/active`, { observe: 'body' });
}
getSessions(campaignId?: string): Observable<Session[]> {
let params = new HttpParams();
if (campaignId) {
params = params.set('campaignId', campaignId);
}
return this.http.get<Session[]>(this.apiUrl, { params });
}
getSessionById(id: string): Observable<Session> {
return this.http.get<Session>(`${this.apiUrl}/${id}`);
}
endSession(id: string): Observable<Session> {
return this.http.post<Session>(`${this.apiUrl}/${id}/end`, {});
}
renameSession(id: string, name: string): Observable<Session> {
return this.http.patch<Session>(`${this.apiUrl}/${id}`, { name });
}
deleteSession(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}

View File

@@ -0,0 +1,72 @@
<div class="ai-chat-panel">
<div #messagesContainer class="messages-area">
<div class="welcome-hint" *ngIf="messages.length === 0 && !currentAssistantText && !error">
<lucide-icon [img]="Sparkles" [size]="18"></lucide-icon>
<p>Pose une question à l'IA pendant la partie.</p>
<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.
</p>
</div>
<div *ngFor="let m of messages" class="msg" [class.msg--user]="m.role === 'user'" [class.msg--assistant]="m.role === 'assistant'">
<div class="msg-content">{{ m.content }}</div>
<button *ngIf="m.role === 'assistant'"
type="button"
class="msg-action"
[disabled]="!canSaveToJournal"
[title]="canSaveToJournal ? 'Ajouter cette réponse au journal' : 'Session terminée'"
(click)="onSaveToJournal(m.content)">
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
Au journal
</button>
</div>
<!-- Stream en cours : on affiche les tokens au fil de l'eau. -->
<div *ngIf="currentAssistantText" class="msg msg--assistant msg--streaming">
<div class="msg-content">{{ currentAssistantText }}<span class="cursor"></span></div>
</div>
<p class="error-hint" *ngIf="error">{{ error }}</p>
</div>
<div class="composer">
<textarea
class="composer-input"
[(ngModel)]="input"
name="aiChatInput"
rows="2"
[placeholder]="isStreaming ? 'LIA répond' : 'Demande une idée, un rebondissement, une description'"
[disabled]="isStreaming"
(keydown.control.enter)="send()"></textarea>
<div class="composer-actions">
<button type="button"
class="btn-link"
[disabled]="messages.length === 0 && !currentAssistantText"
(click)="clearConversation()"
title="Effacer la conversation">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
<button *ngIf="!isStreaming"
type="button"
class="btn-primary btn-send"
[disabled]="!input.trim()"
(click)="send()">
<lucide-icon [img]="Send" [size]="14"></lucide-icon>
Envoyer
</button>
<button *ngIf="isStreaming"
type="button"
class="btn-secondary btn-send"
(click)="cancelStream()">
<lucide-icon [img]="Square" [size]="14"></lucide-icon>
Stop
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,168 @@
.ai-chat-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 0.5rem;
}
.messages-area {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-right: 0.25rem;
}
.welcome-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
color: #9ca3af;
font-size: 0.85rem;
text-align: center;
padding: 1rem 0.5rem;
p { margin: 0; }
.welcome-sub { font-size: 0.75rem; color: #6b7280; font-style: italic; }
}
.msg {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.55rem 0.7rem;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.45;
word-break: break-word;
white-space: pre-wrap;
}
.msg--user {
align-self: flex-end;
max-width: 90%;
background: #1e3a5f;
color: #dbeafe;
}
.msg--assistant {
align-self: flex-start;
max-width: 95%;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.msg--streaming {
opacity: 0.95;
}
.msg-content {
white-space: pre-wrap;
}
.msg-action {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: 1px dashed #374151;
color: #9ca3af;
font-size: 0.7rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:hover:not(:disabled) {
border-color: #6c63ff;
color: #c4bdff;
background: rgba(108, 99, 255, 0.08);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.cursor {
color: #6c63ff;
animation: blink 1s steps(2) infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.error-hint {
color: #f87171;
font-size: 0.8rem;
font-style: italic;
margin: 0.25rem 0;
}
// ─────────────── Composer ───────────────
.composer {
display: flex;
flex-direction: column;
gap: 0.4rem;
border-top: 1px solid #1f2937;
padding-top: 0.5rem;
}
.composer-input {
width: 100%;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
font-family: inherit;
font-size: 0.85rem;
padding: 0.55rem 0.7rem;
border-radius: 6px;
resize: vertical;
min-height: 50px;
&:focus { outline: none; border-color: #6c63ff; }
&:disabled { opacity: 0.7; }
}
.composer-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.45rem;
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: none;
padding: 0.4rem 0.75rem;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
}
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } &:disabled { opacity: 0.5; cursor: not-allowed; } }
.btn-secondary{ background: #374151; color: #e5e7eb; &:hover { background: #4b5563; } }
.btn-link {
background: transparent;
border: none;
color: #6b7280;
padding: 0;
cursor: pointer;
margin-right: auto;
&:hover:not(:disabled) { color: #e5e7eb; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}

View File

@@ -0,0 +1,147 @@
import {
Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges,
ElementRef, ViewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
LucideAngularModule, Send, Sparkles, Trash2, BookmarkPlus, Square
} from 'lucide-angular';
import { Subscription } from 'rxjs';
import { AiChatService, ChatMessage } from '../../services/ai-chat.service';
/**
* Panneau de chat IA pour le mode jeu.
*
* <p>Diffère du {@link AiChatDrawerComponent} :
* - conversation 100% éphémère (le journal joue le rôle de mémoire persistante)
* - intégré dans le panneau latéral, pas en drawer
* - chaque réponse peut être ajoutée au journal en un clic (event {@link saveToJournal})</p>
*
* <p>Le backend reçoit le contexte complet via {@code /api/ai/chat/stream-session} :
* lore + campagne + GameSystem + journal — l'IA "sait" tout ce qui s'est passé.</p>
*/
@Component({
selector: 'app-session-ai-chat-panel',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './session-ai-chat-panel.component.html',
styleUrls: ['./session-ai-chat-panel.component.scss']
})
export class SessionAiChatPanelComponent implements OnChanges, OnDestroy {
readonly Send = Send;
readonly Sparkles = Sparkles;
readonly Trash2 = Trash2;
readonly BookmarkPlus = BookmarkPlus;
readonly Square = Square;
@Input() sessionId!: string;
@Input() canSaveToJournal = true;
/** Émis quand le MJ clique "Ajouter au journal" sur une réponse. */
@Output() saveToJournal = new EventEmitter<string>();
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
messages: ChatMessage[] = [];
currentAssistantText = '';
input = '';
isStreaming = false;
error: string | null = null;
private streamSub: Subscription | null = null;
constructor(private aiChat: AiChatService) {}
ngOnChanges(changes: SimpleChanges): void {
// Reset complet si on change de session (changement d'instance jouée).
if (changes['sessionId'] && !changes['sessionId'].firstChange) {
this.cancelStream();
this.messages = [];
this.currentAssistantText = '';
this.error = null;
}
}
send(): void {
const text = this.input.trim();
if (!text || this.isStreaming || !this.sessionId) return;
this.messages = [...this.messages, { role: 'user', content: text }];
this.input = '';
this.error = null;
this.startStream();
}
private startStream(): void {
this.isStreaming = true;
this.currentAssistantText = '';
this.scrollToBottomSoon();
this.streamSub = this.aiChat.streamChatForSession(this.sessionId, this.messages).subscribe({
next: (event) => {
if (event.type === 'token') {
this.currentAssistantText += event.value;
this.scrollToBottomSoon();
} else if (event.type === 'done') {
this.finishAssistantMessage();
}
},
error: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Erreur inconnue';
this.error = `Erreur IA : ${message}`;
this.isStreaming = false;
this.streamSub = null;
},
complete: () => {
this.finishAssistantMessage();
}
});
}
private finishAssistantMessage(): void {
if (this.currentAssistantText.trim()) {
this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText }];
}
this.currentAssistantText = '';
this.isStreaming = false;
this.streamSub = null;
this.scrollToBottomSoon();
}
cancelStream(): void {
if (this.streamSub) {
this.streamSub.unsubscribe();
this.streamSub = null;
}
// On garde ce qui a déjà été streamé : utile si l'IA partait dans le mur.
if (this.currentAssistantText.trim()) {
this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText + ' [interrompu]' }];
}
this.currentAssistantText = '';
this.isStreaming = false;
}
clearConversation(): void {
this.cancelStream();
this.messages = [];
this.error = null;
}
onSaveToJournal(content: string): void {
if (!this.canSaveToJournal) return;
this.saveToJournal.emit(content);
}
/** Scroll vers le bas après cycle de change detection — preuve d'affichage du dernier token. */
private scrollToBottomSoon(): void {
queueMicrotask(() => {
const el = this.messagesContainer?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
}
ngOnDestroy(): void {
this.cancelStream();
}
}

View File

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

View File

@@ -0,0 +1,346 @@
.session-detail {
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 1500px;
margin: 0 auto;
}
// ─────────────── Layout mode jeu (2 colonnes) ───────────────
.play-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 1.5rem;
align-items: start;
}
.play-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
/*
* Panneau latéral sticky pour garder dés + références visibles pendant
* que le MJ scroll dans le journal. Hauteur = viewport - padding pour ne
* pas déborder ; le panneau gère son propre scroll interne (.ref-content).
*/
.play-aside {
position: sticky;
top: 1rem;
height: calc(100vh - 3rem);
min-height: 0;
}
@media (max-width: 1024px) {
.play-grid {
grid-template-columns: 1fr;
}
.play-aside {
position: static;
height: auto;
}
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: #9ca3af;
font-size: 0.85rem;
text-decoration: none;
width: fit-content;
&:hover { color: #e5e7eb; }
}
// ─────────────── Header ───────────────
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
flex-wrap: wrap;
.header-texts { flex: 1; min-width: 0; }
h1 {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 1.75rem;
font-weight: 700;
color: white;
margin: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
&.edit-mode input {
background: #0d1117;
border: 1px solid #374151;
color: white;
font-size: 1.25rem;
font-weight: 700;
padding: 0.4rem 0.75rem;
border-radius: 6px;
min-width: 280px;
}
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: #1f2937;
color: #9ca3af;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 999px;
}
.badge-active { background: #064e3b; color: #6ee7b7; }
.badge-muted { background: #1f2937; color: #9ca3af; }
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
// ─────────────── Boutons ───────────────
.btn-primary,
.btn-secondary,
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: none;
padding: 0.55rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } }
.btn-secondary{ background: #374151; color: #e5e7eb; &:hover:not(:disabled) { background: #4b5563; } }
.btn-danger { background: #7f1d1d; color: #fecaca; &:hover:not(:disabled) { background: #991b1b; } }
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.75rem; }
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
background: transparent;
border: 1px solid #374151;
color: #9ca3af;
padding: 0.3rem 0.45rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.75rem;
transition: background 0.15s ease, color 0.15s ease;
&:hover:not(:disabled) { background: #1f2937; color: #e5e7eb; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
&--danger:hover:not(:disabled) { background: #7f1d1d; color: #fecaca; border-color: #7f1d1d; }
}
// ─────────────── Sections / cards ───────────────
.detail-section {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1.5rem 1.75rem;
}
.timeline-section h2 {
margin: 0 0 1.25rem 0;
font-size: 1.1rem;
color: #e5e7eb;
}
.empty-state {
text-align: center;
padding: 1.5rem 0;
color: #6b7280;
p { margin: 0.25rem 0; }
.hint { font-size: 0.8rem; font-style: italic; }
}
// ─────────────── Form "Ajouter une entrée" ───────────────
.add-entry-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.type-selector {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
&--compact { margin-bottom: 0.5rem; }
}
/*
* Variable CSS --type-color injectée par le template depuis ENTRY_TYPE_META.
* Permet à chaque puce d'avoir sa propre teinte sans dupliquer la règle.
*/
.type-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: transparent;
border: 1px solid #374151;
color: #9ca3af;
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.78rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:hover { border-color: var(--type-color, #6c63ff); color: #e5e7eb; }
&--active {
background: color-mix(in srgb, var(--type-color, #6c63ff) 18%, transparent);
border-color: var(--type-color, #6c63ff);
color: var(--type-color, #6c63ff);
font-weight: 600;
}
}
.entry-input {
width: 100%;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
font-family: inherit;
font-size: 0.9rem;
padding: 0.65rem 0.85rem;
border-radius: 8px;
resize: vertical;
min-height: 70px;
&:focus { outline: none; border-color: #6c63ff; }
}
.entry-input-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
.hint { color: #6b7280; font-size: 0.75rem; }
}
// ─────────────── Timeline ───────────────
.timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
position: relative;
// Ligne verticale qui relie les markers
&::before {
content: '';
position: absolute;
left: 14px;
top: 8px;
bottom: 8px;
width: 2px;
background: #1f2937;
}
}
.timeline-entry {
display: grid;
grid-template-columns: 30px 1fr;
gap: 0.85rem;
align-items: flex-start;
}
.entry-marker {
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: #0d1117;
border: 2px solid var(--type-color, #6c63ff);
color: var(--type-color, #6c63ff);
flex-shrink: 0;
z-index: 1;
}
.entry-body {
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 0.75rem 1rem;
min-width: 0;
}
.entry-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.4rem;
.entry-type {
color: var(--type-color, #6c63ff);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.entry-time {
color: #6b7280;
font-size: 0.75rem;
}
.entry-actions {
margin-left: auto;
display: flex;
gap: 0.3rem;
}
}
.entry-content {
color: #e5e7eb;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}

View File

@@ -0,0 +1,276 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
LucideAngularModule, LucideIconData,
Dices, ArrowLeft, Square, Trash2, Pencil, Check,
StickyNote, Sparkles, UserCheck, Plus, X
} from 'lucide-angular';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
import { of } from 'rxjs';
import { SessionService } from '../../services/session.service';
import { Session } from '../../services/session.model';
import {
SessionEntry, SessionEntryInput, EntryType, ENTRY_TYPE_META
} from '../../services/session-entry.model';
import { SessionEntryService } from '../../services/session-entry.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
import { SessionReferencePanelComponent } from '../session-reference-panel/session-reference-panel.component';
import { DiceRollResult } from '../session-dice-panel/session-dice-panel.component';
/**
* Vue détail d'une Session avec journal horodaté.
* Form de saisie en haut, timeline en dessous (plus récent en premier).
* Le layout dédié "mode jeu" sera ajouté en Phase 4.
*/
@Component({
selector: 'app-session-detail',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink, SessionReferencePanelComponent],
templateUrl: './session-detail.component.html',
styleUrls: ['./session-detail.component.scss']
})
export class SessionDetailComponent implements OnInit, OnDestroy {
readonly Dices = Dices;
readonly ArrowLeft = ArrowLeft;
readonly Square = Square;
readonly Trash2 = Trash2;
readonly Pencil = Pencil;
readonly Check = Check;
readonly Plus = Plus;
readonly X = X;
/** Mapping enum → composant Lucide pour le rendu des icônes par type. */
readonly typeIcons: Record<EntryType, LucideIconData> = {
NOTE: StickyNote,
EVENT: Sparkles,
DICE_ROLL: Dices,
PLAYER_ACTION: UserCheck,
};
readonly entryTypes: EntryType[] = ['NOTE', 'EVENT', 'DICE_ROLL', 'PLAYER_ACTION'];
readonly entryTypeMeta = ENTRY_TYPE_META;
session: Session | null = null;
/** Timeline triée du plus récent au plus ancien (DESC) pour l'UX en partie. */
entries: SessionEntry[] = [];
editingName = false;
editName = '';
/** State de la zone "Ajouter une entrée". */
newEntryType: EntryType = 'NOTE';
newEntryContent = '';
submittingEntry = false;
/** Id de l'entrée en cours d'édition (null si aucune). */
editingEntryId: string | null = null;
editEntryType: EntryType = 'NOTE';
editEntryContent = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private sessionService: SessionService,
private entryService: SessionEntryService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.layoutService.hide();
this.route.paramMap.pipe(
map(pm => pm.get('id')),
filter((id): id is string => !!id),
switchMap(id => this.sessionService.getSessionById(id).pipe(
catchError(() => of(null))
))
).subscribe(session => {
this.session = session;
if (session) {
this.pageTitleService.set(session.name);
this.loadEntries(session.id);
}
});
}
private loadEntries(sessionId: string): void {
this.entryService.getEntries(sessionId).pipe(
catchError(() => of([] as SessionEntry[]))
).subscribe(list => {
this.entries = list.slice().sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
});
}
// ─────────────── Renommage de la Session ───────────────
startRename(): void {
if (!this.session) return;
this.editName = this.session.name;
this.editingName = true;
}
cancelRename(): void {
this.editingName = false;
this.editName = '';
}
saveRename(): void {
if (!this.session || !this.editName.trim()) return;
this.sessionService.renameSession(this.session.id, this.editName.trim()).subscribe({
next: updated => {
this.session = updated;
this.editingName = false;
this.pageTitleService.set(updated.name);
},
error: () => console.error('Erreur lors du renommage de la session')
});
}
// ─────────────── Fin / suppression de Session ───────────────
endSession(): void {
if (!this.session || !this.session.active) return;
const session = this.session;
this.confirmDialog.confirm({
title: 'Terminer la session ?',
message: `Marquer la session "${session.name}" comme terminée ?`,
details: ['Tu pourras toujours consulter son contenu après.'],
confirmLabel: 'Terminer',
variant: 'warning'
}).then(ok => {
if (!ok) return;
this.sessionService.endSession(session.id).subscribe({
next: updated => this.session = updated,
error: () => console.error('Erreur lors de la fin de session')
});
});
}
deleteSession(): void {
if (!this.session) return;
const session = this.session;
const entryCount = this.entries.length;
const details = [
entryCount > 0
? `${entryCount} entrée${entryCount > 1 ? 's' : ''} de journal sera également supprimée.`
: 'Aucune entrée de journal pour cette session.',
'Cette action est irréversible.'
];
this.confirmDialog.confirm({
title: 'Supprimer la session ?',
message: `Supprimer définitivement la session "${session.name}" ?`,
details,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
const campaignId = session.campaignId;
this.sessionService.deleteSession(session.id).subscribe({
next: () => this.router.navigate(['/campaigns', campaignId]),
error: () => console.error('Erreur lors de la suppression de la session')
});
});
}
// ─────────────── Ajout d'entrée ───────────────
submitNewEntry(): void {
if (!this.session || this.submittingEntry) return;
const content = this.newEntryContent.trim();
if (!content) return;
this.submittingEntry = true;
const input: SessionEntryInput = { type: this.newEntryType, content };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => {
this.submittingEntry = false;
this.entries = [created, ...this.entries];
this.newEntryContent = '';
},
error: () => {
this.submittingEntry = false;
console.error('Erreur lors de l\'ajout de l\'entrée');
}
});
}
// ─────────────── Édition d'entrée ───────────────
startEditEntry(entry: SessionEntry): void {
this.editingEntryId = entry.id;
this.editEntryType = entry.type;
this.editEntryContent = entry.content;
}
cancelEditEntry(): void {
this.editingEntryId = null;
this.editEntryContent = '';
}
saveEditEntry(entry: SessionEntry): void {
if (!this.session) return;
const content = this.editEntryContent.trim();
if (!content) return;
const input: SessionEntryInput = { type: this.editEntryType, content };
this.entryService.updateEntry(this.session.id, entry.id, input).subscribe({
next: updated => {
this.entries = this.entries.map(e => e.id === updated.id ? updated : e);
this.editingEntryId = null;
},
error: () => console.error('Erreur lors de la mise à jour de l\'entrée')
});
}
/**
* Réception d'un jet de dés depuis le panneau latéral.
* On crée une entrée DICE_ROLL dans le journal avec le résumé formaté.
*/
onDiceRolled(result: DiceRollResult): void {
if (!this.session || !this.session.active) return;
const input: SessionEntryInput = { type: 'DICE_ROLL', content: result.summary };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => this.entries = [created, ...this.entries],
error: () => console.error('Erreur lors de l\'ajout du jet au journal')
});
}
/**
* Réception d'une réponse IA à sauvegarder dans le journal.
* Type NOTE par défaut car c'est le MJ qui choisit de capter une suggestion
* comme repère — pas un évènement de partie en lui-même.
*/
onAiReplyToJournal(content: string): void {
if (!this.session || !this.session.active) return;
const trimmed = content.trim();
if (!trimmed) return;
const input: SessionEntryInput = { type: 'NOTE', content: '💡 ' + trimmed };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => this.entries = [created, ...this.entries],
error: () => console.error('Erreur lors de l\'ajout de la suggestion IA au journal')
});
}
deleteEntry(entry: SessionEntry): void {
if (!this.session) return;
const session = this.session;
this.confirmDialog.confirm({
title: 'Supprimer cette entrée ?',
message: 'Cette entrée du journal sera définitivement supprimée.',
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.entryService.deleteEntry(session.id, entry.id).subscribe({
next: () => this.entries = this.entries.filter(e => e.id !== entry.id),
error: () => console.error('Erreur lors de la suppression de l\'entrée')
});
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,61 @@
<div class="dice-panel">
<div class="dice-controls">
<div class="face-grid">
<button *ngFor="let f of faces"
type="button"
class="face-chip"
[class.face-chip--active]="selectedFace === f"
(click)="selectedFace = f">
d{{ f }}
</button>
</div>
<div class="dice-inputs">
<label class="input-group">
<span>Nombre</span>
<input type="number" min="1" max="20" [(ngModel)]="count" />
</label>
<label class="input-group">
<span>Modificateur</span>
<input type="number" [(ngModel)]="modifier" />
</label>
</div>
<button type="button" class="btn-primary btn-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="16"></lucide-icon>
Lancer {{ count }}d{{ selectedFace }}{{ modifier === 0 ? '' : (modifier > 0 ? '+' + modifier : modifier) }}
</button>
</div>
<div class="dice-history" *ngIf="history.length > 0">
<div class="history-header">
<span>Derniers jets</span>
<button type="button" class="btn-link" (click)="clearHistory()" title="Vider l'historique local">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
<ul class="history-list">
<li *ngFor="let r of history" class="history-item">
<div class="history-text">
<span class="history-notation">{{ r.notation }}</span>
<span class="history-detail" *ngIf="r.rolls.length > 1">[{{ r.rolls.join(', ') }}]</span>
<span class="history-total">= {{ r.total }}</span>
</div>
<button type="button"
class="btn-icon"
[disabled]="!canAddToJournal"
[title]="canAddToJournal ? 'Ajouter au journal' : 'Session terminée'"
(click)="addToJournal(r)">
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
</button>
</li>
</ul>
</div>
<p class="placeholder-hint" *ngIf="history.length === 0">
Choisis un dé et lance.
</p>
</div>

View File

@@ -0,0 +1,172 @@
.dice-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dice-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.face-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.4rem;
}
.face-chip {
background: #111827;
border: 1px solid #1f2937;
color: #9ca3af;
font-size: 0.85rem;
font-weight: 600;
padding: 0.45rem 0.5rem;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:hover { border-color: #6c63ff; color: #e5e7eb; }
&--active {
background: rgba(108, 99, 255, 0.18);
border-color: #6c63ff;
color: #c4bdff;
}
}
.dice-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
span {
color: #9ca3af;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.4px;
}
input {
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
padding: 0.4rem 0.55rem;
border-radius: 6px;
font-size: 0.85rem;
width: 100%;
&:focus { outline: none; border-color: #6c63ff; }
}
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
background: #6c63ff;
color: white;
border: none;
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-roll { width: 100%; }
// ─────────────── Historique ───────────────
.dice-history {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
color: #9ca3af;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
background: #111827;
border: 1px solid #1f2937;
border-radius: 6px;
padding: 0.4rem 0.55rem;
font-size: 0.8rem;
}
.history-text {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
flex-wrap: wrap;
.history-notation { color: #c4bdff; font-weight: 600; }
.history-detail { color: #6b7280; font-size: 0.72rem; }
.history-total { color: white; font-weight: 700; margin-left: auto; }
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid #374151;
color: #9ca3af;
padding: 0.25rem 0.35rem;
border-radius: 5px;
cursor: pointer;
&:hover:not(:disabled) { background: #1f2937; color: #e5e7eb; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
.btn-link {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0;
&:hover { color: #e5e7eb; }
}
.placeholder-hint {
color: #6b7280;
font-size: 0.8rem;
font-style: italic;
text-align: center;
margin: 0;
}

View File

@@ -0,0 +1,92 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Dices, BookmarkPlus, Trash2 } from 'lucide-angular';
/** Faces de dés supportées par le roller. */
const DICE_FACES = [4, 6, 8, 10, 12, 20, 100] as const;
type DiceFace = typeof DICE_FACES[number];
/** Résultat d'un jet, exposé au parent pour ajout au journal. */
export interface DiceRollResult {
/** Notation lisible, ex: "2d6+3". */
notation: string;
/** Détail des dés individuels. */
rolls: number[];
modifier: number;
total: number;
/** Formatage textuel prêt à être écrit dans le journal. */
summary: string;
}
/**
* Panneau de jet de dés pour une session.
* Composant isolé : choix face/quantité/modificateur, jet, historique local court,
* et émission d'un événement vers le parent pour ajout au journal.
*/
@Component({
selector: 'app-session-dice-panel',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './session-dice-panel.component.html',
styleUrls: ['./session-dice-panel.component.scss']
})
export class SessionDicePanelComponent {
readonly Dices = Dices;
readonly BookmarkPlus = BookmarkPlus;
readonly Trash2 = Trash2;
readonly faces: readonly DiceFace[] = DICE_FACES;
/** Désactive le bouton "Ajouter au journal" si la session est terminée. */
@Input() canAddToJournal = true;
@Output() rolled = new EventEmitter<DiceRollResult>();
selectedFace: DiceFace = 20;
count = 1;
modifier = 0;
/** Historique local (max 8 entrées) pour permettre de retrouver un jet récent. */
history: DiceRollResult[] = [];
roll(): void {
const safeCount = Math.max(1, Math.min(20, Math.floor(this.count)));
const rolls: number[] = [];
for (let i = 0; i < safeCount; i++) {
rolls.push(this.randomFace(this.selectedFace));
}
const sumRolls = rolls.reduce((s, n) => s + n, 0);
const total = sumRolls + this.modifier;
const modPart = this.modifier === 0 ? '' : (this.modifier > 0 ? `+${this.modifier}` : `${this.modifier}`);
const notation = `${safeCount}d${this.selectedFace}${modPart}`;
const detailsPart = rolls.length > 1 ? ` [${rolls.join(', ')}]` : '';
const summary = `🎲 ${notation}${detailsPart} = ${total}`;
const result: DiceRollResult = { notation, rolls, modifier: this.modifier, total, summary };
this.history = [result, ...this.history].slice(0, 8);
}
/** Émet vers le parent pour qu'il insère le jet comme entrée DICE_ROLL. */
addToJournal(result: DiceRollResult): void {
if (!this.canAddToJournal) return;
this.rolled.emit(result);
}
clearHistory(): void {
this.history = [];
}
/**
* crypto.getRandomValues si dispo, fallback Math.random sinon.
* Pas critique pour du JDR mais évite le biais Math.random sur les très petites distributions.
*/
private randomFace(face: DiceFace): number {
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return (buf[0] % face) + 1;
}
return Math.floor(Math.random() * face) + 1;
}
}

View File

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

View File

@@ -0,0 +1,143 @@
.reference-panel {
display: flex;
flex-direction: column;
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 12px;
overflow: hidden;
height: 100%;
min-height: 0;
}
// ─────────────── Tabs ───────────────
.ref-tabs {
display: flex;
background: #111827;
border-bottom: 1px solid #1f2937;
}
.ref-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
background: transparent;
color: #9ca3af;
border: none;
padding: 0.7rem 0.5rem;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s, background 0.15s;
&:hover { color: #e5e7eb; background: rgba(108, 99, 255, 0.06); }
&--active {
color: #c4bdff;
border-bottom-color: #6c63ff;
background: rgba(108, 99, 255, 0.1);
}
}
.ref-content {
padding: 1rem;
overflow-y: auto;
flex: 1;
min-height: 0;
}
/*
* Onglet IA : on retire l'overflow du conteneur (le panneau de chat gère
* son propre scroll interne pour les messages) et on force display flex
* pour que l'enfant prenne toute la hauteur.
*/
.ref-content--fill {
display: flex;
overflow: hidden;
> app-session-ai-chat-panel {
flex: 1;
min-height: 0;
display: flex;
}
}
// ─────────────── Listes (PJ/PNJ/Scènes) ───────────────
.ref-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.ref-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
h4 {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #9ca3af;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.4px;
margin: 0 0 0.25rem 0;
}
}
.ref-subgroup {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding-left: 0.25rem;
margin-top: 0.35rem;
.ref-subgroup-title {
color: #6b7280;
font-size: 0.72rem;
margin-bottom: 0.15rem;
}
}
.ref-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
font-size: 0.82rem;
text-align: left;
padding: 0.45rem 0.65rem;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
&:hover { border-color: #6c63ff; background: #131c2e; }
&--nested { padding-left: 0.9rem; }
.ref-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-item-icon {
color: #6b7280;
flex-shrink: 0;
}
}
.loading-hint,
.empty-hint {
color: #6b7280;
font-size: 0.8rem;
font-style: italic;
text-align: center;
margin: 0.5rem 0;
}

View File

@@ -0,0 +1,138 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles } from 'lucide-angular';
import { catchError, of } from 'rxjs';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { NpcService } from '../../services/npc.service';
import { Character } from '../../services/character.model';
import { Npc } from '../../services/npc.model';
import { Arc, Chapter, Scene } from '../../services/campaign.model';
import { loadCampaignTreeData, CampaignTreeData } from '../../campaigns/campaign-tree.helper';
import {
SessionDicePanelComponent, DiceRollResult
} from '../session-dice-panel/session-dice-panel.component';
import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-panel.component';
type TabId = 'dice' | 'characters' | 'scenes' | 'ai';
/**
* Panneau latéral du mode jeu : référence rapide en lecture seule.
*
* <p>Charge à la volée les PJ/PNJ et l'arbre de scènes de la campagne associée
* à la session. La navigation vers les fiches s'ouvre dans un nouvel onglet
* pour ne pas casser le flux de la session en cours.</p>
*
* <p>Le sous-composant {@link SessionDicePanelComponent} émet un événement
* de jet qui remonte ici puis vers le parent via {@link rolled}.</p>
*/
@Component({
selector: 'app-session-reference-panel',
standalone: true,
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent],
templateUrl: './session-reference-panel.component.html',
styleUrls: ['./session-reference-panel.component.scss']
})
export class SessionReferencePanelComponent implements OnChanges {
readonly User = User;
readonly Drama = Drama;
readonly Swords = Swords;
readonly Dices = Dices;
readonly ExternalLink = ExternalLink;
readonly Sparkles = Sparkles;
@Input() campaignId!: string;
@Input() sessionId!: string;
@Input() canAddToJournal = true;
@Output() rolled = new EventEmitter<DiceRollResult>();
/** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
@Output() aiReplyToJournal = new EventEmitter<string>();
activeTab: TabId = 'dice';
characters: Character[] = [];
npcs: Npc[] = [];
treeData: CampaignTreeData | null = null;
loadingChars = false;
loadingTree = false;
/** True dès qu'un tab "lourd" a été chargé pour éviter de rappeler l'API en boucle. */
private charsLoaded = false;
private treeLoaded = false;
constructor(
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService
) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['campaignId']) {
this.charsLoaded = false;
this.treeLoaded = false;
this.characters = [];
this.npcs = [];
this.treeData = null;
}
}
selectTab(tab: TabId): void {
this.activeTab = tab;
if (tab === 'characters') this.ensureCharactersLoaded();
if (tab === 'scenes') this.ensureTreeLoaded();
}
private ensureCharactersLoaded(): void {
if (this.charsLoaded || this.loadingChars || !this.campaignId) return;
this.loadingChars = true;
this.characterService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Character[])))
.subscribe(list => { this.characters = list; this.tryFinishCharsLoad(); });
this.npcService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Npc[])))
.subscribe(list => { this.npcs = list; this.tryFinishCharsLoad(); });
}
private tryFinishCharsLoad(): void {
// On considère que le chargement est fini quand au moins une des deux listes
// a été assignée (vide ou pleine). Le double subscribe ci-dessus garantit
// qu'on tombe ici deux fois ; idempotent.
this.loadingChars = false;
this.charsLoaded = true;
}
private ensureTreeLoaded(): void {
if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
this.loadingTree = true;
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
).subscribe(data => {
this.treeData = data;
this.loadingTree = false;
this.treeLoaded = true;
});
}
/**
* Ouvre une fiche dans un nouvel onglet pour préserver l'écran de session.
* Le MJ peut consulter sans perdre son journal ni son historique de dés.
*/
openInNewTab(path: (string | number)[]): void {
const url = path.map(p => String(p)).join('/');
window.open('/' + url, '_blank', 'noopener');
}
/** Helpers de typage pour le template (Angular n'infère pas bien sans). */
chaptersOf(arc: Arc): Chapter[] {
return this.treeData?.chaptersByArc[arc.id!] ?? [];
}
scenesOf(chapter: Chapter): Scene[] {
return this.treeData?.scenesByChapter[chapter.id!] ?? [];
}
onDiceRolled(result: DiceRollResult): void {
this.rolled.emit(result);
}
onAiSaveToJournal(content: string): void {
this.aiReplyToJournal.emit(content);
}
}