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:
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user