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

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

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

View File

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

View File

@@ -1,4 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, EventEmitter, HostListener, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Lightbulb, Maximize2, MessageSquarePlus, Minimize2, PanelLeftClose, PanelLeftOpen, Pencil, Send, Sparkles, Trash2, Wand2, X } from 'lucide-angular';
@@ -31,7 +31,7 @@ export interface ChatPrimaryAction {
*/
@Component({
selector: 'app-ai-chat-drawer',
imports: [CommonModule, FormsModule, LucideAngularModule, MarkdownPipe],
imports: [FormsModule, LucideAngularModule, MarkdownPipe],
templateUrl: './ai-chat-drawer.component.html',
styleUrls: ['./ai-chat-drawer.component.scss']
})

View File

@@ -1,12 +1,20 @@
<nav class="breadcrumb" aria-label="Fil d'Ariane" *ngIf="items.length">
<ol>
<li *ngFor="let item of items; let last = last; trackBy: trackByIndex"
class="breadcrumb-item"
[class.current]="last">
<a *ngIf="item.route && !last"
[routerLink]="item.route"
class="breadcrumb-link">{{ item.label }}</a>
<span *ngIf="!item.route || last" class="breadcrumb-text">{{ item.label }}</span>
</li>
</ol>
</nav>
@if (items.length) {
<nav class="breadcrumb" aria-label="Fil d'Ariane">
<ol>
@for (item of items; track $index; let last = $last) {
<li
class="breadcrumb-item"
[class.current]="last">
@if (item.route && !last) {
<a
[routerLink]="item.route"
class="breadcrumb-link">{{ item.label }}</a>
}
@if (!item.route || last) {
<span class="breadcrumb-text">{{ item.label }}</span>
}
</li>
}
</ol>
</nav>
}

View File

@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
@@ -23,12 +23,11 @@ export interface BreadcrumbItem {
*/
@Component({
selector: 'app-breadcrumb',
imports: [CommonModule, RouterLink],
imports: [RouterLink],
templateUrl: './breadcrumb.component.html',
styleUrls: ['./breadcrumb.component.scss']
})
export class BreadcrumbComponent {
@Input() items: BreadcrumbItem[] = [];
trackByIndex = (i: number) => i;
}

View File

@@ -1,10 +1,12 @@
<div class="chips-wrapper">
<span class="chip" *ngFor="let tag of value; let i = index">
{{ tag }}
<button type="button" class="chip-remove" (click)="remove(i)" title="Retirer">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
</button>
</span>
@for (tag of value; track tag; let i = $index) {
<span class="chip">
{{ tag }}
<button type="button" class="chip-remove" (click)="remove(i)" title="Retirer">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
</button>
</span>
}
<input
type="text"
class="chip-input"

View File

@@ -1,5 +1,5 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X } from 'lucide-angular';
@@ -21,7 +21,7 @@ import { LucideAngularModule, X } from 'lucide-angular';
*/
@Component({
selector: 'app-chips-input',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './chips-input.component.html',
styleUrls: ['./chips-input.component.scss']
})

View File

@@ -7,19 +7,20 @@ import { ConfirmDialogService } from './confirm-dialog.service';
selector: 'app-confirm-dialog-host',
imports: [CommonModule, ConfirmDialogComponent],
template: `
<app-confirm-dialog
*ngIf="(svc.state$ | async) as s"
[open]="s.open"
[title]="s.title"
[message]="s.message"
[details]="s.details"
[confirmLabel]="s.confirmLabel"
[cancelLabel]="s.cancelLabel"
[variant]="s.variant"
(confirmed)="svc.resolve(true)"
(cancelled)="svc.resolve(false)">
</app-confirm-dialog>
`
@if ((svc.state$ | async); as s) {
<app-confirm-dialog
[open]="s.open"
[title]="s.title"
[message]="s.message"
[details]="s.details"
[confirmLabel]="s.confirmLabel"
[cancelLabel]="s.cancelLabel"
[variant]="s.variant"
(confirmed)="svc.resolve(true)"
(cancelled)="svc.resolve(false)">
</app-confirm-dialog>
}
`
})
export class ConfirmDialogHostComponent {
constructor(public svc: ConfirmDialogService) {}

View File

@@ -1,34 +1,36 @@
<div class="confirm-backdrop" *ngIf="open" (click)="onCancel()">
<div class="confirm-modal"
role="dialog"
aria-modal="true"
[attr.aria-label]="title"
[class.variant-warning]="variant === 'warning'"
[class.variant-danger]="variant === 'danger'"
[class.variant-info]="variant === 'info'"
(click)="$event.stopPropagation()">
<div class="confirm-header">
<div class="confirm-icon">
<lucide-icon [img]="TriangleAlert" [size]="22"></lucide-icon>
@if (open) {
<div class="confirm-backdrop" (click)="onCancel()">
<div class="confirm-modal"
role="dialog"
aria-modal="true"
[attr.aria-label]="title"
[class.variant-warning]="variant === 'warning'"
[class.variant-danger]="variant === 'danger'"
[class.variant-info]="variant === 'info'"
(click)="$event.stopPropagation()">
<div class="confirm-header">
<div class="confirm-icon">
<lucide-icon [img]="TriangleAlert" [size]="22"></lucide-icon>
</div>
<h2>{{ title }}</h2>
<button type="button" class="btn-close" (click)="onCancel()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</div>
<div class="confirm-body">
<p class="confirm-message">{{ message }}</p>
@if (details.length > 0) {
<ul class="confirm-details">
@for (line of details; track line) {
<li>{{ line }}</li>
}
</ul>
}
</div>
<div class="confirm-actions">
<button type="button" class="btn-secondary" (click)="onCancel()">{{ cancelLabel }}</button>
<button type="button" class="btn-confirm" (click)="onConfirm()">{{ confirmLabel }}</button>
</div>
<h2>{{ title }}</h2>
<button type="button" class="btn-close" (click)="onCancel()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</div>
<div class="confirm-body">
<p class="confirm-message">{{ message }}</p>
<ul *ngIf="details.length > 0" class="confirm-details">
<li *ngFor="let line of details">{{ line }}</li>
</ul>
</div>
<div class="confirm-actions">
<button type="button" class="btn-secondary" (click)="onCancel()">{{ cancelLabel }}</button>
<button type="button" class="btn-confirm" (click)="onConfirm()">{{ confirmLabel }}</button>
</div>
</div>
</div>
}

View File

@@ -1,12 +1,12 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, TriangleAlert, X } from 'lucide-angular';
export type ConfirmDialogVariant = 'warning' | 'danger' | 'info';
@Component({
selector: 'app-confirm-dialog',
imports: [CommonModule, LucideAngularModule],
imports: [LucideAngularModule],
templateUrl: './confirm-dialog.component.html',
styleUrls: ['./confirm-dialog.component.scss']
})

View File

@@ -1,56 +1,64 @@
<div class="dff" *ngIf="fields?.length; else emptyTpl">
<div class="dff-field" *ngFor="let f of fields; trackBy: trackByName">
<label>{{ f.name }}</label>
<ng-container [ngSwitch]="f.type">
<textarea
*ngSwitchCase="'TEXT'"
rows="4"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="Renseignez {{ f.name }}…">
</textarea>
<input
*ngSwitchCase="'NUMBER'"
type="number"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="0"
/>
<app-image-gallery
*ngSwitchCase="'IMAGE'"
[editable]="true"
[layout]="f.layout || 'GALLERY'"
[imageIds]="imagesFor(f)"
(imageIdsChange)="onImageIdsChange(f, $event)">
</app-image-gallery>
<!-- KEY_VALUE_LIST : grille d'inputs avec labels figes du template -->
<div *ngSwitchCase="'KEY_VALUE_LIST'" class="dff-kv-grid">
<div class="dff-kv-cell" *ngFor="let lbl of f.labels; trackBy: trackByLabel">
<span class="dff-kv-label">{{ lbl }}</span>
<input
type="text"
[ngModel]="kvValue(f, lbl)"
(ngModelChange)="onKvChange(f, lbl, $event)"
[name]="'kv-' + f.name + '-' + lbl"
placeholder="—"
/>
</div>
<div *ngIf="!f.labels?.length" class="dff-kv-empty">
Aucun label defini dans le template pour ce champ.
</div>
@if (fields?.length) {
<div class="dff">
@for (f of fields; track trackByName($index, f)) {
<div class="dff-field">
<label>{{ f.name }}</label>
@switch (f.type) {
@case ('TEXT') {
<textarea
rows="4"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="Renseignez {{ f.name }}…">
</textarea>
}
@case ('NUMBER') {
<input
type="number"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="0"
/>
}
@case ('IMAGE') {
<app-image-gallery
[editable]="true"
[layout]="f.layout || 'GALLERY'"
[imageIds]="imagesFor(f)"
(imageIdsChange)="onImageIdsChange(f, $event)">
</app-image-gallery>
}
<!-- KEY_VALUE_LIST : grille d'inputs avec labels figes du template -->
@case ('KEY_VALUE_LIST') {
<div class="dff-kv-grid">
@for (lbl of f.labels; track trackByLabel($index, lbl)) {
<div class="dff-kv-cell">
<span class="dff-kv-label">{{ lbl }}</span>
<input
type="text"
[ngModel]="kvValue(f, lbl)"
(ngModelChange)="onKvChange(f, lbl, $event)"
[name]="'kv-' + f.name + '-' + lbl"
placeholder="—"
/>
</div>
}
@if (!f.labels?.length) {
<div class="dff-kv-empty">
Aucun label defini dans le template pour ce champ.
</div>
}
</div>
}
}
</div>
</ng-container>
}
</div>
</div>
<ng-template #emptyTpl>
} @else {
<div class="dff-empty">
Aucun champ defini dans le template de ce systeme. Editez le GameSystem pour ajouter des champs.
</div>
</ng-template>
}

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TemplateField } from '../../services/template.model';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
@@ -17,7 +17,7 @@ import { ImageGalleryComponent } from '../image-gallery/image-gallery.component'
*/
@Component({
selector: 'app-dynamic-fields-form',
imports: [CommonModule, FormsModule, ImageGalleryComponent],
imports: [FormsModule, ImageGalleryComponent],
templateUrl: './dynamic-fields-form.component.html',
styleUrls: ['./dynamic-fields-form.component.scss']
})

View File

@@ -2,14 +2,18 @@
<button type="button" class="section-header" (click)="toggle()">
<span class="section-title">
<span class="section-icon" *ngIf="icon">{{ icon }}</span>
@if (icon) {
<span class="section-icon">{{ icon }}</span>
}
{{ title }}
</span>
<lucide-icon [img]="isOpen ? ChevronUp : ChevronDown" [size]="16"></lucide-icon>
</button>
<div class="section-content" *ngIf="isOpen">
<ng-content></ng-content>
</div>
@if (isOpen) {
<div class="section-content">
<ng-content></ng-content>
</div>
}
</div>

View File

@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
/**
@@ -13,7 +13,7 @@ import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
*/
@Component({
selector: 'app-expandable-section',
imports: [CommonModule, LucideAngularModule],
imports: [LucideAngularModule],
templateUrl: './expandable-section.component.html',
styleUrls: ['./expandable-section.component.scss']
})

View File

@@ -1,51 +1,59 @@
<div class="gs-overlay" *ngIf="open" (click)="close()">
<div class="gs-modal" (click)="$event.stopPropagation()">
<div class="gs-input-row">
<lucide-icon [img]="Search" [size]="18" class="gs-search-icon"></lucide-icon>
<input
#searchInput
type="text"
class="gs-input"
placeholder="Rechercher dans LoreMind..."
[ngModel]="query"
(ngModelChange)="onQueryChange($event)" />
<span class="gs-kbd">ESC</span>
</div>
<div class="gs-results" *ngIf="results.length > 0">
<button
*ngFor="let r of results; let i = index"
class="gs-result"
[class.active]="i === activeIndex"
(mouseenter)="activeIndex = i"
(click)="select(r)">
<lucide-icon [img]="iconFor(r.kind)" [size]="16" class="gs-result-icon"></lucide-icon>
<div class="gs-result-body">
<div class="gs-result-title">{{ r.title }}</div>
<div class="gs-result-subtitle" *ngIf="r.subtitle">{{ r.subtitle }}</div>
<div class="gs-result-tag">{{ r.tag }}</div>
</div>
</button>
</div>
<div class="gs-empty" *ngIf="!loading && query.trim().length >= 2 && results.length === 0">
Aucun résultat
</div>
<div class="gs-empty" *ngIf="query.trim().length < 2">
Tape au moins 2 caractères
</div>
<div class="gs-footer">
<div class="gs-hints">
<span><kbd></kbd><kbd></kbd> naviguer</span>
<span><kbd></kbd> sélectionner</span>
<span><kbd>ESC</kbd> fermer</span>
@if (open) {
<div class="gs-overlay" (click)="close()">
<div class="gs-modal" (click)="$event.stopPropagation()">
<div class="gs-input-row">
<lucide-icon [img]="Search" [size]="18" class="gs-search-icon"></lucide-icon>
<input
#searchInput
type="text"
class="gs-input"
placeholder="Rechercher dans LoreMind..."
[ngModel]="query"
(ngModelChange)="onQueryChange($event)" />
<span class="gs-kbd">ESC</span>
</div>
@if (results.length > 0) {
<div class="gs-results">
@for (r of results; track r; let i = $index) {
<button
class="gs-result"
[class.active]="i === activeIndex"
(mouseenter)="activeIndex = i"
(click)="select(r)">
<lucide-icon [img]="iconFor(r.kind)" [size]="16" class="gs-result-icon"></lucide-icon>
<div class="gs-result-body">
<div class="gs-result-title">{{ r.title }}</div>
@if (r.subtitle) {
<div class="gs-result-subtitle">{{ r.subtitle }}</div>
}
<div class="gs-result-tag">{{ r.tag }}</div>
</div>
</button>
}
</div>
}
@if (!loading && query.trim().length >= 2 && results.length === 0) {
<div class="gs-empty">
Aucun résultat
</div>
}
@if (query.trim().length < 2) {
<div class="gs-empty">
Tape au moins 2 caractères
</div>
}
<div class="gs-footer">
<div class="gs-hints">
<span><kbd></kbd><kbd></kbd> naviguer</span>
<span><kbd></kbd> sélectionner</span>
<span><kbd>ESC</kbd> fermer</span>
</div>
@if (results.length > 0) {
<span class="gs-count">
{{ results.length }} résultat{{ results.length > 1 ? 's' : '' }}
</span>
}
</div>
<span class="gs-count" *ngIf="results.length > 0">
{{ results.length }} résultat{{ results.length > 1 ? 's' : '' }}
</span>
</div>
</div>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { BehaviorSubject, Subject, forkJoin, of } from 'rxjs';
@@ -31,7 +31,7 @@ interface SearchResult {
*/
@Component({
selector: 'app-global-search',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './global-search.component.html',
styleUrls: ['./global-search.component.scss']
})

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, LucideIconData } from 'lucide-angular';
export interface IconPickerOption {
@@ -20,21 +20,22 @@ export interface IconPickerOption {
*/
@Component({
selector: 'app-icon-picker',
imports: [CommonModule, LucideAngularModule],
imports: [LucideAngularModule],
template: `
<div class="icon-grid">
<button
type="button"
class="icon-btn"
*ngFor="let option of options"
[class.selected]="selected === option.key"
[attr.aria-pressed]="selected === option.key"
[title]="option.key"
(click)="pick(option.key)">
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
</button>
@for (option of options; track option) {
<button
type="button"
class="icon-btn"
[class.selected]="selected === option.key"
[attr.aria-pressed]="selected === option.key"
[title]="option.key"
(click)="pick(option.key)">
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
</button>
}
</div>
`,
`,
styleUrls: ['./icon-picker.component.scss']
})
export class IconPickerComponent {

View File

@@ -1,201 +1,261 @@
<!-- Container avec classe dynamique selon le layout choisi. -->
<div [ngSwitch]="effectiveLayout" class="gallery-root">
<!-- =================== HERO =================== -->
<ng-container *ngSwitchCase="'HERO'">
<div class="hero" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="hero-main"
*ngIf="heroId"
(click)="openLightbox(heroId)"
role="button"
tabindex="0">
<img [src]="urlFor(heroId)" [alt]="'Illustration principale'" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(heroId, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="hero-rest" *ngIf="restIds.length > 0 || editable">
<div class="gallery-tile hero-thumb"
*ngFor="let id of restIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
<div class="gallery-root">
@switch (effectiveLayout) {
<!-- =================== HERO =================== -->
@case ('HERO') {
@if (imageIds.length > 0 || editable) {
<div class="hero">
@if (heroId) {
<div class="hero-main"
(click)="openLightbox(heroId)"
role="button"
tabindex="0">
<img [src]="urlFor(heroId)" [alt]="'Illustration principale'" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(heroId, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
</div>
}
@if (restIds.length > 0 || editable) {
<div class="hero-rest">
@for (id of restIds; track id) {
<div class="gallery-tile hero-thumb"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
</div>
}
@if (editable) {
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
}
</div>
}
<!-- Si pas de hero mais editable, on montre au moins l'uploader. -->
@if (!heroId && editable) {
<div class="hero-rest">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
}
</div>
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
</div>
}
}
<!-- =================== MASONRY =================== -->
@case ('MASONRY') {
@if (imageIds.length > 0 || editable) {
<div class="masonry">
@for (id of imageIds; track id) {
<div class="masonry-item"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
</div>
}
@if (editable) {
<div class="masonry-item masonry-uploader">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
}
</div>
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
<!-- Si pas de hero mais editable, on montre au moins l'uploader. -->
<div class="hero-rest" *ngIf="!heroId && editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== MASONRY =================== -->
<ng-container *ngSwitchCase="'MASONRY'">
<div class="masonry" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="masonry-item"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="masonry-item masonry-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== CAROUSEL =================== -->
<ng-container *ngSwitchCase="'CAROUSEL'">
<div class="carousel" *ngIf="imageIds.length > 0 || editable; else empty">
<button type="button"
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
</div>
}
}
<!-- =================== CAROUSEL =================== -->
@case ('CAROUSEL') {
@if (imageIds.length > 0 || editable) {
<div class="carousel">
@if (imageIds.length > 1) {
<button type="button"
class="carousel-nav carousel-prev"
*ngIf="imageIds.length > 1"
(click)="scrollCarousel(-1)"
aria-label="Precedent">
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
</button>
<div class="carousel-track" #carouselTrack>
<div class="carousel-slide"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="carousel-slide carousel-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
<button type="button"
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
</button>
}
<div class="carousel-track" #carouselTrack>
@for (id of imageIds; track id) {
<div class="carousel-slide"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
</div>
}
@if (editable) {
<div class="carousel-slide carousel-uploader">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
}
</div>
@if (imageIds.length > 1) {
<button type="button"
class="carousel-nav carousel-next"
*ngIf="imageIds.length > 1"
(click)="scrollCarousel(1)"
aria-label="Suivant">
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
</button>
</div>
</ng-container>
<!-- =================== EDITORIAL =================== -->
<!-- Rendu adaptatif facon magazine : 1 image → hero, 2 → diptyque, 3 → feature + 2 satellites, 4+ → feature + 3 satellites. -->
<ng-container *ngSwitchCase="'EDITORIAL'">
<div class="editorial" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="editorial-item"
*ngFor="let id of imageIds; let i = index"
[class.editorial-feature]="i === 0"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="editorial-item editorial-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== MAPS =================== -->
<!-- Cartes / plans : grandes vignettes, ratio natif preserve (pas de crop). -->
<ng-container *ngSwitchCase="'MAPS'">
<div class="maps" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="map-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Carte ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette carte">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="map-tile map-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== GALLERY (default) =================== -->
<ng-container *ngSwitchDefault>
<div class="gallery" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="gallery-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</ng-container>
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
</button>
}
</div>
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
</div>
}
}
<!-- =================== EDITORIAL =================== -->
<!-- Rendu adaptatif facon magazine : 1 image → hero, 2 → diptyque, 3 → feature + 2 satellites, 4+ → feature + 3 satellites. -->
@case ('EDITORIAL') {
@if (imageIds.length > 0 || editable) {
<div class="editorial">
@for (id of imageIds; track id; let i = $index) {
<div class="editorial-item"
[class.editorial-feature]="i === 0"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
</div>
}
@if (editable) {
<div class="editorial-item editorial-uploader">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
}
</div>
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
</div>
}
}
<!-- =================== MAPS =================== -->
<!-- Cartes / plans : grandes vignettes, ratio natif preserve (pas de crop). -->
@case ('MAPS') {
@if (imageIds.length > 0 || editable) {
<div class="maps">
@for (id of imageIds; track id) {
<div class="map-tile"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Carte ' + id" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette carte">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
</div>
}
@if (editable) {
<div class="map-tile map-uploader">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
}
</div>
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
</div>
}
}
<!-- =================== GALLERY (default) =================== -->
@default {
@if (imageIds.length > 0 || editable) {
<div class="gallery">
@for (id of imageIds; track id) {
<div class="gallery-tile"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
</div>
}
@if (editable) {
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
}
</div>
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
</div>
}
}
}
</div>
<!-- Etat vide (lecture uniquement). -->
<ng-template #empty>
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
</div>
</ng-template>
<!-- Lightbox : image plein ecran sur fond noir, clic pour fermer. -->
<div class="lightbox-backdrop"
*ngIf="lightboxId"
(click)="closeLightbox()"
role="dialog"
aria-label="Image en plein ecran">
<img [src]="urlFor(lightboxId)" alt="Image agrandie" class="lightbox-image" />
<button type="button" class="lightbox-close" (click)="closeLightbox()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="24"></lucide-icon>
</button>
</div>
@if (lightboxId) {
<div class="lightbox-backdrop"
(click)="closeLightbox()"
role="dialog"
aria-label="Image en plein ecran">
<img [src]="urlFor(lightboxId)" alt="Image agrandie" class="lightbox-image" />
<button type="button" class="lightbox-close" (click)="closeLightbox()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="24"></lucide-icon>
</button>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, X, Image as ImageIcon, ChevronLeft, ChevronRight } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
@@ -27,7 +27,7 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
*/
@Component({
selector: 'app-image-gallery',
imports: [CommonModule, LucideAngularModule, ImageUploaderComponent],
imports: [LucideAngularModule, ImageUploaderComponent],
templateUrl: './image-gallery.component.html',
styleUrls: ['./image-gallery.component.scss']
})

View File

@@ -1,51 +1,53 @@
<!-- Mode compact : bouton "+ ajouter" carre, utilise dans les galeries. -->
<ng-container *ngIf="compact; else dropZone">
@if (compact) {
<label class="upload-compact" [class.loading]="uploading" [title]="errorMessage || 'Ajouter une image'">
<ng-container *ngIf="!uploading; else spinner">
@if (!uploading) {
<lucide-icon [img]="Upload" [size]="18"></lucide-icon>
<span>Ajouter</span>
</ng-container>
} @else {
<div class="upload-spinner" aria-label="Upload en cours"></div>
<p class="upload-zone-hint">Upload en cours...</p>
}
<input type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
(change)="onFileSelected($event)"
[disabled]="uploading"
hidden />
accept="image/jpeg,image/png,image/webp,image/gif"
(change)="onFileSelected($event)"
[disabled]="uploading"
hidden />
</label>
<p *ngIf="errorMessage" class="upload-error-inline">
<lucide-icon [img]="AlertCircle" [size]="12"></lucide-icon>
{{ errorMessage }}
</p>
</ng-container>
<!-- Mode standard : grande drop-zone cliquable. -->
<ng-template #dropZone>
@if (errorMessage) {
<p class="upload-error-inline">
<lucide-icon [img]="AlertCircle" [size]="12"></lucide-icon>
{{ errorMessage }}
</p>
}
} @else {
<label class="upload-zone"
[class.drag-over]="dragOver"
[class.loading]="uploading"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave()"
(drop)="onDrop($event)">
<ng-container *ngIf="!uploading; else spinner">
[class.drag-over]="dragOver"
[class.loading]="uploading"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave()"
(drop)="onDrop($event)">
@if (!uploading) {
<lucide-icon [img]="Upload" [size]="32"></lucide-icon>
<p class="upload-zone-title">Glisse une image ici</p>
<p class="upload-zone-hint">ou clique pour choisir un fichier (JPEG, PNG, WebP, GIF, max 10 Mo)</p>
</ng-container>
} @else {
<div class="upload-spinner" aria-label="Upload en cours"></div>
<p class="upload-zone-hint">Upload en cours...</p>
}
<input type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
(change)="onFileSelected($event)"
[disabled]="uploading"
hidden />
accept="image/jpeg,image/png,image/webp,image/gif"
(change)="onFileSelected($event)"
[disabled]="uploading"
hidden />
</label>
@if (errorMessage) {
<p class="upload-error" role="alert">
<lucide-icon [img]="AlertCircle" [size]="14"></lucide-icon>
{{ errorMessage }}
</p>
}
}
<p *ngIf="errorMessage" class="upload-error" role="alert">
<lucide-icon [img]="AlertCircle" [size]="14"></lucide-icon>
{{ errorMessage }}
</p>
</ng-template>
<!-- Mode standard : grande drop-zone cliquable. -->
<ng-template #spinner>
<div class="upload-spinner" aria-label="Upload en cours"></div>
<p class="upload-zone-hint">Upload en cours...</p>
</ng-template>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Upload, AlertCircle } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
@@ -19,7 +19,7 @@ import { Image } from '../../services/image.model';
*/
@Component({
selector: 'app-image-uploader',
imports: [CommonModule, LucideAngularModule],
imports: [LucideAngularModule],
templateUrl: './image-uploader.component.html',
styleUrls: ['./image-uploader.component.scss']
})

View File

@@ -1,22 +1,26 @@
<div class="picker">
<!-- Chips des pages déjà liées -->
<div class="linked-chips" *ngIf="linkedPages.length > 0">
<span class="chip" *ngFor="let p of linkedPages">
<a
class="chip-label"
[href]="pageUrl(p.id!)"
target="_blank"
rel="noopener"
title="Ouvrir dans un nouvel onglet">
<lucide-icon [img]="Link2" [size]="12"></lucide-icon>
{{ p.title }}
</a>
<button type="button" class="chip-remove" (click)="remove(p.id!)" title="Retirer le lien">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
</button>
</span>
</div>
@if (linkedPages.length > 0) {
<div class="linked-chips">
@for (p of linkedPages; track p) {
<span class="chip">
<a
class="chip-label"
[href]="pageUrl(p.id!)"
target="_blank"
rel="noopener"
title="Ouvrir dans un nouvel onglet">
<lucide-icon [img]="Link2" [size]="12"></lucide-icon>
{{ p.title }}
</a>
<button type="button" class="chip-remove" (click)="remove(p.id!)" title="Retirer le lien">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
</button>
</span>
}
</div>
}
<!-- Input + dropdown de suggestions -->
<div class="search-wrapper">
@@ -28,15 +32,21 @@
(focus)="dropdownOpen = true"
(blur)="onBlur()" />
<ul class="suggestions" *ngIf="dropdownOpen && suggestions.length > 0">
<li *ngFor="let p of suggestions" (mousedown)="add(p)">
{{ p.title }}
</li>
</ul>
@if (dropdownOpen && suggestions.length > 0) {
<ul class="suggestions">
@for (p of suggestions; track p) {
<li (mousedown)="add(p)">
{{ p.title }}
</li>
}
</ul>
}
<p class="empty-hint" *ngIf="dropdownOpen && query && suggestions.length === 0">
Aucune page ne correspond
</p>
@if (dropdownOpen && query && suggestions.length === 0) {
<p class="empty-hint">
Aucune page ne correspond
</p>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X, Link2 } from 'lucide-angular';
import { Page } from '../../services/page.model';
@@ -26,7 +26,7 @@ import { Page } from '../../services/page.model';
*/
@Component({
selector: 'app-lore-link-picker',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './lore-link-picker.component.html',
styleUrls: ['./lore-link-picker.component.scss']
})

View File

@@ -1,108 +1,131 @@
<div class="pv" *ngIf="persona">
<!-- Bandeau / Header -->
<div class="pv-banner" *ngIf="persona.headerImageId">
<img [src]="contentUrl(persona.headerImageId)" alt="" />
<div class="pv-banner-fade"></div>
</div>
<!-- En-tete : portrait + titre -->
<div class="pv-hero" [class.no-banner]="!persona.headerImageId">
<div class="pv-portrait" *ngIf="persona.portraitImageId">
<img [src]="contentUrl(persona.portraitImageId)" alt="" />
</div>
<div class="pv-title-block">
<h1 class="pv-name">{{ persona.name }}</h1>
<p *ngIf="subtitle" class="pv-subtitle">{{ subtitle }}</p>
<!-- Badges des NUMBER isoles (rendu compact, evite la grosse card pour 1 valeur) -->
<div *ngIf="heroBadges.length" class="pv-hero-badges">
<span *ngFor="let b of heroBadges" class="pv-hero-badge">
<span class="pv-hero-badge-label">{{ b.label }}</span>
<span class="pv-hero-badge-value">{{ b.value }}</span>
</span>
@if (persona) {
<div class="pv">
<!-- Bandeau / Header -->
@if (persona.headerImageId) {
<div class="pv-banner">
<img [src]="contentUrl(persona.headerImageId)" alt="" />
<div class="pv-banner-fade"></div>
</div>
}
<!-- En-tete : portrait + titre -->
<div class="pv-hero" [class.no-banner]="!persona.headerImageId">
@if (persona.portraitImageId) {
<div class="pv-portrait">
<img [src]="contentUrl(persona.portraitImageId)" alt="" />
</div>
}
<div class="pv-title-block">
<h1 class="pv-name">{{ persona.name }}</h1>
@if (subtitle) {
<p class="pv-subtitle">{{ subtitle }}</p>
}
<!-- Badges des NUMBER isoles (rendu compact, evite la grosse card pour 1 valeur) -->
@if (heroBadges.length) {
<div class="pv-hero-badges">
@for (b of heroBadges; track b) {
<span class="pv-hero-badge">
<span class="pv-hero-badge-label">{{ b.label }}</span>
<span class="pv-hero-badge-value">{{ b.value }}</span>
</span>
}
</div>
}
</div>
</div>
</div>
<!-- Sections rendues dans l'ordre du template -->
<div class="pv-sections">
<ng-container *ngFor="let s of orderedSections">
<!-- TEXT -->
<section *ngIf="s.kind === 'TEXT'" class="pv-section">
<h2 class="pv-section-title">{{ s.name }}</h2>
<div class="pv-section-body">
<p class="pv-paragraph">
{{ firstParagraph(s.value) }}
</p>
<p *ngIf="restAfterFirstParagraph(s.value)" class="pv-paragraph">
{{ restAfterFirstParagraph(s.value) }}
</p>
<!-- Sections rendues dans l'ordre du template -->
<div class="pv-sections">
@for (s of orderedSections; track s) {
<!-- TEXT -->
@if (s.kind === 'TEXT') {
<section class="pv-section">
<h2 class="pv-section-title">{{ s.name }}</h2>
<div class="pv-section-body">
<p class="pv-paragraph">
{{ firstParagraph(s.value) }}
</p>
@if (restAfterFirstParagraph(s.value)) {
<p class="pv-paragraph">
{{ restAfterFirstParagraph(s.value) }}
</p>
}
</div>
</section>
}
<!-- NUMBER_GROUP : NUMBER consecutifs - table si <=6, sinon liste 2 cols -->
@if (s.kind === 'NUMBER_GROUP') {
<section class="pv-section pv-section-number">
@if (s.entries.length <= 6) {
<div class="pv-kv-table" [style.--cols]="s.entries.length">
<div class="pv-kv-row pv-kv-row-labels">
@for (e of s.entries; track e) {
<div class="pv-kv-cell">{{ e.label }}</div>
}
</div>
<div class="pv-kv-row pv-kv-row-values">
@for (e of s.entries; track e) {
<div class="pv-kv-cell">{{ e.value }}</div>
}
</div>
</div>
} @else {
<div class="pv-kv-list">
@for (e of s.entries; track e) {
<div class="pv-kv-list-row">
<span class="pv-kv-list-label">{{ e.label }}</span>
<span class="pv-kv-list-dots"></span>
<span class="pv-kv-list-value">{{ e.value }}</span>
</div>
}
</div>
}
</section>
}
<!-- KEY_VALUE_LIST : table style Foundry si <=6, sinon liste 2 cols (skills) -->
@if (s.kind === 'KEY_VALUE_LIST') {
<section class="pv-section pv-section-kv">
<h2 class="pv-section-title">{{ s.name }}</h2>
@if (s.entries.length <= 6) {
<div class="pv-kv-table" [style.--cols]="s.entries.length">
<div class="pv-kv-row pv-kv-row-labels">
@for (e of s.entries; track e) {
<div class="pv-kv-cell">{{ e.label }}</div>
}
</div>
<div class="pv-kv-row pv-kv-row-values">
@for (e of s.entries; track e) {
<div class="pv-kv-cell">{{ e.value || '—' }}</div>
}
</div>
</div>
} @else {
<div class="pv-kv-list">
@for (e of s.entries; track e) {
<div class="pv-kv-list-row">
<span class="pv-kv-list-label">{{ e.label }}</span>
<span class="pv-kv-list-dots"></span>
<span class="pv-kv-list-value">{{ e.value || '—' }}</span>
</div>
}
</div>
}
</section>
}
<!-- IMAGE : galerie -->
@if (s.kind === 'IMAGE') {
<section class="pv-section pv-section-images">
<h2 class="pv-section-title">{{ s.name }}</h2>
<app-image-gallery [imageIds]="s.ids" [layout]="s.layout" [editable]="false">
</app-image-gallery>
</section>
}
}
<!-- Etat vide -->
@if (orderedSections.length === 0) {
<div class="pv-empty">
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
<p>Cette fiche est encore vide.</p>
</div>
</section>
<!-- NUMBER_GROUP : NUMBER consecutifs - table si <=6, sinon liste 2 cols -->
<section *ngIf="s.kind === 'NUMBER_GROUP'" class="pv-section pv-section-number">
<ng-container *ngIf="s.entries.length <= 6; else numGroupList">
<div class="pv-kv-table" [style.--cols]="s.entries.length">
<div class="pv-kv-row pv-kv-row-labels">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.label }}</div>
</div>
<div class="pv-kv-row pv-kv-row-values">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.value }}</div>
</div>
</div>
</ng-container>
<ng-template #numGroupList>
<div class="pv-kv-list">
<div class="pv-kv-list-row" *ngFor="let e of s.entries">
<span class="pv-kv-list-label">{{ e.label }}</span>
<span class="pv-kv-list-dots"></span>
<span class="pv-kv-list-value">{{ e.value }}</span>
</div>
</div>
</ng-template>
</section>
<!-- KEY_VALUE_LIST : table style Foundry si <=6, sinon liste 2 cols (skills) -->
<section *ngIf="s.kind === 'KEY_VALUE_LIST'" class="pv-section pv-section-kv">
<h2 class="pv-section-title">{{ s.name }}</h2>
<ng-container *ngIf="s.entries.length <= 6; else kvList">
<div class="pv-kv-table" [style.--cols]="s.entries.length">
<div class="pv-kv-row pv-kv-row-labels">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.label }}</div>
</div>
<div class="pv-kv-row pv-kv-row-values">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.value || '—' }}</div>
</div>
</div>
</ng-container>
<ng-template #kvList>
<div class="pv-kv-list">
<div class="pv-kv-list-row" *ngFor="let e of s.entries">
<span class="pv-kv-list-label">{{ e.label }}</span>
<span class="pv-kv-list-dots"></span>
<span class="pv-kv-list-value">{{ e.value || '—' }}</span>
</div>
</div>
</ng-template>
</section>
<!-- IMAGE : galerie -->
<section *ngIf="s.kind === 'IMAGE'" class="pv-section pv-section-images">
<h2 class="pv-section-title">{{ s.name }}</h2>
<app-image-gallery [imageIds]="s.ids" [layout]="s.layout" [editable]="false">
</app-image-gallery>
</section>
</ng-container>
<!-- Etat vide -->
<div *ngIf="orderedSections.length === 0" class="pv-empty">
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
<p>Cette fiche est encore vide.</p>
}
</div>
</div>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { TemplateField, ImageLayout } from '../../services/template.model';
@@ -34,7 +34,7 @@ export interface PersonaLike {
@Component({
selector: 'app-persona-view',
imports: [CommonModule, LucideAngularModule, ImageGalleryComponent],
imports: [LucideAngularModule, ImageGalleryComponent],
templateUrl: './persona-view.component.html',
styleUrls: ['./persona-view.component.scss']
})

View File

@@ -1,21 +1,27 @@
<div class="flags-manager">
<p class="flags-empty" *ngIf="!loading && rows.length === 0">
Aucun fait référencé. Ajoutez une condition « Quand un fait est vrai » sur
au moins une quête pour qu'il apparaisse ici.
</p>
@if (!loading && rows.length === 0) {
<p class="flags-empty">
Aucun fait référencé. Ajoutez une condition « Quand un fait est vrai » sur
au moins une quête pour qu'il apparaisse ici.
</p>
}
<ul class="flag-list" *ngIf="rows.length > 0">
<li class="flag-row" *ngFor="let r of rows" [class.flag-on]="r.value">
<label class="flag-toggle">
<input type="checkbox" [checked]="r.value" (change)="toggle(r)" />
<span class="flag-toggle-slider"></span>
</label>
<div class="flag-info">
<span class="flag-name">{{ r.name }}</span>
</div>
<span class="flag-value">{{ r.value ? 'vrai' : 'faux' }}</span>
</li>
</ul>
@if (rows.length > 0) {
<ul class="flag-list">
@for (r of rows; track r) {
<li class="flag-row" [class.flag-on]="r.value">
<label class="flag-toggle">
<input type="checkbox" [checked]="r.value" (change)="toggle(r)" />
<span class="flag-toggle-slider"></span>
</label>
<div class="flag-info">
<span class="flag-name">{{ r.name }}</span>
</div>
<span class="flag-value">{{ r.value ? 'vrai' : 'faux' }}</span>
</li>
}
</ul>
}
</div>

View File

@@ -1,5 +1,5 @@
import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignFlagService } from '../../services/campaign-flag.service';
@@ -15,7 +15,7 @@ import { forkJoin } from 'rxjs';
*/
@Component({
selector: 'app-playthrough-flags-manager',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './playthrough-flags-manager.component.html',
styleUrls: ['./playthrough-flags-manager.component.scss']
})

View File

@@ -1,60 +1,66 @@
<div class="prereq-editor">
<p class="prereq-editor-empty" *ngIf="prerequisites.length === 0">
Aucune condition — la quête est immédiatement disponible.
</p>
@if (prerequisites.length === 0) {
<p class="prereq-editor-empty">
Aucune condition — la quête est immédiatement disponible.
</p>
}
<ul class="prereq-list" *ngIf="prerequisites.length > 0">
<li class="prereq-row" *ngFor="let p of prerequisites; let i = index; trackBy: trackByIndex">
<ng-container [ngSwitch]="p.kind">
<!-- QUEST_COMPLETED -->
<ng-container *ngSwitchCase="'QUEST_COMPLETED'">
<span class="prereq-row-label">Quête terminée :</span>
<select class="prereq-input"
[ngModel]="asQuestCompleted(p).questId"
(ngModelChange)="onQuestIdChange(i, $event)"
[ngModelOptions]="{ standalone: true }"
[disabled]="availableQuests.length === 0">
<option *ngIf="availableQuests.length === 0" value="">(aucune autre quête)</option>
<option *ngFor="let q of availableQuests" [value]="q.id">{{ q.name }}</option>
</select>
</ng-container>
<!-- SESSION_REACHED -->
<ng-container *ngSwitchCase="'SESSION_REACHED'">
<span class="prereq-row-label">À partir de la session :</span>
<input type="number"
class="prereq-input prereq-input-number"
min="1"
[ngModel]="asSessionReached(p).minSessionNumber"
(ngModelChange)="onMinSessionChange(i, $event)"
[ngModelOptions]="{ standalone: true }" />
</ng-container>
<!-- FLAG_SET -->
<ng-container *ngSwitchCase="'FLAG_SET'">
<span class="prereq-row-label">Quand le fait est vrai :</span>
<input type="text"
class="prereq-input"
placeholder="nom_du_fait"
[attr.list]="'prereq-flag-list-' + i"
[ngModel]="asFlagSet(p).flagName"
(ngModelChange)="onFlagNameChange(i, $event)"
[ngModelOptions]="{ standalone: true }" />
<datalist [attr.id]="'prereq-flag-list-' + i">
<option *ngFor="let f of availableFlags" [value]="f"></option>
</datalist>
</ng-container>
</ng-container>
<button type="button" class="prereq-remove" (click)="removeAt(i)" title="Retirer ce prérequis">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
</ul>
@if (prerequisites.length > 0) {
<ul class="prereq-list">
@for (p of prerequisites; track $index; let i = $index) {
<li class="prereq-row">
@switch (p.kind) {
<!-- QUEST_COMPLETED -->
@case ('QUEST_COMPLETED') {
<span class="prereq-row-label">Quête terminée :</span>
<select class="prereq-input"
[ngModel]="asQuestCompleted(p).questId"
(ngModelChange)="onQuestIdChange(i, $event)"
[ngModelOptions]="{ standalone: true }"
[disabled]="availableQuests.length === 0">
@if (availableQuests.length === 0) {
<option value="">(aucune autre quête)</option>
}
@for (q of availableQuests; track q) {
<option [value]="q.id">{{ q.name }}</option>
}
</select>
}
<!-- SESSION_REACHED -->
@case ('SESSION_REACHED') {
<span class="prereq-row-label">À partir de la session :</span>
<input type="number"
class="prereq-input prereq-input-number"
min="1"
[ngModel]="asSessionReached(p).minSessionNumber"
(ngModelChange)="onMinSessionChange(i, $event)"
[ngModelOptions]="{ standalone: true }" />
}
<!-- FLAG_SET -->
@case ('FLAG_SET') {
<span class="prereq-row-label">Quand le fait est vrai :</span>
<input type="text"
class="prereq-input"
placeholder="nom_du_fait"
[attr.list]="'prereq-flag-list-' + i"
[ngModel]="asFlagSet(p).flagName"
(ngModelChange)="onFlagNameChange(i, $event)"
[ngModelOptions]="{ standalone: true }" />
<datalist [attr.id]="'prereq-flag-list-' + i">
@for (f of availableFlags; track f) {
<option [value]="f"></option>
}
</datalist>
}
}
<button type="button" class="prereq-remove" (click)="removeAt(i)" title="Retirer ce prérequis">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
}
</ul>
}
<div class="prereq-add">
<button type="button" class="btn-secondary prereq-add-btn" (click)="toggleAddMenu()">
@@ -63,17 +69,19 @@
<lucide-icon [img]="ChevronDown" [size]="14"></lucide-icon>
</button>
<div class="prereq-add-menu" *ngIf="addMenuOpen">
<button type="button" (click)="addPrerequisite('QUEST_COMPLETED')">
Après une autre quête
</button>
<button type="button" (click)="addPrerequisite('SESSION_REACHED')">
À partir d'une session
</button>
<button type="button" (click)="addPrerequisite('FLAG_SET')">
Quand un fait est vrai
</button>
</div>
@if (addMenuOpen) {
<div class="prereq-add-menu">
<button type="button" (click)="addPrerequisite('QUEST_COMPLETED')">
Après une autre quête
</button>
<button type="button" (click)="addPrerequisite('SESSION_REACHED')">
À partir d'une session
</button>
<button type="button" (click)="addPrerequisite('FLAG_SET')">
Quand un fait est vrai
</button>
</div>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular';
import { Chapter, Prerequisite } from '../../services/campaign.model';
@@ -13,7 +13,7 @@ import { Chapter, Prerequisite } from '../../services/campaign.model';
*/
@Component({
selector: 'app-prerequisite-editor',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './prerequisite-editor.component.html',
styleUrls: ['./prerequisite-editor.component.scss']
})
@@ -81,7 +81,6 @@ export class PrerequisiteEditorComponent {
}
/** trackBy stable pour *ngFor (sinon les inputs reset à chaque édition). */
trackByIndex(i: number): number { return i; }
// === Casts typés pour le template ===
// Angular strict templates ne fait pas le narrowing d'une union discriminée à travers

View File

@@ -1,4 +1,6 @@
<span [class]="cssClass" [attr.aria-label]="label" [title]="label">
<lucide-icon [img]="icon" [size]="14"></lucide-icon>
<span class="status-label" *ngIf="!compact">{{ label }}</span>
@if (!compact) {
<span class="status-label">{{ label }}</span>
}
</span>

View File

@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, Lock, Circle, Play, CheckCircle2, LucideIconData } from 'lucide-angular';
import { QuestStatus } from '../../services/campaign.model';
@@ -9,7 +9,7 @@ import { QuestStatus } from '../../services/campaign.model';
*/
@Component({
selector: 'app-quest-status-badge',
imports: [CommonModule, LucideAngularModule],
imports: [LucideAngularModule],
templateUrl: './quest-status-badge.component.html',
styleUrls: ['./quest-status-badge.component.scss']
})

View File

@@ -1,122 +1,132 @@
<div class="rooms-editor">
<p class="rooms-empty" *ngIf="rooms.length === 0">
Aucune pièce. La scène se comporte comme un beat narratif classique.
Ajoutez une première pièce pour la convertir en lieu explorable (donjon, crypte…).
</p>
@if (rooms.length === 0) {
<p class="rooms-empty">
Aucune pièce. La scène se comporte comme un beat narratif classique.
Ajoutez une première pièce pour la convertir en lieu explorable (donjon, crypte…).
</p>
}
<ul class="room-list" *ngIf="rooms.length > 0">
<li class="room-card" *ngFor="let r of rooms; let i = index; trackBy: trackById">
<!-- Header repliable -->
<header class="room-head" (click)="toggleExpanded(r.id)">
<button type="button" class="room-toggle" type="button">
<lucide-icon [img]="isExpanded(r.id) ? ChevronUp : ChevronDown" [size]="16"></lucide-icon>
</button>
<span class="room-index">#{{ i + 1 }}</span>
<input type="text" class="room-name-input"
[ngModel]="r.name"
(ngModelChange)="patch(r, { name: $event })"
(click)="$event.stopPropagation()"
placeholder="Nom de la pièce" />
<input type="number" class="room-floor-input"
[ngModel]="r.floor"
(ngModelChange)="patch(r, { floor: $event === null || $event === '' ? null : +$event })"
(click)="$event.stopPropagation()"
placeholder="Ét." title="Étage (0 = RdC)" />
<div class="room-actions" (click)="$event.stopPropagation()">
<button type="button" class="btn-icon" (click)="moveUp(r)" [disabled]="i === 0" title="Monter"></button>
<button type="button" class="btn-icon" (click)="moveDown(r)" [disabled]="i === rooms.length - 1" title="Descendre"></button>
<button type="button" class="btn-icon-danger" (click)="removeRoom(r)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
</header>
<!-- Corps déplié -->
<div class="room-body" *ngIf="isExpanded(r.id)">
<div class="field">
<label>Description (lue/résumée aux joueurs)</label>
<textarea rows="3"
[ngModel]="r.description"
(ngModelChange)="patch(r, { description: $event })"
placeholder="Atmosphère, ce que voient les PJ en entrant…"></textarea>
</div>
<div class="field-row">
<div class="field">
<label>Ennemis / créatures / boss</label>
<textarea rows="3"
[ngModel]="r.enemies"
(ngModelChange)="patch(r, { enemies: $event })"
placeholder="2 gobelins, 1 ogre boss (PV 60, AC 14)…"></textarea>
</div>
<div class="field">
<label>Loot / trésors</label>
<textarea rows="3"
[ngModel]="r.loot"
(ngModelChange)="patch(r, { loot: $event })"
placeholder="50 po, potion de soin, clé en argent…"></textarea>
</div>
</div>
<div class="field-row">
<div class="field">
<label>Pièges / dangers</label>
<textarea rows="2"
[ngModel]="r.traps"
(ngModelChange)="patch(r, { traps: $event })"
placeholder="Trappe (DC 15 Perception), flèches empoisonnées…"></textarea>
</div>
<div class="field">
<label>Notes MJ (privé)</label>
<textarea rows="2"
[ngModel]="r.gmNotes"
(ngModelChange)="patch(r, { gmNotes: $event })"
placeholder="Le boss connaît les PJ par leur nom, indice…"></textarea>
</div>
</div>
<!-- Branches inter-pièces -->
<div class="field">
<label>
<lucide-icon [img]="GitBranch" [size]="14"></lucide-icon>
Sorties / portes vers d'autres pièces
</label>
<ul class="branch-list" *ngIf="(r.branches?.length ?? 0) > 0">
<li class="branch-row" *ngFor="let b of r.branches; let bi = index">
<input type="text" class="branch-label"
[ngModel]="b.label"
(ngModelChange)="updateBranch(r, bi, { label: $event })"
placeholder="Porte nord" />
<span class="branch-arrow"></span>
<select class="branch-target"
[ngModel]="b.targetRoomId"
(ngModelChange)="updateBranch(r, bi, { targetRoomId: $event })"
[disabled]="otherRooms(r).length === 0">
<option *ngIf="otherRooms(r).length === 0" value="">(aucune autre pièce)</option>
<option *ngFor="let o of otherRooms(r)" [value]="o.id">{{ o.name }}</option>
</select>
<input type="text" class="branch-condition"
[ngModel]="b.condition"
(ngModelChange)="updateBranch(r, bi, { condition: $event })"
placeholder="Condition (optionnelle)" />
<button type="button" class="btn-icon-danger" (click)="removeBranch(r, bi)">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
@if (rooms.length > 0) {
<ul class="room-list">
@for (r of rooms; track trackById(i, r); let i = $index) {
<li class="room-card">
<!-- Header repliable -->
<header class="room-head" (click)="toggleExpanded(r.id)">
<button type="button" class="room-toggle" type="button">
<lucide-icon [img]="isExpanded(r.id) ? ChevronUp : ChevronDown" [size]="16"></lucide-icon>
</button>
<span class="room-index">#{{ i + 1 }}</span>
<input type="text" class="room-name-input"
[ngModel]="r.name"
(ngModelChange)="patch(r, { name: $event })"
(click)="$event.stopPropagation()"
placeholder="Nom de la pièce" />
<input type="number" class="room-floor-input"
[ngModel]="r.floor"
(ngModelChange)="patch(r, { floor: $event === null || $event === '' ? null : +$event })"
(click)="$event.stopPropagation()"
placeholder="Ét." title="Étage (0 = RdC)" />
<div class="room-actions" (click)="$event.stopPropagation()">
<button type="button" class="btn-icon" (click)="moveUp(r)" [disabled]="i === 0" title="Monter"></button>
<button type="button" class="btn-icon" (click)="moveDown(r)" [disabled]="i === rooms.length - 1" title="Descendre"></button>
<button type="button" class="btn-icon-danger" (click)="removeRoom(r)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
</ul>
<button type="button" class="btn-secondary btn-small"
</div>
</header>
<!-- Corps déplié -->
@if (isExpanded(r.id)) {
<div class="room-body">
<div class="field">
<label>Description (lue/résumée aux joueurs)</label>
<textarea rows="3"
[ngModel]="r.description"
(ngModelChange)="patch(r, { description: $event })"
placeholder="Atmosphère, ce que voient les PJ en entrant…"></textarea>
</div>
<div class="field-row">
<div class="field">
<label>Ennemis / créatures / boss</label>
<textarea rows="3"
[ngModel]="r.enemies"
(ngModelChange)="patch(r, { enemies: $event })"
placeholder="2 gobelins, 1 ogre boss (PV 60, AC 14)…"></textarea>
</div>
<div class="field">
<label>Loot / trésors</label>
<textarea rows="3"
[ngModel]="r.loot"
(ngModelChange)="patch(r, { loot: $event })"
placeholder="50 po, potion de soin, clé en argent…"></textarea>
</div>
</div>
<div class="field-row">
<div class="field">
<label>Pièges / dangers</label>
<textarea rows="2"
[ngModel]="r.traps"
(ngModelChange)="patch(r, { traps: $event })"
placeholder="Trappe (DC 15 Perception), flèches empoisonnées…"></textarea>
</div>
<div class="field">
<label>Notes MJ (privé)</label>
<textarea rows="2"
[ngModel]="r.gmNotes"
(ngModelChange)="patch(r, { gmNotes: $event })"
placeholder="Le boss connaît les PJ par leur nom, indice…"></textarea>
</div>
</div>
<!-- Branches inter-pièces -->
<div class="field">
<label>
<lucide-icon [img]="GitBranch" [size]="14"></lucide-icon>
Sorties / portes vers d'autres pièces
</label>
@if ((r.branches?.length ?? 0) > 0) {
<ul class="branch-list">
@for (b of r.branches; track b; let bi = $index) {
<li class="branch-row">
<input type="text" class="branch-label"
[ngModel]="b.label"
(ngModelChange)="updateBranch(r, bi, { label: $event })"
placeholder="Porte nord" />
<span class="branch-arrow"></span>
<select class="branch-target"
[ngModel]="b.targetRoomId"
(ngModelChange)="updateBranch(r, bi, { targetRoomId: $event })"
[disabled]="otherRooms(r).length === 0">
@if (otherRooms(r).length === 0) {
<option value="">(aucune autre pièce)</option>
}
@for (o of otherRooms(r); track o) {
<option [value]="o.id">{{ o.name }}</option>
}
</select>
<input type="text" class="branch-condition"
[ngModel]="b.condition"
(ngModelChange)="updateBranch(r, bi, { condition: $event })"
placeholder="Condition (optionnelle)" />
<button type="button" class="btn-icon-danger" (click)="removeBranch(r, bi)">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</li>
}
</ul>
}
<button type="button" class="btn-secondary btn-small"
(click)="addBranch(r)"
[disabled]="otherRooms(r).length === 0">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
Ajouter une sortie
</button>
</div>
</div>
</li>
</ul>
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
Ajouter une sortie
</button>
</div>
</div>
}
</li>
}
</ul>
}
<button type="button" class="btn-primary" (click)="addRoom()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ChevronDown, ChevronUp, GitBranch, X } from 'lucide-angular';
import { Room, RoomBranch } from '../../services/campaign.model';
@@ -15,7 +15,7 @@ import { Room, RoomBranch } from '../../services/campaign.model';
*/
@Component({
selector: 'app-rooms-editor',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './rooms-editor.component.html',
styleUrls: ['./rooms-editor.component.scss']
})

View File

@@ -1,136 +1,154 @@
<aside class="secondary-sidebar"
[class.collapsed]="isCollapsed"
[style.width.px]="isCollapsed ? null : width">
[class.collapsed]="isCollapsed"
[style.width.px]="isCollapsed ? null : width">
<div class="collapse-toggle" (click)="toggleCollapse()">
<lucide-icon [img]="isCollapsed ? PanelLeftOpen : PanelLeftClose" [size]="16"></lucide-icon>
</div>
<ng-container *ngIf="!isCollapsed">
<button
*ngIf="titleRoute; else staticTitle"
type="button"
class="sidebar-title sidebar-title--link"
[class.active]="isTitleActive()"
title="Retour à l'accueil de la campagne"
(click)="clickTitle()">
<lucide-icon [img]="Home" [size]="14" class="sidebar-title-icon"></lucide-icon>
<span class="sidebar-title-text">{{ title }}</span>
</button>
<ng-template #staticTitle>
<h2 class="sidebar-title">{{ title }}</h2>
</ng-template>
<div class="actions-row" *ngIf="createActions.length">
@if (!isCollapsed) {
@if (titleRoute) {
<button
*ngFor="let action of createActions"
class="btn-pill"
[class.primary]="action.variant === 'primary'"
[class.secondary]="action.variant === 'secondary'"
(click)="runAction(action)">
{{ action.label }}
type="button"
class="sidebar-title sidebar-title--link"
[class.active]="isTitleActive()"
title="Retour à l'accueil de la campagne"
(click)="clickTitle()">
<lucide-icon [img]="Home" [size]="14" class="sidebar-title-icon"></lucide-icon>
<span class="sidebar-title-text">{{ title }}</span>
</button>
</div>
} @else {
<h2 class="sidebar-title">{{ title }}</h2>
}
@if (createActions.length) {
<div class="actions-row">
@for (action of createActions; track action) {
<button
class="btn-pill"
[class.primary]="action.variant === 'primary'"
[class.secondary]="action.variant === 'secondary'"
(click)="runAction(action)">
{{ action.label }}
</button>
}
</div>
}
<div class="tree">
<ng-container *ngFor="let item of items">
@for (item of items; track item) {
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: item, level: 0 }"></ng-container>
</ng-container>
}
</div>
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
<ng-template #treeNode let-item let-level="level">
<div class="tree-section-header" *ngIf="level === 0 && item.sectionHeaderBefore">
{{ item.sectionHeaderBefore }}
</div>
@if (level === 0 && item.sectionHeaderBefore) {
<div class="tree-section-header">
{{ item.sectionHeaderBefore }}
</div>
}
<div class="tree-item" [style.padding-left.px]="level * 12">
<div class="tree-row">
<button
*ngIf="!item.isAction && isExpandable(item)"
type="button"
class="chevron-btn"
(click)="clickChevron($event, item)">
<lucide-icon
[img]="isExpanded(item.id) ? ChevronDown : ChevronRight"
[size]="12">
</lucide-icon>
</button>
<span *ngIf="item.isAction || !isExpandable(item)" class="chevron-spacer"></span>
<button type="button" class="tree-btn"
[class.action]="item.isAction"
[class.active]="isActive(item)"
(click)="clickItem(item)">
<lucide-icon
*ngIf="iconFor(item) as icon"
[img]="icon"
[size]="14"
class="item-icon">
</lucide-icon>
{{ item.label }}
<span class="tree-item-meta" *ngIf="!item.isAction && item.meta">{{ item.meta }}</span>
</button>
<!-- Actions de creation contextuelles, revelees au survol de la ligne -->
<span class="node-actions" *ngIf="item.createActions?.length">
@if (!item.isAction && isExpandable(item)) {
<button
*ngFor="let a of item.createActions"
type="button"
class="node-action-btn"
[title]="a.label"
[attr.aria-label]="a.label"
(click)="runCreateAction($event, a)">
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
class="chevron-btn"
(click)="clickChevron($event, item)">
<lucide-icon
[img]="isExpanded(item.id) ? ChevronDown : ChevronRight"
[size]="12">
</lucide-icon>
</button>
</span>
</div>
<div class="tree-children" *ngIf="isExpanded(item.id) && hasChildren(item)">
<ng-container *ngFor="let child of item.children">
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
</ng-container>
}
@if (item.isAction || !isExpandable(item)) {
<span class="chevron-spacer"></span>
}
<button type="button" class="tree-btn"
[class.action]="item.isAction"
[class.active]="isActive(item)"
(click)="clickItem(item)">
@if (iconFor(item); as icon) {
<lucide-icon
[img]="icon"
[size]="14"
class="item-icon">
</lucide-icon>
}
{{ item.label }}
@if (!item.isAction && item.meta) {
<span class="tree-item-meta">{{ item.meta }}</span>
}
</button>
<!-- Actions de creation contextuelles, revelees au survol de la ligne -->
@if (item.createActions?.length) {
<span class="node-actions">
@for (a of item.createActions; track a) {
<button
type="button"
class="node-action-btn"
[title]="a.label"
[attr.aria-label]="a.label"
(click)="runCreateAction($event, a)">
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
</button>
}
</span>
}
</div>
@if (isExpanded(item.id) && hasChildren(item)) {
<div class="tree-children">
@for (child of item.children; track child) {
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
}
</div>
}
</div>
</ng-template>
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
<section class="bottom-panel" *ngIf="bottomPanel">
<div class="panel-header-row">
<button class="panel-header" (click)="togglePanel()">
<span class="panel-title">{{ bottomPanel.title }}</span>
<lucide-icon
[img]="panelOpen ? ChevronDown : ChevronRight"
[size]="14">
</lucide-icon>
</button>
<button
*ngIf="bottomPanel.headerAction as action"
type="button"
class="panel-header-action"
[title]="action.label"
[attr.aria-label]="action.label"
(click)="runPanelHeaderAction($event, action)">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button>
</div>
<ul class="panel-list" *ngIf="panelOpen">
<li *ngFor="let item of bottomPanel.items">
<button
class="panel-item"
[class.action]="item.isAction"
(click)="clickPanelItem(item)">
<span class="panel-item-label">{{ item.label }}</span>
<span class="panel-item-meta" *ngIf="item.meta">{{ item.meta }}</span>
@if (bottomPanel) {
<section class="bottom-panel">
<div class="panel-header-row">
<button class="panel-header" (click)="togglePanel()">
<span class="panel-title">{{ bottomPanel.title }}</span>
<lucide-icon
[img]="panelOpen ? ChevronDown : ChevronRight"
[size]="14">
</lucide-icon>
</button>
</li>
</ul>
</section>
</ng-container>
@if (bottomPanel.headerAction; as action) {
<button
type="button"
class="panel-header-action"
[title]="action.label"
[attr.aria-label]="action.label"
(click)="runPanelHeaderAction($event, action)">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button>
}
</div>
@if (panelOpen) {
<ul class="panel-list">
@for (item of bottomPanel.items; track item) {
<li>
<button
class="panel-item"
[class.action]="item.isAction"
(click)="clickPanelItem(item)">
<span class="panel-item-label">{{ item.label }}</span>
@if (item.meta) {
<span class="panel-item-meta">{{ item.meta }}</span>
}
</button>
</li>
}
</ul>
}
</section>
}
}
<!-- Poignée de redimensionnement sur le bord droit (masquée si replié) -->
<div class="resize-handle"
*ngIf="!isCollapsed"
(mousedown)="startResize($event)"
title="Glissez pour redimensionner"></div>
@if (!isCollapsed) {
<div class="resize-handle"
(mousedown)="startResize($event)"
title="Glissez pour redimensionner"></div>
}
</aside>

View File

@@ -1,14 +1,15 @@
<div class="sip">
<div class="sip-frame" [style.aspectRatio]="aspectRatio">
<ng-container *ngIf="imageId; else uploadTpl">
@if (imageId) {
<img [src]="contentUrl(imageId)" alt="" />
<button type="button" class="sip-remove" (click)="remove()" title="Retirer l'image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</ng-container>
<ng-template #uploadTpl>
} @else {
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</ng-template>
}
</div>
<small *ngIf="hint" class="sip-hint">{{ hint }}</small>
@if (hint) {
<small class="sip-hint">{{ hint }}</small>
}
</div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
@@ -21,7 +21,7 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
*/
@Component({
selector: 'app-single-image-picker',
imports: [CommonModule, LucideAngularModule, ImageUploaderComponent],
imports: [LucideAngularModule, ImageUploaderComponent],
templateUrl: './single-image-picker.component.html',
styleUrls: ['./single-image-picker.component.scss']
})

View File

@@ -1,103 +1,114 @@
<div class="tfe">
<div class="tfe-header">
<h3 class="tfe-label">{{ label }}</h3>
<p *ngIf="hint" class="tfe-hint">{{ hint }}</p>
@if (hint) {
<p class="tfe-hint">{{ hint }}</p>
}
</div>
<div class="tfe-list">
<div class="tfe-item" *ngFor="let f of fields; let i = index">
<div class="tfe-row" [class.invalid]="isDuplicate(f, i) || !f.name.trim()">
<div class="tfe-row-controls">
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" title="Monter">
<lucide-icon [img]="ArrowUp" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-arrow" (click)="moveDown(i)" [disabled]="i === fields.length - 1" title="Descendre">
<lucide-icon [img]="ArrowDown" [size]="14"></lucide-icon>
</button>
</div>
<input
type="text"
class="tfe-name"
[(ngModel)]="f.name"
[name]="'name-' + i"
(ngModelChange)="onFieldChanged()"
placeholder="Nom du champ (ex: Histoire, PV...)"
/>
<select
class="tfe-type"
[(ngModel)]="f.type"
[name]="'type-' + i"
(ngModelChange)="onFieldChanged()">
<option *ngFor="let opt of typeOptions" [value]="opt.value">{{ opt.label }}</option>
</select>
<select
class="tfe-layout"
*ngIf="f.type === 'IMAGE'"
[(ngModel)]="f.layout"
[name]="'layout-' + i"
(ngModelChange)="onFieldChanged()">
<option *ngFor="let opt of layoutOptions" [value]="opt.value">{{ opt.label }}</option>
</select>
<button type="button" class="btn-remove" (click)="remove(i)" title="Supprimer ce champ">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<!-- Sous-editeur des labels pour KEY_VALUE_LIST -->
<div class="tfe-labels" *ngIf="f.type === 'KEY_VALUE_LIST'">
<div class="tfe-labels-header">
<lucide-icon [img]="ListOrdered" [size]="12"></lucide-icon>
<span>Labels (cles fixes pour toutes les fiches)</span>
</div>
<div class="tfe-labels-list">
<div class="tfe-label-row" *ngFor="let lbl of f.labels; let li = index; trackBy: trackByIndex">
<button type="button" class="btn-arrow-mini" (click)="moveLabelUp(f, li)" [disabled]="li === 0" title="Monter">
<lucide-icon [img]="ArrowUp" [size]="11"></lucide-icon>
@for (f of fields; track f; let i = $index) {
<div class="tfe-item">
<div class="tfe-row" [class.invalid]="isDuplicate(f, i) || !f.name.trim()">
<div class="tfe-row-controls">
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" title="Monter">
<lucide-icon [img]="ArrowUp" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-arrow-mini" (click)="moveLabelDown(f, li)" [disabled]="li === (f.labels?.length || 0) - 1" title="Descendre">
<lucide-icon [img]="ArrowDown" [size]="11"></lucide-icon>
</button>
<input
type="text"
class="tfe-label-input"
[ngModel]="lbl"
(ngModelChange)="updateLabelAt(f, li, $event)"
[name]="'lbl-' + i + '-' + li"
placeholder="Ex: FOR, DEX..."
/>
<button type="button" class="btn-remove-mini" (click)="removeLabel(f, li)" title="Retirer ce label">
<lucide-icon [img]="X" [size]="11"></lucide-icon>
<button type="button" class="btn-arrow" (click)="moveDown(i)" [disabled]="i === fields.length - 1" title="Descendre">
<lucide-icon [img]="ArrowDown" [size]="14"></lucide-icon>
</button>
</div>
<input
type="text"
class="tfe-name"
[(ngModel)]="f.name"
[name]="'name-' + i"
(ngModelChange)="onFieldChanged()"
placeholder="Nom du champ (ex: Histoire, PV...)"
/>
<select
class="tfe-type"
[(ngModel)]="f.type"
[name]="'type-' + i"
(ngModelChange)="onFieldChanged()">
@for (opt of typeOptions; track opt) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
@if (f.type === 'IMAGE') {
<select
class="tfe-layout"
[(ngModel)]="f.layout"
[name]="'layout-' + i"
(ngModelChange)="onFieldChanged()">
@for (opt of layoutOptions; track opt) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
}
<button type="button" class="btn-remove" (click)="remove(i)" title="Supprimer ce champ">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<button type="button" class="chip chip-mini" (click)="addLabel(f)">
<lucide-icon [img]="Plus" [size]="11"></lucide-icon>
Ajouter un label
</button>
<!-- Sous-editeur des labels pour KEY_VALUE_LIST -->
@if (f.type === 'KEY_VALUE_LIST') {
<div class="tfe-labels">
<div class="tfe-labels-header">
<lucide-icon [img]="ListOrdered" [size]="12"></lucide-icon>
<span>Labels (cles fixes pour toutes les fiches)</span>
</div>
<div class="tfe-labels-list">
@for (lbl of f.labels; track $index; let li = $index) {
<div class="tfe-label-row">
<button type="button" class="btn-arrow-mini" (click)="moveLabelUp(f, li)" [disabled]="li === 0" title="Monter">
<lucide-icon [img]="ArrowUp" [size]="11"></lucide-icon>
</button>
<button type="button" class="btn-arrow-mini" (click)="moveLabelDown(f, li)" [disabled]="li === (f.labels?.length || 0) - 1" title="Descendre">
<lucide-icon [img]="ArrowDown" [size]="11"></lucide-icon>
</button>
<input
type="text"
class="tfe-label-input"
[ngModel]="lbl"
(ngModelChange)="updateLabelAt(f, li, $event)"
[name]="'lbl-' + i + '-' + li"
placeholder="Ex: FOR, DEX..."
/>
<button type="button" class="btn-remove-mini" (click)="removeLabel(f, li)" title="Retirer ce label">
<lucide-icon [img]="X" [size]="11"></lucide-icon>
</button>
</div>
}
</div>
<button type="button" class="chip chip-mini" (click)="addLabel(f)">
<lucide-icon [img]="Plus" [size]="11"></lucide-icon>
Ajouter un label
</button>
</div>
}
</div>
</div>
}
<div *ngIf="fields.length === 0" class="tfe-empty">
Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.
</div>
@if (fields.length === 0) {
<div class="tfe-empty">
Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.
</div>
}
</div>
<div class="tfe-add">
<span class="tfe-add-label">Ajouter :</span>
<button
type="button"
class="chip"
*ngFor="let s of suggestions"
[class.disabled]="isSuggestionUsed(s)"
[disabled]="isSuggestionUsed(s)"
(click)="addSuggestion(s)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ s }}
</button>
@for (s of suggestions; track s) {
<button
type="button"
class="chip"
[class.disabled]="isSuggestionUsed(s)"
[disabled]="isSuggestionUsed(s)"
(click)="addSuggestion(s)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ s }}
</button>
}
<button type="button" class="chip chip-custom" (click)="addBlank('TEXT')">
<lucide-icon [img]="Type" [size]="12"></lucide-icon>
Texte

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash, ListOrdered, X } from 'lucide-angular';
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
@@ -16,7 +16,7 @@ import { TemplateField, FieldType, ImageLayout } from '../../services/template.m
*/
@Component({
selector: 'app-template-fields-editor',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './template-fields-editor.component.html',
styleUrls: ['./template-fields-editor.component.scss']
})
@@ -122,7 +122,6 @@ export class TemplateFieldsEditorComponent {
this.onFieldChanged();
}
trackByIndex = (i: number) => i;
remove(index: number): void {
const next = [...this.fields];

View File

@@ -1,29 +1,30 @@
<div
class="update-banner"
*ngIf="versionChecker.hasUpdate() && !dismissed"
role="status"
aria-live="polite"
>
<div class="banner-content">
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
<span>
Une nouvelle version de LoreMind est disponible
(<strong>{{ versionChecker.remoteVersion() }}</strong>).
Recharge pour profiter des dernieres ameliorations.
</span>
</div>
<div class="banner-actions">
<button type="button" class="btn-reload" (click)="reload()">
Recharger
</button>
<button
type="button"
class="btn-dismiss"
(click)="dismiss()"
title="Fermer (sera reaffiche au prochain demarrage)"
aria-label="Fermer"
@if (versionChecker.hasUpdate() && !dismissed) {
<div
class="update-banner"
role="status"
aria-live="polite"
>
<lucide-icon [img]="X" [size]="16"></lucide-icon>
</button>
<div class="banner-content">
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
<span>
Une nouvelle version de LoreMind est disponible
(<strong>{{ versionChecker.remoteVersion() }}</strong>).
Recharge pour profiter des dernieres ameliorations.
</span>
</div>
<div class="banner-actions">
<button type="button" class="btn-reload" (click)="reload()">
Recharger
</button>
<button
type="button"
class="btn-dismiss"
(click)="dismiss()"
title="Fermer (sera reaffiche au prochain demarrage)"
aria-label="Fermer"
>
<lucide-icon [img]="X" [size]="16"></lucide-icon>
</button>
</div>
</div>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, RefreshCw, X } from 'lucide-angular';
import { VersionCheckerService } from '../../services/version-checker.service';
@@ -10,7 +10,7 @@ import { VersionCheckerService } from '../../services/version-checker.service';
*/
@Component({
selector: 'app-update-banner',
imports: [CommonModule, LucideAngularModule],
imports: [LucideAngularModule],
templateUrl: './update-banner.component.html',
styleUrls: ['./update-banner.component.scss']
})