Corrections visuel ; optimisation du chargement des pages (préchargement anticité, sinon temps de latence chaque fois qu'on visite un type de page une première fois)

This commit is contained in:
2026-04-22 13:17:05 +02:00
parent 8f4dd3e9d6
commit 8efa148739
13 changed files with 385 additions and 34 deletions

View File

@@ -70,7 +70,7 @@
</div>
</div>
<div class="characters-section">
<section class="detail-section characters-section">
<div class="section-header">
<h2>Personnages joueurs</h2>
<button class="btn-add" (click)="createCharacter()">
@@ -97,33 +97,33 @@
Créer votre premier PJ
</button>
</div>
</div>
</section>
<div class="arcs-section">
<section class="detail-section arcs-section">
<div class="section-header">
<h2>Arcs narratifs</h2>
<button class="btn-add">
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
</div>
<div class="arcs-grid" *ngIf="arcs.length > 0">
<div class="arc-card" *ngFor="let arc of arcs">
<div class="arc-card" *ngFor="let arc of arcs" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ arc.chapterCount || 0 }} chapitres</span>
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
</div>
</div>
<div class="empty-state" *ngIf="arcs.length === 0">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p>
<button class="btn-add-first">
<button class="btn-add-first" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier arc
</button>
</div>
</div>
</section>
</div>

View File

@@ -1,9 +1,23 @@
.campaign-detail {
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/**
* Chaque bloc (resume, PJ, arcs) est encapsule dans une carte distincte
* pour separer visuellement les zones. Le gap au niveau du parent gere
* les espacements — les sections ne portent plus de margin-bottom.
*/
.detail-section {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1.5rem 1.75rem;
}
.detail-header {
margin-bottom: 2.5rem;
h1 {
font-size: 1.75rem;
@@ -173,9 +187,6 @@
.arc-meta { color: #6b7280; font-size: 0.75rem; }
}
.characters-section {
margin-bottom: 2.5rem;
}
.characters-grid {
display: grid;

View File

@@ -36,6 +36,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
campaign: Campaign | null = null;
arcs: Arc[] = [];
/** Nombre de chapitres par arc — alimente le compteur des cartes. */
chapterCountByArc: Record<string, number> = {};
/** Lore associé si `campaign.loreId` est renseigné ; sinon null. */
linkedLore: Lore | null = null;
/** Lores disponibles pour changer l'association en mode édition. */
@@ -86,11 +88,20 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
});
}
private computeChapterCounts(data: CampaignTreeData): Record<string, number> {
const counts: Record<string, number> = {};
for (const arcId of Object.keys(data.chaptersByArc)) {
counts[arcId] = data.chaptersByArc[arcId].length;
}
return counts;
}
/**
* Recharge explicitement après une mise à jour locale (ex: saveEdit).
* Contrairement au flux ngOnInit, on bypass le filter sur l'ID puisqu'on
@@ -110,6 +121,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
});
@@ -157,6 +169,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
}
createArc(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
}
openArc(arc: Arc): void {
if (!this.campaign || !arc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);
}
/** Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). */
characterSnippet(c: Character): string {
if (!c.markdownContent) return '(Fiche vide)';

View File

@@ -39,7 +39,7 @@
</div>
<!-- ============ Grille des dossiers racine ============ -->
<div class="nodes-section">
<section class="detail-section nodes-section">
<div class="section-header">
<h2>Dossiers</h2>
<button class="btn-add" (click)="navigateToCreateNode()">
@@ -65,6 +65,6 @@
Créer votre premier dossier
</button>
</div>
</div>
</section>
</div>

View File

@@ -2,6 +2,18 @@
padding: 2.5rem 2rem;
}
/**
* Carte visuelle pour les sous-sections (Dossiers). Le header (titre + resume)
* reste en dehors d'une carte : il EST le lore, pas une section qui lui
* appartient. Meme pattern que campaign-detail.
*/
.detail-section {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1.5rem 1.75rem;
}
.detail-header {
display: flex;
align-items: flex-start;

View File

@@ -1,4 +1,21 @@
<aside class="drawer" [class.drawer-open]="isOpen" [class.with-sidebar]="persistent && sidebarOpen" aria-label="Assistant IA">
<aside
class="drawer"
[class.drawer-open]="isOpen"
[class.is-resizing]="isResizing"
[class.is-wide]="isWide"
[style.width.px]="effectiveWidth"
aria-label="Assistant IA">
<!-- Poignee de redimensionnement (desactivee en mode wide) -->
<div
class="resize-handle"
*ngIf="!isWide"
(mousedown)="onResizeStart($event)"
role="separator"
aria-orientation="vertical"
aria-label="Redimensionner le panneau"
title="Glisser pour redimensionner"></div>
<!-- Sidebar conversations (mode persistent uniquement) -->
<section class="conv-sidebar" *ngIf="persistent && sidebarOpen" aria-label="Conversations">
@@ -65,6 +82,15 @@
</ng-template>
</div>
<button
type="button"
class="wide-toggle-btn"
(click)="toggleWide()"
[attr.aria-label]="isWide ? 'Reduire le panneau' : 'Agrandir le panneau'"
[title]="isWide ? 'Reduire' : 'Agrandir'">
<lucide-icon [img]="isWide ? Minimize2 : Maximize2" [size]="16"></lucide-icon>
</button>
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
@@ -82,18 +108,18 @@
</div>
<div #messagesContainer class="messages">
<div class="msg msg-assistant" *ngIf="messages.length === 0 && !currentAssistantText">
{{ welcomeMessage }}
<div class="msg msg-assistant md" *ngIf="messages.length === 0 && !currentAssistantText"
[innerHTML]="welcomeMessage | markdown">
</div>
<ng-container *ngFor="let m of messages">
<div class="msg" [class.msg-user]="m.role === 'user'" [class.msg-assistant]="m.role === 'assistant'">
{{ m.content }}
</div>
<div *ngIf="m.role === 'user'" class="msg msg-user">{{ m.content }}</div>
<div *ngIf="m.role === 'assistant'" class="msg msg-assistant md"
[innerHTML]="m.content | markdown"></div>
</ng-container>
<div class="msg msg-assistant msg-streaming" *ngIf="currentAssistantText">
{{ currentAssistantText }}<span class="caret"></span>
<div class="msg msg-assistant msg-streaming md" *ngIf="currentAssistantText">
<span [innerHTML]="currentAssistantText | markdown"></span><span class="caret"></span>
</div>
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite">

View File

@@ -7,7 +7,7 @@
position: fixed;
top: 0;
right: 0;
width: 380px;
// La largeur est appliquee en inline-style par le composant (effectiveWidth).
height: 100vh;
background: #0f0f1a;
border-left: 1px solid #1e1e3a;
@@ -19,8 +19,26 @@
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
}
.drawer.with-sidebar {
width: 600px;
// Pendant un drag, on veut que la largeur suive la souris sans interpolation.
.drawer.is-resizing {
transition: transform 0.25s ease;
}
.resize-handle {
position: absolute;
top: 0;
left: -3px;
width: 6px;
height: 100%;
cursor: ew-resize;
z-index: 1;
background: transparent;
transition: background 0.15s;
&:hover,
.drawer.is-resizing & {
background: rgba(108, 99, 255, 0.35);
}
}
.conv-sidebar {
@@ -200,7 +218,8 @@
color: white;
}
.close-btn {
.close-btn,
.wide-toggle-btn {
background: transparent;
border: none;
color: #9ca3af;
@@ -244,6 +263,91 @@
border: 1px solid #2a2a3d;
}
// Rendu markdown dans les messages assistant. On override le white-space
// puisque marked genere des <p>/<li>/<br> et on veut les espacements naturels.
.msg.md {
white-space: normal;
// Marges propres : pas d'espace avant le premier / apres le dernier bloc.
> :first-child { margin-top: 0; }
> :last-child { margin-bottom: 0; }
p { margin: 0 0 0.5em; }
p:last-child { margin-bottom: 0; }
h1, h2, h3, h4, h5, h6 {
margin: 0.75em 0 0.35em;
font-weight: 600;
color: #f3f4f6;
line-height: 1.25;
}
h1 { font-size: 1.05rem; }
h2 { font-size: 1rem; }
h3 { font-size: 0.95rem; }
h4, h5, h6 { font-size: 0.9rem; }
strong { color: #f3f4f6; font-weight: 600; }
em { color: #d1d5db; font-style: italic; }
ul, ol { margin: 0.35em 0 0.5em; padding-left: 1.4em; }
li { margin: 0.15em 0; }
ul ul, ul ol, ol ul, ol ol { margin: 0.15em 0; }
code {
background: #0b0b15;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0 0.25em;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 0.82em;
}
pre {
background: #0b0b15;
border: 1px solid #2a2a3d;
border-radius: 6px;
padding: 0.6em 0.75em;
margin: 0.5em 0;
overflow-x: auto;
font-size: 0.82em;
code {
background: transparent;
border: 0;
padding: 0;
font-size: inherit;
}
}
blockquote {
margin: 0.4em 0;
padding: 0.2em 0.8em;
border-left: 3px solid #3a3a5a;
color: #9ca3af;
font-style: italic;
}
a {
color: #a5b4fc;
text-decoration: underline;
&:hover { color: #c7d2fe; }
}
hr {
border: 0;
border-top: 1px solid #2a2a3d;
margin: 0.6em 0;
}
table {
border-collapse: collapse;
margin: 0.4em 0;
font-size: 0.85em;
th, td { border: 1px solid #2a2a3d; padding: 0.3em 0.5em; }
th { background: #14142a; font-weight: 600; }
}
}
.msg-user {
align-self: flex-end;
background: #6c63ff;

View File

@@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild } from '@angular/core';
import { Component, ElementRef, EventEmitter, HostListener, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Lightbulb, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Send, Sparkles, Trash2, Wand2, X } from 'lucide-angular';
import { LucideAngularModule, Lightbulb, Maximize2, MessageSquarePlus, Minimize2, PanelLeftClose, PanelLeftOpen, Pencil, Send, Sparkles, Trash2, Wand2, X } from 'lucide-angular';
import { Subscription } from 'rxjs';
import { AiChatService, ChatMessage, ChatUsage, NarrativeEntityType } from '../../services/ai-chat.service';
import { Conversation, ConversationContext } from '../../services/conversation.model';
import { ConversationService } from '../../services/conversation.service';
import { MarkdownPipe } from '../markdown.pipe';
/**
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
@@ -30,11 +31,11 @@ export interface ChatPrimaryAction {
@Component({
selector: 'app-ai-chat-drawer',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [CommonModule, FormsModule, LucideAngularModule, MarkdownPipe],
templateUrl: './ai-chat-drawer.component.html',
styleUrls: ['./ai-chat-drawer.component.scss'],
})
export class AiChatDrawerComponent implements OnChanges, OnDestroy {
export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
readonly X = X;
readonly Send = Send;
readonly Sparkles = Sparkles;
@@ -45,6 +46,33 @@ export class AiChatDrawerComponent implements OnChanges, OnDestroy {
readonly PanelLeftOpen = PanelLeftOpen;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly Maximize2 = Maximize2;
readonly Minimize2 = Minimize2;
// --- Redimensionnement --------------------------------------------------
/** Largeur min du drawer en pixels (en dessous ca devient inutilisable). */
private readonly MIN_WIDTH = 340;
/** Largeur max : 95% de la fenetre, pour laisser un peu de page visible. */
private readonly MAX_WIDTH_RATIO = 0.95;
/** Largeurs par defaut selon la presence de la sidebar conversations. */
private readonly DEFAULT_WIDTH = 380;
private readonly DEFAULT_WIDTH_WITH_SIDEBAR = 600;
/** Ratio utilise en mode "agrandi" (bouton Maximize). */
private readonly WIDE_RATIO = 0.6;
private readonly LS_WIDTH = 'ai-chat-drawer-width';
private readonly LS_WIDE = 'ai-chat-drawer-wide';
/** Largeur custom choisie par l'utilisateur via la poignee (null = defaut). */
customWidth: number | null = null;
/** Mode "grand ecran" : prend ~60% de la fenetre, ignore customWidth. */
isWide = false;
/** Drag en cours — utilise pour desactiver les transitions pendant le resize. */
isResizing = false;
private resizeStartX = 0;
private resizeStartWidth = 0;
private readonly onMouseMove = (e: MouseEvent) => this.handleResizeMove(e);
private readonly onMouseUp = () => this.handleResizeEnd();
@Input() loreId = '';
@Input() pageId: string | null = null;
@@ -115,6 +143,88 @@ export class AiChatDrawerComponent implements OnChanges, OnDestroy {
// --- Cycle de vie -------------------------------------------------------
ngOnInit(): void {
this.loadSizePreferences();
}
@HostListener('window:resize')
onWindowResize(): void {
// Clamp si la fenetre a retreci en dessous du max actuel.
if (this.customWidth !== null) {
const max = Math.floor(window.innerWidth * this.MAX_WIDTH_RATIO);
if (this.customWidth > max) this.customWidth = max;
}
}
/** Largeur effective appliquee au drawer (px). */
get effectiveWidth(): number {
if (this.isWide) {
return Math.max(this.MIN_WIDTH, Math.floor(window.innerWidth * this.WIDE_RATIO));
}
if (this.customWidth !== null) return this.customWidth;
return this.persistent && this.sidebarOpen
? this.DEFAULT_WIDTH_WITH_SIDEBAR
: this.DEFAULT_WIDTH;
}
toggleWide(): void {
this.isWide = !this.isWide;
try {
localStorage.setItem(this.LS_WIDE, this.isWide ? '1' : '0');
} catch {}
}
/** Debut du drag : enregistre la position de depart + abonne listeners globaux. */
onResizeStart(event: MouseEvent): void {
if (this.isWide) return; // en mode wide la poignee est desactivee
event.preventDefault();
this.isResizing = true;
this.resizeStartX = event.clientX;
this.resizeStartWidth = this.effectiveWidth;
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
// Empeche la selection de texte pendant le drag.
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
}
private handleResizeMove(event: MouseEvent): void {
if (!this.isResizing) return;
// Le drawer est ancre a droite : largeur augmente quand la souris va a gauche.
const delta = this.resizeStartX - event.clientX;
const max = Math.floor(window.innerWidth * this.MAX_WIDTH_RATIO);
const next = Math.min(max, Math.max(this.MIN_WIDTH, this.resizeStartWidth + delta));
this.customWidth = next;
}
private handleResizeEnd(): void {
if (!this.isResizing) return;
this.isResizing = false;
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (this.customWidth !== null) {
try {
localStorage.setItem(this.LS_WIDTH, String(this.customWidth));
} catch {}
}
}
private loadSizePreferences(): void {
try {
const w = localStorage.getItem(this.LS_WIDTH);
if (w) {
const n = parseInt(w, 10);
if (Number.isFinite(n) && n >= this.MIN_WIDTH) {
const max = Math.floor(window.innerWidth * this.MAX_WIDTH_RATIO);
this.customWidth = Math.min(max, n);
}
}
this.isWide = localStorage.getItem(this.LS_WIDE) === '1';
} catch {}
}
ngOnChanges(changes: SimpleChanges): void {
if (!this.persistent) return;
const contextChanged =
@@ -128,6 +238,8 @@ export class AiChatDrawerComponent implements OnChanges, OnDestroy {
ngOnDestroy(): void {
this.abortStream();
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
}
// --- Sidebar : listing / nouveau / select / rename / delete ------------

View File

@@ -0,0 +1,23 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
/**
* Pipe markdown → HTML sanitise. Utilise pour le rendu des reponses IA.
* Combine marked (parser) + DOMPurify (anti-XSS) puis bypass la sanitization
* Angular puisque le contenu est deja nettoye.
*
* Configure en mode synchrone (`async: false`) pour eviter une Promise.
*/
@Pipe({ name: 'markdown', standalone: true })
export class MarkdownPipe implements PipeTransform {
constructor(private readonly sanitizer: DomSanitizer) {}
transform(value: string | null | undefined): SafeHtml {
if (!value) return '';
const html = marked.parse(value, { async: false, gfm: true, breaks: true }) as string;
const clean = DOMPurify.sanitize(html);
return this.sanitizer.bypassSecurityTrustHtml(clean);
}
}