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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ------------
|
||||
|
||||
23
web/src/app/shared/markdown.pipe.ts
Normal file
23
web/src/app/shared/markdown.pipe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user