Mise en place de l'anglais comme deuxième langue pour l'application
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
[class.is-resizing]="isResizing"
|
||||
[class.is-wide]="isWide"
|
||||
[style.width.px]="effectiveWidth"
|
||||
aria-label="Assistant IA">
|
||||
[attr.aria-label]="'aiChatDrawer.title' | translate">
|
||||
|
||||
<!-- Poignee de redimensionnement (desactivee en mode wide) -->
|
||||
@if (!isWide) {
|
||||
@@ -13,23 +13,23 @@
|
||||
(mousedown)="onResizeStart($event)"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Redimensionner le panneau"
|
||||
title="Glisser pour redimensionner"></div>
|
||||
[attr.aria-label]="'aiChatDrawer.resizeAria' | translate"
|
||||
[title]="'aiChatDrawer.resizeTitle' | translate"></div>
|
||||
}
|
||||
|
||||
|
||||
<!-- Sidebar conversations (mode persistent uniquement) -->
|
||||
@if (persistent && sidebarOpen) {
|
||||
<section class="conv-sidebar" aria-label="Conversations">
|
||||
<section class="conv-sidebar" [attr.aria-label]="'aiChatDrawer.conversations' | translate">
|
||||
<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">
|
||||
<span class="conv-sidebar-title">{{ 'aiChatDrawer.conversations' | translate }}</span>
|
||||
<button type="button" class="conv-new-btn" (click)="startNewConversation()" [disabled]="isStreaming" [title]="'aiChatDrawer.newConversation' | translate">
|
||||
<lucide-icon [img]="MessageSquarePlus" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="conv-list">
|
||||
@if (conversations.length === 0) {
|
||||
<li class="conv-empty">Aucune conversation</li>
|
||||
<li class="conv-empty">{{ 'aiChatDrawer.noConversation' | translate }}</li>
|
||||
}
|
||||
@for (c of conversations; track c) {
|
||||
<li
|
||||
@@ -42,7 +42,7 @@
|
||||
class="conv-item-del"
|
||||
(click)="deleteConversation(c, $event)"
|
||||
[disabled]="isStreaming"
|
||||
title="Supprimer">
|
||||
[title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
@@ -59,8 +59,8 @@
|
||||
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'">
|
||||
[attr.aria-label]="(sidebarOpen ? 'aiChatDrawer.hideList' : 'aiChatDrawer.showList') | translate"
|
||||
[title]="(sidebarOpen ? 'aiChatDrawer.hideList' : 'aiChatDrawer.showList') | translate">
|
||||
<lucide-icon [img]="sidebarOpen ? PanelLeftClose : PanelLeftOpen" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -68,8 +68,8 @@
|
||||
<div class="header-title-wrap">
|
||||
@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">
|
||||
<h2 class="header-title" [title]="currentTitle">{{ currentTitle || ('aiChatDrawer.newConversation' | translate) }}</h2>
|
||||
<button type="button" class="rename-btn" (click)="startRenameTitle()" [title]="'common.rename' | translate" [attr.aria-label]="'common.rename' | translate">
|
||||
<lucide-icon [img]="Pencil" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
} @else {
|
||||
@@ -83,7 +83,7 @@
|
||||
autofocus />
|
||||
}
|
||||
} @else {
|
||||
<h2 class="header-title">Assistant IA</h2>
|
||||
<h2 class="header-title">{{ 'aiChatDrawer.title' | translate }}</h2>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -91,19 +91,19 @@
|
||||
type="button"
|
||||
class="wide-toggle-btn"
|
||||
(click)="toggleWide()"
|
||||
[attr.aria-label]="isWide ? 'Reduire le panneau' : 'Agrandir le panneau'"
|
||||
[title]="isWide ? 'Reduire' : 'Agrandir'">
|
||||
[attr.aria-label]="(isWide ? 'aiChatDrawer.shrinkAria' : 'aiChatDrawer.expandAria') | translate"
|
||||
[title]="(isWide ? 'aiChatDrawer.shrink' : 'aiChatDrawer.expand') | translate">
|
||||
<lucide-icon [img]="isWide ? Minimize2 : Maximize2" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
|
||||
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
|
||||
<button type="button" class="close-btn" (click)="onClose()" [attr.aria-label]="'common.close' | translate">
|
||||
<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'">
|
||||
[attr.title]="(usageHasMax ? 'aiChatDrawer.gaugeTitleMax' : 'aiChatDrawer.gaugeTitle') | translate:{ system: usage.system, history: usage.history, current: usage.current, max: usage.max }">
|
||||
<!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
|
||||
@if (usageHasMax) {
|
||||
<div class="gauge-bar">
|
||||
@@ -111,9 +111,11 @@
|
||||
</div>
|
||||
}
|
||||
<div class="gauge-label">
|
||||
<span class="gauge-text">Contexte : {{ usageTotal }}@if (usageHasMax) {
|
||||
/ {{ usage.max }}
|
||||
} tokens</span>
|
||||
<span class="gauge-text">@if (usageHasMax) {
|
||||
{{ 'aiChatDrawer.gaugeLabelMax' | translate:{ total: usageTotal, max: usage.max } }}
|
||||
} @else {
|
||||
{{ 'aiChatDrawer.gaugeLabel' | translate:{ total: usageTotal } }}
|
||||
}</span>
|
||||
@if (usageHasMax) {
|
||||
<span class="gauge-percent">{{ usagePercent }}%</span>
|
||||
}
|
||||
@@ -172,7 +174,7 @@
|
||||
|
||||
@if (quickSuggestions.length) {
|
||||
<div class="quick-suggestions">
|
||||
<p class="quick-label">Suggestions rapides :</p>
|
||||
<p class="quick-label">{{ 'aiChatDrawer.quickSuggestions' | translate }}</p>
|
||||
<div class="quick-list">
|
||||
@for (s of quickSuggestions; track s) {
|
||||
<button
|
||||
@@ -193,10 +195,10 @@
|
||||
type="text"
|
||||
[(ngModel)]="input"
|
||||
name="chatInput"
|
||||
placeholder="Posez une question..."
|
||||
[placeholder]="'aiChatDrawer.inputPlaceholder' | translate"
|
||||
[disabled]="isStreaming"
|
||||
autocomplete="off" />
|
||||
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
|
||||
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" [attr.aria-label]="'aiChatDrawer.send' | translate">
|
||||
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { AiChatService, ChatMessage, ChatUsage, NarrativeEntityType } from '../../services/ai-chat.service';
|
||||
import { Conversation, ConversationContext } from '../../services/conversation.model';
|
||||
@@ -31,7 +32,7 @@ export interface ChatPrimaryAction {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-ai-chat-drawer',
|
||||
imports: [FormsModule, LucideAngularModule, MarkdownPipe],
|
||||
imports: [FormsModule, LucideAngularModule, MarkdownPipe, TranslatePipe],
|
||||
templateUrl: './ai-chat-drawer.component.html',
|
||||
styleUrls: ['./ai-chat-drawer.component.scss']
|
||||
})
|
||||
@@ -80,7 +81,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() entityType: NarrativeEntityType | null = null;
|
||||
@Input() entityId: string | null = null;
|
||||
@Input() isOpen = false;
|
||||
@Input() welcomeMessage = 'Bonjour ! Je peux vous aider a developper cette page. Que souhaitez-vous creer ?';
|
||||
@Input() welcomeMessage = '';
|
||||
@Input() quickSuggestions: string[] = [];
|
||||
@Input() primaryAction: ChatPrimaryAction | null = null;
|
||||
@Input() systemPromptAddon: string | null = null;
|
||||
@@ -120,7 +121,10 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private readonly chatService: AiChatService,
|
||||
private readonly conversationService: ConversationService,
|
||||
private readonly confirmDialog: ConfirmDialogService,
|
||||
) {}
|
||||
private readonly translate: TranslateService,
|
||||
) {
|
||||
this.welcomeMessage = this.translate.instant('aiChatDrawer.welcome');
|
||||
}
|
||||
|
||||
// --- Jauge de contexte --------------------------------------------------
|
||||
|
||||
@@ -310,7 +314,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.usage = null;
|
||||
this.scrollToBottom();
|
||||
},
|
||||
error: () => (this.errorMessage = 'Impossible de charger la conversation.'),
|
||||
error: () => (this.errorMessage = this.translate.instant('aiChatDrawer.loadConversationError')),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -318,9 +322,9 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
event.stopPropagation();
|
||||
if (this.isStreaming) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la conversation',
|
||||
message: `Supprimer la conversation "${conv.title}" ?`,
|
||||
confirmLabel: 'Supprimer',
|
||||
title: this.translate.instant('aiChatDrawer.deleteConversationTitle'),
|
||||
message: this.translate.instant('aiChatDrawer.deleteConversationMessage', { title: conv.title }),
|
||||
confirmLabel: this.translate.instant('common.delete'),
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
@@ -410,7 +414,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (this.currentConversationId) return Promise.resolve(this.currentConversationId);
|
||||
const ctx = this.buildContext();
|
||||
if (!ctx.loreId && !ctx.campaignId) {
|
||||
this.errorMessage = 'Contexte manquant pour creer une conversation.';
|
||||
this.errorMessage = this.translate.instant('aiChatDrawer.missingContextError');
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
@@ -422,7 +426,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
resolve(conv.id);
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Impossible de creer la conversation.';
|
||||
this.errorMessage = this.translate.instant('aiChatDrawer.createConversationError');
|
||||
resolve(null);
|
||||
},
|
||||
});
|
||||
@@ -460,7 +464,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
},
|
||||
error: (err) => {
|
||||
this.isStreaming = false;
|
||||
this.errorMessage = err?.message ?? 'Erreur inconnue.';
|
||||
this.errorMessage = err?.message ?? this.translate.instant('aiChatDrawer.unknownError');
|
||||
this.currentAssistantText = '';
|
||||
},
|
||||
complete: () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@if (items.length) {
|
||||
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
||||
<nav class="breadcrumb" [attr.aria-label]="'breadcrumb.ariaLabel' | translate">
|
||||
<ol>
|
||||
@for (item of items; track $index; let last = $last) {
|
||||
<li
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Un niveau dans le fil d'Ariane.
|
||||
@@ -23,7 +24,7 @@ export interface BreadcrumbItem {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-breadcrumb',
|
||||
imports: [RouterLink],
|
||||
imports: [RouterLink, TranslatePipe],
|
||||
templateUrl: './breadcrumb.component.html',
|
||||
styleUrls: ['./breadcrumb.component.scss']
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@for (tag of value; track tag; let i = $index) {
|
||||
<span class="chip">
|
||||
{{ tag }}
|
||||
<button type="button" class="chip-remove" (click)="remove(i)" title="Retirer">
|
||||
<button type="button" class="chip-remove" (click)="remove(i)" [title]="'chipsInput.removeTag' | translate">
|
||||
<lucide-icon [img]="X" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Composant réutilisable de saisie de chips (étiquettes textuelles).
|
||||
@@ -21,7 +22,7 @@ import { LucideAngularModule, X } from 'lucide-angular';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-chips-input',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './chips-input.component.html',
|
||||
styleUrls: ['./chips-input.component.scss']
|
||||
})
|
||||
@@ -29,12 +30,16 @@ export class ChipsInputComponent {
|
||||
readonly X = X;
|
||||
|
||||
@Input() value: string[] = [];
|
||||
@Input() placeholder = 'Ajouter...';
|
||||
@Input() placeholder = '';
|
||||
@Output() valueChange = new EventEmitter<string[]>();
|
||||
|
||||
/** Texte de l'input en cours de saisie. */
|
||||
current = '';
|
||||
|
||||
constructor(private translate: TranslateService) {
|
||||
this.placeholder = this.translate.instant('chipsInput.placeholder');
|
||||
}
|
||||
|
||||
/** Ajoute le texte courant s'il est non vide et non déjà présent. */
|
||||
add(): void {
|
||||
const raw = this.current.trim().replace(/,$/, '').trim();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<lucide-icon [img]="TriangleAlert" [size]="22"></lucide-icon>
|
||||
</div>
|
||||
<h2>{{ title }}</h2>
|
||||
<button type="button" class="btn-close" (click)="onCancel()" aria-label="Fermer">
|
||||
<button type="button" class="btn-close" (click)="onCancel()" [attr.aria-label]="'common.close' | translate">
|
||||
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { LucideAngularModule, TriangleAlert, X } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export type ConfirmDialogVariant = 'warning' | 'danger' | 'info';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
imports: [LucideAngularModule],
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './confirm-dialog.component.html',
|
||||
styleUrls: ['./confirm-dialog.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { ConfirmDialogVariant } from './confirm-dialog.component';
|
||||
|
||||
export interface ConfirmDialogOptions {
|
||||
@@ -31,6 +32,8 @@ export class ConfirmDialogService {
|
||||
readonly state$ = new BehaviorSubject<ConfirmDialogState>(CLOSED_STATE);
|
||||
private resolver: ((value: boolean) => void) | null = null;
|
||||
|
||||
constructor(private translate: TranslateService) {}
|
||||
|
||||
confirm(opts: ConfirmDialogOptions): Promise<boolean> {
|
||||
// Si un dialog precedent est encore ouvert, on le resout en "false"
|
||||
// avant d'en ouvrir un nouveau pour eviter une fuite de Promise.
|
||||
@@ -40,11 +43,11 @@ export class ConfirmDialogService {
|
||||
}
|
||||
this.state$.next({
|
||||
open: true,
|
||||
title: opts.title ?? 'Confirmation',
|
||||
title: opts.title ?? this.translate.instant('confirmDialog.defaultTitle'),
|
||||
message: opts.message,
|
||||
details: opts.details ?? [],
|
||||
confirmLabel: opts.confirmLabel ?? 'Confirmer',
|
||||
cancelLabel: opts.cancelLabel ?? 'Annuler',
|
||||
confirmLabel: opts.confirmLabel ?? this.translate.instant('common.confirm'),
|
||||
cancelLabel: opts.cancelLabel ?? this.translate.instant('common.cancel'),
|
||||
variant: opts.variant ?? 'warning'
|
||||
});
|
||||
return new Promise<boolean>((resolve) => { this.resolver = resolve; });
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[ngModel]="values[f.name] || ''"
|
||||
(ngModelChange)="onTextChange(f, $event)"
|
||||
[name]="'val-' + f.name"
|
||||
placeholder="Renseignez {{ f.name }}…">
|
||||
[placeholder]="'dynamicFieldsForm.textPlaceholder' | translate:{ name: f.name }">
|
||||
</textarea>
|
||||
}
|
||||
@case ('NUMBER') {
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
@if (!f.labels?.length) {
|
||||
<div class="dff-kv-empty">
|
||||
Aucun label defini dans le template pour ce champ.
|
||||
{{ 'dynamicFieldsForm.noLabels' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="dff-empty">
|
||||
Aucun champ defini dans le template de ce systeme. Editez le GameSystem pour ajouter des champs.
|
||||
{{ 'dynamicFieldsForm.noFields' | translate }}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TemplateField } from '../../services/template.model';
|
||||
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
|
||||
|
||||
@@ -17,7 +18,7 @@ import { ImageGalleryComponent } from '../image-gallery/image-gallery.component'
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-dynamic-fields-form',
|
||||
imports: [FormsModule, ImageGalleryComponent],
|
||||
imports: [FormsModule, ImageGalleryComponent, TranslatePipe],
|
||||
templateUrl: './dynamic-fields-form.component.html',
|
||||
styleUrls: ['./dynamic-fields-form.component.scss']
|
||||
})
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
[href]="enemyUrl(e.id!)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Ouvrir la fiche dans un nouvel onglet">
|
||||
[title]="'enemyLinkPicker.openInNewTab' | translate">
|
||||
<lucide-icon [img]="Skull" [size]="12"></lucide-icon>
|
||||
{{ label(e) }}
|
||||
</a>
|
||||
<button type="button" class="chip-remove" (click)="remove(e.id!)" title="Retirer le lien">
|
||||
<button type="button" class="chip-remove" (click)="remove(e.id!)" [title]="'enemyLinkPicker.removeLink' | translate">
|
||||
<lucide-icon [img]="X" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
@@ -27,7 +27,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher un ennemi du bestiaire..."
|
||||
[placeholder]="'enemyLinkPicker.searchPlaceholder' | translate"
|
||||
[(ngModel)]="query"
|
||||
(focus)="dropdownOpen = true"
|
||||
(blur)="onBlur()" />
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
@if (dropdownOpen && query && suggestions.length === 0) {
|
||||
<p class="empty-hint">
|
||||
Aucun ennemi ne correspond
|
||||
{{ 'enemyLinkPicker.noMatch' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X, Skull } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { Enemy } from '../../services/enemy.model';
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,7 @@ import { Enemy } from '../../services/enemy.model';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-enemy-link-picker',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './enemy-link-picker.component.html',
|
||||
styleUrls: ['./enemy-link-picker.component.scss']
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#searchInput
|
||||
type="text"
|
||||
class="gs-input"
|
||||
placeholder="Rechercher dans LoreMind..."
|
||||
[placeholder]="'globalSearch.placeholder' | translate"
|
||||
[ngModel]="query"
|
||||
(ngModelChange)="onQueryChange($event)" />
|
||||
<span class="gs-kbd">ESC</span>
|
||||
@@ -34,23 +34,23 @@
|
||||
}
|
||||
@if (!loading && query.trim().length >= 2 && results.length === 0) {
|
||||
<div class="gs-empty">
|
||||
Aucun résultat
|
||||
{{ 'globalSearch.noResults' | translate }}
|
||||
</div>
|
||||
}
|
||||
@if (query.trim().length < 2) {
|
||||
<div class="gs-empty">
|
||||
Tape au moins 2 caractères
|
||||
{{ 'globalSearch.minChars' | translate }}
|
||||
</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>
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> {{ 'globalSearch.hintNavigate' | translate }}</span>
|
||||
<span><kbd>↵</kbd> {{ 'globalSearch.hintSelect' | translate }}</span>
|
||||
<span><kbd>ESC</kbd> {{ 'globalSearch.hintClose' | translate }}</span>
|
||||
</div>
|
||||
@if (results.length > 0) {
|
||||
<span class="gs-count">
|
||||
{{ results.length }} résultat{{ results.length > 1 ? 's' : '' }}
|
||||
{{ (results.length > 1 ? 'globalSearch.countPlural' : 'globalSearch.countSingular') | translate:{ count: results.length } }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild } fro
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, Subject, forkJoin, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Search, BookOpen, Folder, Users, FileText, Scroll, Drama, User, Dices, Package, Skull } from 'lucide-angular';
|
||||
@@ -39,7 +40,7 @@ interface SearchResult {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-global-search',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './global-search.component.html',
|
||||
styleUrls: ['./global-search.component.scss']
|
||||
})
|
||||
@@ -78,7 +79,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private randomTableService: RandomTableService,
|
||||
private itemCatalogService: ItemCatalogService,
|
||||
private enemyService: EnemyService
|
||||
private enemyService: EnemyService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -147,7 +149,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
kind: 'page' as ResultKind,
|
||||
title: p.title,
|
||||
subtitle: p.notes ? this.firstLine(p.notes) : '',
|
||||
tag: 'Page',
|
||||
tag: this.translate.instant('globalSearch.tags.page'),
|
||||
route: ['/lore', p.loreId, 'pages', p.id]
|
||||
}));
|
||||
const nodeResults: SearchResult[] = nodes.map(n => ({
|
||||
@@ -155,7 +157,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
kind: 'node' as ResultKind,
|
||||
title: n.name,
|
||||
subtitle: '',
|
||||
tag: 'Dossier',
|
||||
tag: this.translate.instant('globalSearch.tags.node'),
|
||||
route: ['/lore', n.loreId, 'folders', n.id]
|
||||
}));
|
||||
const templateResults: SearchResult[] = templates.map(t => ({
|
||||
@@ -163,7 +165,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
kind: 'template' as ResultKind,
|
||||
title: t.name,
|
||||
subtitle: t.description ?? '',
|
||||
tag: 'Template',
|
||||
tag: this.translate.instant('globalSearch.tags.template'),
|
||||
route: ['/lore', t.loreId, 'templates', t.id]
|
||||
}));
|
||||
const loreResults: SearchResult[] = lores.map(l => ({
|
||||
@@ -171,7 +173,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
kind: 'lore' as ResultKind,
|
||||
title: l.name,
|
||||
subtitle: l.description ?? '',
|
||||
tag: 'Lore',
|
||||
tag: this.translate.instant('globalSearch.tags.lore'),
|
||||
route: ['/lore', l.id]
|
||||
}));
|
||||
const campaignResults: SearchResult[] = campaigns.map(c => ({
|
||||
@@ -179,7 +181,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
kind: 'campaign' as ResultKind,
|
||||
title: c.name,
|
||||
subtitle: c.description ?? '',
|
||||
tag: 'Campagne',
|
||||
tag: this.translate.instant('globalSearch.tags.campaign'),
|
||||
route: ['/campaigns', c.id]
|
||||
}));
|
||||
const npcResults: SearchResult[] = npcs.map(n => ({
|
||||
@@ -187,7 +189,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
kind: 'npc' as ResultKind,
|
||||
title: n.name,
|
||||
subtitle: (n.folder ?? '') as string,
|
||||
tag: 'PNJ',
|
||||
tag: this.translate.instant('globalSearch.tags.npc'),
|
||||
route: ['/campaigns', n.campaignId, 'npcs', n.id]
|
||||
}));
|
||||
const characterResults: SearchResult[] = characters.map(c => ({
|
||||
@@ -195,7 +197,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
kind: 'character' as ResultKind,
|
||||
title: c.name,
|
||||
subtitle: '',
|
||||
tag: 'PJ',
|
||||
tag: this.translate.instant('globalSearch.tags.character'),
|
||||
route: ['/campaigns', c.campaignId, 'playthroughs', c.playthroughId, 'characters', c.id]
|
||||
}));
|
||||
const tableResults: SearchResult[] = tables.map(t => ({
|
||||
@@ -203,7 +205,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
kind: 'random-table' as ResultKind,
|
||||
title: t.name,
|
||||
subtitle: t.description ?? '',
|
||||
tag: 'Table aléatoire',
|
||||
tag: this.translate.instant('globalSearch.tags.randomTable'),
|
||||
route: ['/campaigns', t.campaignId, 'random-tables', t.id]
|
||||
}));
|
||||
const catalogResults: SearchResult[] = catalogs.map(c => ({
|
||||
@@ -211,15 +213,15 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
kind: 'item-catalog' as ResultKind,
|
||||
title: c.name,
|
||||
subtitle: c.description ?? '',
|
||||
tag: 'Catalogue d\'objets',
|
||||
tag: this.translate.instant('globalSearch.tags.itemCatalog'),
|
||||
route: ['/campaigns', c.campaignId, 'item-catalogs', c.id]
|
||||
}));
|
||||
const enemyResults: SearchResult[] = enemies.map(e => ({
|
||||
id: e.id,
|
||||
kind: 'enemy' as ResultKind,
|
||||
title: e.name,
|
||||
subtitle: [e.level ? `Niv. ${e.level}` : '', e.folder ?? ''].filter(Boolean).join(' · '),
|
||||
tag: 'Ennemi',
|
||||
subtitle: [e.level ? this.translate.instant('globalSearch.enemyLevel', { level: e.level }) : '', e.folder ?? ''].filter(Boolean).join(' · '),
|
||||
tag: this.translate.instant('globalSearch.tags.enemy'),
|
||||
route: ['/campaigns', e.campaignId, 'enemies', e.id]
|
||||
}));
|
||||
return [
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
(click)="openLightbox(heroId)"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<img [src]="urlFor(heroId)" [alt]="'Illustration principale'" loading="lazy" />
|
||||
<img [src]="urlFor(heroId)" [alt]="'imageGallery.mainIllustrationAlt' | translate" loading="lazy" />
|
||||
@if (editable) {
|
||||
<button type="button"
|
||||
class="gallery-remove"
|
||||
(click)="remove(heroId, $event)"
|
||||
aria-label="Retirer cette image">
|
||||
[attr.aria-label]="'imageGallery.removeImage' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -28,12 +28,12 @@
|
||||
(click)="openLightbox(id)"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
|
||||
@if (editable) {
|
||||
<button type="button"
|
||||
class="gallery-remove"
|
||||
(click)="remove(id, $event)"
|
||||
aria-label="Retirer cette image">
|
||||
[attr.aria-label]="'imageGallery.removeImage' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
} @else {
|
||||
<div class="gallery-empty">
|
||||
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
|
||||
<span>Aucune illustration</span>
|
||||
<span>{{ 'imageGallery.empty' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -67,12 +67,12 @@
|
||||
(click)="openLightbox(id)"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
|
||||
@if (editable) {
|
||||
<button type="button"
|
||||
class="gallery-remove"
|
||||
(click)="remove(id, $event)"
|
||||
aria-label="Retirer cette image">
|
||||
[attr.aria-label]="'imageGallery.removeImage' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -87,7 +87,7 @@
|
||||
} @else {
|
||||
<div class="gallery-empty">
|
||||
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
|
||||
<span>Aucune illustration</span>
|
||||
<span>{{ 'imageGallery.empty' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@
|
||||
<button type="button"
|
||||
class="carousel-nav carousel-prev"
|
||||
(click)="scrollCarousel(-1)"
|
||||
aria-label="Precedent">
|
||||
[attr.aria-label]="'common.previous' | translate">
|
||||
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -109,12 +109,12 @@
|
||||
(click)="openLightbox(id)"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
|
||||
@if (editable) {
|
||||
<button type="button"
|
||||
class="gallery-remove"
|
||||
(click)="remove(id, $event)"
|
||||
aria-label="Retirer cette image">
|
||||
[attr.aria-label]="'imageGallery.removeImage' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -130,7 +130,7 @@
|
||||
<button type="button"
|
||||
class="carousel-nav carousel-next"
|
||||
(click)="scrollCarousel(1)"
|
||||
aria-label="Suivant">
|
||||
[attr.aria-label]="'common.next' | translate">
|
||||
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -138,7 +138,7 @@
|
||||
} @else {
|
||||
<div class="gallery-empty">
|
||||
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
|
||||
<span>Aucune illustration</span>
|
||||
<span>{{ 'imageGallery.empty' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -153,12 +153,12 @@
|
||||
(click)="openLightbox(id)"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
|
||||
@if (editable) {
|
||||
<button type="button"
|
||||
class="gallery-remove"
|
||||
(click)="remove(id, $event)"
|
||||
aria-label="Retirer cette image">
|
||||
[attr.aria-label]="'imageGallery.removeImage' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -173,7 +173,7 @@
|
||||
} @else {
|
||||
<div class="gallery-empty">
|
||||
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
|
||||
<span>Aucune illustration</span>
|
||||
<span>{{ 'imageGallery.empty' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -187,12 +187,12 @@
|
||||
(click)="openLightbox(id)"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<img [src]="urlFor(id)" [alt]="'Carte ' + id" loading="lazy" />
|
||||
<img [src]="urlFor(id)" [alt]="'imageGallery.mapAlt' | translate" loading="lazy" />
|
||||
@if (editable) {
|
||||
<button type="button"
|
||||
class="gallery-remove"
|
||||
(click)="remove(id, $event)"
|
||||
aria-label="Retirer cette carte">
|
||||
[attr.aria-label]="'imageGallery.removeMap' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -207,7 +207,7 @@
|
||||
} @else {
|
||||
<div class="gallery-empty">
|
||||
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
|
||||
<span>Aucune illustration</span>
|
||||
<span>{{ 'imageGallery.empty' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -220,12 +220,12 @@
|
||||
(click)="openLightbox(id)"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
|
||||
@if (editable) {
|
||||
<button type="button"
|
||||
class="gallery-remove"
|
||||
(click)="remove(id, $event)"
|
||||
aria-label="Retirer cette image">
|
||||
[attr.aria-label]="'imageGallery.removeImage' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -238,7 +238,7 @@
|
||||
} @else {
|
||||
<div class="gallery-empty">
|
||||
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
|
||||
<span>Aucune illustration</span>
|
||||
<span>{{ 'imageGallery.empty' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -252,9 +252,9 @@
|
||||
<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">
|
||||
[attr.aria-label]="'imageGallery.lightboxAria' | translate">
|
||||
<img [src]="urlFor(lightboxId)" [alt]="'imageGallery.lightboxAlt' | translate" class="lightbox-image" />
|
||||
<button type="button" class="lightbox-close" (click)="closeLightbox()" [attr.aria-label]="'common.close' | translate">
|
||||
<lucide-icon [img]="X" [size]="24"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
|
||||
|
||||
import { LucideAngularModule, X, Image as ImageIcon, ChevronLeft, ChevronRight } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ImageService } from '../../services/image.service';
|
||||
import { Image } from '../../services/image.model';
|
||||
import { ImageLayout } from '../../services/template.model';
|
||||
@@ -27,7 +28,7 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-image-gallery',
|
||||
imports: [LucideAngularModule, ImageUploaderComponent],
|
||||
imports: [LucideAngularModule, ImageUploaderComponent, TranslatePipe],
|
||||
templateUrl: './image-gallery.component.html',
|
||||
styleUrls: ['./image-gallery.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!-- Mode compact : bouton "+ ajouter" carre, utilise dans les galeries. -->
|
||||
@if (compact) {
|
||||
<label class="upload-compact" [class.loading]="uploading" [title]="errorMessage || 'Ajouter une image'">
|
||||
<label class="upload-compact" [class.loading]="uploading" [title]="errorMessage || ('imageUploader.addImage' | translate)">
|
||||
@if (!uploading) {
|
||||
<lucide-icon [img]="Upload" [size]="18"></lucide-icon>
|
||||
<span>Ajouter</span>
|
||||
<span>{{ 'common.add' | translate }}</span>
|
||||
} @else {
|
||||
<div class="upload-spinner" aria-label="Upload en cours"></div>
|
||||
<p class="upload-zone-hint">Upload en cours...</p>
|
||||
<div class="upload-spinner" [attr.aria-label]="'imageUploader.uploading' | translate"></div>
|
||||
<p class="upload-zone-hint">{{ 'imageUploader.uploadingHint' | translate }}</p>
|
||||
}
|
||||
<input type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
@@ -29,11 +29,11 @@
|
||||
(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>
|
||||
<p class="upload-zone-title">{{ 'imageUploader.dropTitle' | translate }}</p>
|
||||
<p class="upload-zone-hint">{{ 'imageUploader.dropHint' | translate }}</p>
|
||||
} @else {
|
||||
<div class="upload-spinner" aria-label="Upload en cours"></div>
|
||||
<p class="upload-zone-hint">Upload en cours...</p>
|
||||
<div class="upload-spinner" [attr.aria-label]="'imageUploader.uploading' | translate"></div>
|
||||
<p class="upload-zone-hint">{{ 'imageUploader.uploadingHint' | translate }}</p>
|
||||
}
|
||||
<input type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { LucideAngularModule, Upload, AlertCircle } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ImageService } from '../../services/image.service';
|
||||
import { Image } from '../../services/image.model';
|
||||
|
||||
@@ -19,7 +20,7 @@ import { Image } from '../../services/image.model';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-image-uploader',
|
||||
imports: [LucideAngularModule],
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './image-uploader.component.html',
|
||||
styleUrls: ['./image-uploader.component.scss']
|
||||
})
|
||||
@@ -41,7 +42,7 @@ export class ImageUploaderComponent {
|
||||
private readonly ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
private readonly MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
constructor(private imageService: ImageService) {}
|
||||
constructor(private imageService: ImageService, private translate: TranslateService) {}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
@@ -74,11 +75,11 @@ export class ImageUploaderComponent {
|
||||
|
||||
// Validation cote client (premier filet de securite).
|
||||
if (!this.ALLOWED_MIMES.includes(file.type)) {
|
||||
this.errorMessage = 'Format non supporte (JPEG, PNG, WebP, GIF uniquement).';
|
||||
this.errorMessage = this.translate.instant('imageUploader.unsupportedFormat');
|
||||
return;
|
||||
}
|
||||
if (file.size > this.MAX_BYTES) {
|
||||
this.errorMessage = `Fichier trop volumineux (max ${this.MAX_BYTES / 1024 / 1024} Mo).`;
|
||||
this.errorMessage = this.translate.instant('imageUploader.tooLarge', { max: this.MAX_BYTES / 1024 / 1024 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,8 +92,8 @@ export class ImageUploaderComponent {
|
||||
error: (err) => {
|
||||
this.uploading = false;
|
||||
this.errorMessage = err?.status === 413
|
||||
? 'Fichier refuse par le serveur (trop volumineux).'
|
||||
: 'Echec de l\'upload. Verifiez que le backend et MinIO tournent.';
|
||||
? this.translate.instant('imageUploader.serverRejected')
|
||||
: this.translate.instant('imageUploader.uploadFailed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
|
||||
/**
|
||||
* Sélecteur de langue réutilisable (liste déroulante).
|
||||
*
|
||||
* Réutilisable partout : page Paramètres, en-tête, etc. Délègue tout au
|
||||
* LanguageService — le changement est appliqué à chaud, sans rechargement.
|
||||
*
|
||||
* Usage : <app-language-switcher></app-language-switcher>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-language-switcher',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, FormsModule],
|
||||
template: `
|
||||
<label class="lang-switcher">
|
||||
<span class="lang-switcher__label">{{ 'language.label' | translate }}</span>
|
||||
<select
|
||||
class="lang-switcher__select"
|
||||
[ngModel]="lang.current"
|
||||
(ngModelChange)="lang.use($event)">
|
||||
@for (l of lang.languages; track l.code) {
|
||||
<option [value]="l.code">{{ l.flag }} {{ l.labelKey | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
`,
|
||||
styles: [`
|
||||
.lang-switcher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lang-switcher__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.lang-switcher__select {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class LanguageSwitcherComponent {
|
||||
readonly lang = inject(LanguageService);
|
||||
}
|
||||
@@ -10,11 +10,11 @@
|
||||
[href]="pageUrl(p.id!)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Ouvrir dans un nouvel onglet">
|
||||
[title]="'loreLinkPicker.openInNewTab' | translate">
|
||||
<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">
|
||||
<button type="button" class="chip-remove" (click)="remove(p.id!)" [title]="'loreLinkPicker.removeLink' | translate">
|
||||
<lucide-icon [img]="X" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
@@ -27,7 +27,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher une page à lier..."
|
||||
[placeholder]="'loreLinkPicker.searchPlaceholder' | translate"
|
||||
[(ngModel)]="query"
|
||||
(focus)="dropdownOpen = true"
|
||||
(blur)="onBlur()" />
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
@if (dropdownOpen && query && suggestions.length === 0) {
|
||||
<p class="empty-hint">
|
||||
Aucune page ne correspond
|
||||
{{ 'loreLinkPicker.noMatch' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X, Link2 } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { Page } from '../../services/page.model';
|
||||
|
||||
/**
|
||||
@@ -26,7 +27,7 @@ import { Page } from '../../services/page.model';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lore-link-picker',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './lore-link-picker.component.html',
|
||||
styleUrls: ['./lore-link-picker.component.scss']
|
||||
})
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
@if (orderedSections.length === 0) {
|
||||
<div class="pv-empty">
|
||||
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
|
||||
<p>Cette fiche est encore vide.</p>
|
||||
<p>{{ 'personaView.empty' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TemplateField, ImageLayout } from '../../services/template.model';
|
||||
|
||||
/** Section rendue dans la vue, dans l'ordre du template. Discriminee par `kind`. */
|
||||
@@ -34,7 +35,7 @@ export interface PersonaLike {
|
||||
|
||||
@Component({
|
||||
selector: 'app-persona-view',
|
||||
imports: [LucideAngularModule, ImageGalleryComponent],
|
||||
imports: [LucideAngularModule, ImageGalleryComponent, TranslatePipe],
|
||||
templateUrl: './persona-view.component.html',
|
||||
styleUrls: ['./persona-view.component.scss']
|
||||
})
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
@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.
|
||||
{{ 'playthroughFlagsManager.empty' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -18,7 +17,7 @@
|
||||
<div class="flag-info">
|
||||
<span class="flag-name">{{ r.name }}</span>
|
||||
</div>
|
||||
<span class="flag-value">{{ r.value ? 'vrai' : 'faux' }}</span>
|
||||
<span class="flag-value">{{ (r.value ? 'playthroughFlagsManager.true' : 'playthroughFlagsManager.false') | translate }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/cor
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { CampaignFlagService } from '../../services/campaign-flag.service';
|
||||
import { PlaythroughFlagService } from '../../services/playthrough-flag.service';
|
||||
import { forkJoin } from 'rxjs';
|
||||
@@ -15,7 +16,7 @@ import { forkJoin } from 'rxjs';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-playthrough-flags-manager',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './playthrough-flags-manager.component.html',
|
||||
styleUrls: ['./playthrough-flags-manager.component.scss']
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@if (prerequisites.length === 0) {
|
||||
<p class="prereq-editor-empty">
|
||||
Aucune condition — la quête est immédiatement disponible.
|
||||
{{ 'prerequisiteEditor.empty' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
@switch (p.kind) {
|
||||
<!-- QUEST_COMPLETED -->
|
||||
@case ('QUEST_COMPLETED') {
|
||||
<span class="prereq-row-label">Quête terminée :</span>
|
||||
<span class="prereq-row-label">{{ 'prerequisiteEditor.questCompletedLabel' | translate }}</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>
|
||||
<option value="">{{ 'prerequisiteEditor.noOtherQuest' | translate }}</option>
|
||||
}
|
||||
@for (q of availableQuests; track q) {
|
||||
<option [value]="q.id">{{ q.name }}</option>
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
<!-- SESSION_REACHED -->
|
||||
@case ('SESSION_REACHED') {
|
||||
<span class="prereq-row-label">À partir de la session :</span>
|
||||
<span class="prereq-row-label">{{ 'prerequisiteEditor.sessionReachedLabel' | translate }}</span>
|
||||
<input type="number"
|
||||
class="prereq-input prereq-input-number"
|
||||
min="1"
|
||||
@@ -39,10 +39,10 @@
|
||||
}
|
||||
<!-- FLAG_SET -->
|
||||
@case ('FLAG_SET') {
|
||||
<span class="prereq-row-label">Quand le fait est vrai :</span>
|
||||
<span class="prereq-row-label">{{ 'prerequisiteEditor.flagSetLabel' | translate }}</span>
|
||||
<input type="text"
|
||||
class="prereq-input"
|
||||
placeholder="nom_du_fait"
|
||||
[placeholder]="'prerequisiteEditor.flagNamePlaceholder' | translate"
|
||||
[attr.list]="'prereq-flag-list-' + i"
|
||||
[ngModel]="asFlagSet(p).flagName"
|
||||
(ngModelChange)="onFlagNameChange(i, $event)"
|
||||
@@ -54,7 +54,7 @@
|
||||
</datalist>
|
||||
}
|
||||
}
|
||||
<button type="button" class="prereq-remove" (click)="removeAt(i)" title="Retirer ce prérequis">
|
||||
<button type="button" class="prereq-remove" (click)="removeAt(i)" [title]="'prerequisiteEditor.removePrerequisite' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
@@ -65,20 +65,20 @@
|
||||
<div class="prereq-add">
|
||||
<button type="button" class="btn-secondary prereq-add-btn" (click)="toggleAddMenu()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Ajouter une condition
|
||||
{{ 'prerequisiteEditor.addCondition' | translate }}
|
||||
<lucide-icon [img]="ChevronDown" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
|
||||
@if (addMenuOpen) {
|
||||
<div class="prereq-add-menu">
|
||||
<button type="button" (click)="addPrerequisite('QUEST_COMPLETED')">
|
||||
Après une autre quête
|
||||
{{ 'prerequisiteEditor.menuAfterQuest' | translate }}
|
||||
</button>
|
||||
<button type="button" (click)="addPrerequisite('SESSION_REACHED')">
|
||||
À partir d'une session
|
||||
{{ 'prerequisiteEditor.menuFromSession' | translate }}
|
||||
</button>
|
||||
<button type="button" (click)="addPrerequisite('FLAG_SET')">
|
||||
Quand un fait est vrai
|
||||
{{ 'prerequisiteEditor.menuWhenFlag' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { Chapter, Prerequisite } from '../../services/campaign.model';
|
||||
|
||||
/**
|
||||
@@ -13,7 +14,7 @@ import { Chapter, Prerequisite } from '../../services/campaign.model';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-prerequisite-editor',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './prerequisite-editor.component.html',
|
||||
styleUrls: ['./prerequisite-editor.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { LucideAngularModule, Lock, Circle, Play, CheckCircle2, LucideIconData } from 'lucide-angular';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { QuestStatus } from '../../services/campaign.model';
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,8 @@ export class QuestStatusBadgeComponent {
|
||||
/** Variante visuelle compacte (sans label) — utile pour les listes denses. */
|
||||
@Input() compact = false;
|
||||
|
||||
constructor(private translate: TranslateService) {}
|
||||
|
||||
get icon(): LucideIconData {
|
||||
switch (this.status) {
|
||||
case 'LOCKED': return Lock;
|
||||
@@ -31,11 +34,11 @@ export class QuestStatusBadgeComponent {
|
||||
|
||||
get label(): string {
|
||||
switch (this.status) {
|
||||
case 'LOCKED': return 'Verrouillée';
|
||||
case 'IN_PROGRESS': return 'En cours';
|
||||
case 'COMPLETED': return 'Terminée';
|
||||
case 'LOCKED': return this.translate.instant('questStatusBadge.locked');
|
||||
case 'IN_PROGRESS': return this.translate.instant('questStatusBadge.inProgress');
|
||||
case 'COMPLETED': return this.translate.instant('questStatusBadge.completed');
|
||||
case 'AVAILABLE':
|
||||
default: return 'Disponible';
|
||||
default: return this.translate.instant('questStatusBadge.available');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
@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…).
|
||||
{{ 'roomsEditor.empty' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -21,16 +20,16 @@
|
||||
[ngModel]="r.name"
|
||||
(ngModelChange)="patch(r, { name: $event })"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="Nom de la pièce" />
|
||||
[placeholder]="'roomsEditor.roomNamePlaceholder' | translate" />
|
||||
<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)" />
|
||||
[placeholder]="'roomsEditor.floorPlaceholder' | translate" [title]="'roomsEditor.floorTitle' | translate" />
|
||||
<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">
|
||||
<button type="button" class="btn-icon" (click)="moveUp(r)" [disabled]="i === 0" [title]="'roomsEditor.moveUp' | translate">↑</button>
|
||||
<button type="button" class="btn-icon" (click)="moveDown(r)" [disabled]="i === rooms.length - 1" [title]="'roomsEditor.moveDown' | translate">↓</button>
|
||||
<button type="button" class="btn-icon-danger" (click)="removeRoom(r)" [title]="'roomsEditor.removeRoom' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -39,14 +38,14 @@
|
||||
@if (isExpanded(r.id)) {
|
||||
<div class="room-body">
|
||||
<div class="field">
|
||||
<label>Description (lue/résumée aux joueurs)</label>
|
||||
<label>{{ 'roomsEditor.descriptionLabel' | translate }}</label>
|
||||
<textarea rows="3"
|
||||
[ngModel]="r.description"
|
||||
(ngModelChange)="patch(r, { description: $event })"
|
||||
placeholder="Atmosphère, ce que voient les PJ en entrant…"></textarea>
|
||||
[placeholder]="'roomsEditor.descriptionPlaceholder' | translate"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ennemis du bestiaire</label>
|
||||
<label>{{ 'roomsEditor.bestiaryLabel' | translate }}</label>
|
||||
<app-enemy-link-picker
|
||||
[value]="r.enemyIds ?? []"
|
||||
[availableEnemies]="availableEnemies"
|
||||
@@ -56,41 +55,41 @@
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Ennemis / créatures / boss (texte libre)</label>
|
||||
<label>{{ 'roomsEditor.enemiesLabel' | translate }}</label>
|
||||
<textarea rows="3"
|
||||
[ngModel]="r.enemies"
|
||||
(ngModelChange)="patch(r, { enemies: $event })"
|
||||
placeholder="2 gobelins, 1 ogre boss (PV 60, AC 14)…"></textarea>
|
||||
[placeholder]="'roomsEditor.enemiesPlaceholder' | translate"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Loot / trésors</label>
|
||||
<label>{{ 'roomsEditor.lootLabel' | translate }}</label>
|
||||
<textarea rows="3"
|
||||
[ngModel]="r.loot"
|
||||
(ngModelChange)="patch(r, { loot: $event })"
|
||||
placeholder="50 po, potion de soin, clé en argent…"></textarea>
|
||||
[placeholder]="'roomsEditor.lootPlaceholder' | translate"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Pièges / dangers</label>
|
||||
<label>{{ 'roomsEditor.trapsLabel' | translate }}</label>
|
||||
<textarea rows="2"
|
||||
[ngModel]="r.traps"
|
||||
(ngModelChange)="patch(r, { traps: $event })"
|
||||
placeholder="Trappe (DC 15 Perception), flèches empoisonnées…"></textarea>
|
||||
[placeholder]="'roomsEditor.trapsPlaceholder' | translate"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notes MJ (privé)</label>
|
||||
<label>{{ 'roomsEditor.gmNotesLabel' | translate }}</label>
|
||||
<textarea rows="2"
|
||||
[ngModel]="r.gmNotes"
|
||||
(ngModelChange)="patch(r, { gmNotes: $event })"
|
||||
placeholder="Le boss connaît les PJ par leur nom, indice…"></textarea>
|
||||
[placeholder]="'roomsEditor.gmNotesPlaceholder' | translate"></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
|
||||
{{ 'roomsEditor.exitsLabel' | translate }}
|
||||
</label>
|
||||
@if ((r.branches?.length ?? 0) > 0) {
|
||||
<ul class="branch-list">
|
||||
@@ -99,14 +98,14 @@
|
||||
<input type="text" class="branch-label"
|
||||
[ngModel]="b.label"
|
||||
(ngModelChange)="updateBranch(r, bi, { label: $event })"
|
||||
placeholder="Porte nord" />
|
||||
[placeholder]="'roomsEditor.branchLabelPlaceholder' | translate" />
|
||||
<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>
|
||||
<option value="">{{ 'roomsEditor.noOtherRoom' | translate }}</option>
|
||||
}
|
||||
@for (o of otherRooms(r); track o) {
|
||||
<option [value]="o.id">{{ o.name }}</option>
|
||||
@@ -115,7 +114,7 @@
|
||||
<input type="text" class="branch-condition"
|
||||
[ngModel]="b.condition"
|
||||
(ngModelChange)="updateBranch(r, bi, { condition: $event })"
|
||||
placeholder="Condition (optionnelle)" />
|
||||
[placeholder]="'roomsEditor.branchConditionPlaceholder' | translate" />
|
||||
<button type="button" class="btn-icon-danger" (click)="removeBranch(r, bi)">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
@@ -127,7 +126,7 @@
|
||||
(click)="addBranch(r)"
|
||||
[disabled]="otherRooms(r).length === 0">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
|
||||
Ajouter une sortie
|
||||
{{ 'roomsEditor.addExit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +138,7 @@
|
||||
|
||||
<button type="button" class="btn-primary" (click)="addRoom()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Ajouter une pièce
|
||||
{{ 'roomsEditor.addRoom' | translate }}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Plus, Trash2, ChevronDown, ChevronUp, GitBranch, X } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Room, RoomBranch } from '../../services/campaign.model';
|
||||
import { Enemy } from '../../services/enemy.model';
|
||||
import { EnemyLinkPickerComponent } from '../enemy-link-picker/enemy-link-picker.component';
|
||||
@@ -17,7 +18,7 @@ import { EnemyLinkPickerComponent } from '../enemy-link-picker/enemy-link-picker
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-rooms-editor',
|
||||
imports: [FormsModule, LucideAngularModule, EnemyLinkPickerComponent],
|
||||
imports: [FormsModule, LucideAngularModule, EnemyLinkPickerComponent, TranslatePipe],
|
||||
templateUrl: './rooms-editor.component.html',
|
||||
styleUrls: ['./rooms-editor.component.scss']
|
||||
})
|
||||
@@ -30,6 +31,8 @@ export class RoomsEditorComponent {
|
||||
@Input() campaignId = '';
|
||||
@Output() roomsChange = new EventEmitter<Room[]>();
|
||||
|
||||
constructor(private translate: TranslateService) {}
|
||||
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
@@ -57,7 +60,7 @@ export class RoomsEditorComponent {
|
||||
: 0;
|
||||
const newRoom: Room = {
|
||||
id: this.newId(),
|
||||
name: `Salle ${this.rooms.length + 1}`,
|
||||
name: this.translate.instant('roomsEditor.defaultRoomName', { n: this.rooms.length + 1 }),
|
||||
order: nextOrder,
|
||||
branches: []
|
||||
};
|
||||
@@ -89,7 +92,7 @@ export class RoomsEditorComponent {
|
||||
addBranch(room: Room): void {
|
||||
const others = this.rooms.filter(r => r.id !== room.id);
|
||||
const branch: RoomBranch = {
|
||||
label: 'Porte',
|
||||
label: this.translate.instant('roomsEditor.defaultBranchLabel'),
|
||||
targetRoomId: others[0]?.id ?? '',
|
||||
condition: ''
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
type="button"
|
||||
class="sidebar-title sidebar-title--link"
|
||||
[class.active]="isTitleActive()"
|
||||
title="Retour à l'accueil de la campagne"
|
||||
[title]="'secondarySidebar.backToCampaignHome' | translate"
|
||||
(click)="clickTitle()">
|
||||
<lucide-icon [img]="Home" [size]="14" class="sidebar-title-icon"></lucide-icon>
|
||||
<span class="sidebar-title-text">{{ title }}</span>
|
||||
@@ -149,6 +149,6 @@
|
||||
@if (!isCollapsed) {
|
||||
<div class="resize-handle"
|
||||
(mousedown)="startResize($event)"
|
||||
title="Glissez pour redimensionner"></div>
|
||||
[title]="'secondarySidebar.resizeHandle' | translate"></div>
|
||||
}
|
||||
</aside>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, Home, LucideIconData } from 'lucide-angular';
|
||||
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService } from '../../services/layout.service';
|
||||
import { resolveIcon } from '../../lore/lore-icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-secondary-sidebar',
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
imports: [CommonModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './secondary-sidebar.component.html',
|
||||
styleUrls: ['./secondary-sidebar.component.scss']
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="sip-frame" [style.aspectRatio]="aspectRatio">
|
||||
@if (imageId) {
|
||||
<img [src]="contentUrl(imageId)" alt="" />
|
||||
<button type="button" class="sip-remove" (click)="remove()" title="Retirer l'image">
|
||||
<button type="button" class="sip-remove" (click)="remove()" [title]="'singleImagePicker.removeImage' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
} @else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ImageService } from '../../services/image.service';
|
||||
import { Image } from '../../services/image.model';
|
||||
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
|
||||
@@ -21,7 +22,7 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-single-image-picker',
|
||||
imports: [LucideAngularModule, ImageUploaderComponent],
|
||||
imports: [LucideAngularModule, ImageUploaderComponent, TranslatePipe],
|
||||
templateUrl: './single-image-picker.component.html',
|
||||
styleUrls: ['./single-image-picker.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="tfe">
|
||||
<div class="tfe-header">
|
||||
<h3 class="tfe-label">{{ label }}</h3>
|
||||
<h3 class="tfe-label">{{ label || ('templateFieldsEditor.defaultLabel' | translate) }}</h3>
|
||||
@if (hint) {
|
||||
<p class="tfe-hint">{{ hint }}</p>
|
||||
}
|
||||
@@ -11,10 +11,10 @@
|
||||
<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">
|
||||
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" [title]="'templateFieldsEditor.moveUp' | translate">
|
||||
<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">
|
||||
<button type="button" class="btn-arrow" (click)="moveDown(i)" [disabled]="i === fields.length - 1" [title]="'templateFieldsEditor.moveDown' | translate">
|
||||
<lucide-icon [img]="ArrowDown" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -24,7 +24,7 @@
|
||||
[(ngModel)]="f.name"
|
||||
[name]="'name-' + i"
|
||||
(ngModelChange)="onFieldChanged()"
|
||||
placeholder="Nom du champ (ex: Histoire, PV...)"
|
||||
[placeholder]="'templateFieldsEditor.fieldNamePlaceholder' | translate"
|
||||
/>
|
||||
<select
|
||||
class="tfe-type"
|
||||
@@ -46,7 +46,7 @@
|
||||
}
|
||||
</select>
|
||||
}
|
||||
<button type="button" class="btn-remove" (click)="remove(i)" title="Supprimer ce champ">
|
||||
<button type="button" class="btn-remove" (click)="remove(i)" [title]="'templateFieldsEditor.removeField' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -55,15 +55,15 @@
|
||||
<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>
|
||||
<span>{{ 'templateFieldsEditor.labelsHeader' | translate }}</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">
|
||||
<button type="button" class="btn-arrow-mini" (click)="moveLabelUp(f, li)" [disabled]="li === 0" [title]="'templateFieldsEditor.moveUp' | translate">
|
||||
<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">
|
||||
<button type="button" class="btn-arrow-mini" (click)="moveLabelDown(f, li)" [disabled]="li === (f.labels?.length || 0) - 1" [title]="'templateFieldsEditor.moveDown' | translate">
|
||||
<lucide-icon [img]="ArrowDown" [size]="11"></lucide-icon>
|
||||
</button>
|
||||
<input
|
||||
@@ -72,9 +72,9 @@
|
||||
[ngModel]="lbl"
|
||||
(ngModelChange)="updateLabelAt(f, li, $event)"
|
||||
[name]="'lbl-' + i + '-' + li"
|
||||
placeholder="Ex: FOR, DEX..."
|
||||
[placeholder]="'templateFieldsEditor.labelPlaceholder' | translate"
|
||||
/>
|
||||
<button type="button" class="btn-remove-mini" (click)="removeLabel(f, li)" title="Retirer ce label">
|
||||
<button type="button" class="btn-remove-mini" (click)="removeLabel(f, li)" [title]="'templateFieldsEditor.removeLabel' | translate">
|
||||
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@
|
||||
</div>
|
||||
<button type="button" class="chip chip-mini" (click)="addLabel(f)">
|
||||
<lucide-icon [img]="Plus" [size]="11"></lucide-icon>
|
||||
Ajouter un label
|
||||
{{ 'templateFieldsEditor.addLabel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -91,13 +91,13 @@
|
||||
|
||||
@if (fields.length === 0) {
|
||||
<div class="tfe-empty">
|
||||
Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.
|
||||
{{ 'templateFieldsEditor.empty' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tfe-add">
|
||||
<span class="tfe-add-label">Ajouter :</span>
|
||||
<span class="tfe-add-label">{{ 'templateFieldsEditor.addPrefix' | translate }}</span>
|
||||
@for (s of suggestions; track s) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -111,19 +111,19 @@
|
||||
}
|
||||
<button type="button" class="chip chip-custom" (click)="addBlank('TEXT')">
|
||||
<lucide-icon [img]="Type" [size]="12"></lucide-icon>
|
||||
Texte
|
||||
{{ 'templateFieldsEditor.typeText' | translate }}
|
||||
</button>
|
||||
<button type="button" class="chip chip-custom" (click)="addBlank('NUMBER')">
|
||||
<lucide-icon [img]="Hash" [size]="12"></lucide-icon>
|
||||
Nombre
|
||||
{{ 'templateFieldsEditor.typeNumber' | translate }}
|
||||
</button>
|
||||
<button type="button" class="chip chip-custom" (click)="addBlank('IMAGE')">
|
||||
<lucide-icon [img]="ImageIcon" [size]="12"></lucide-icon>
|
||||
Image(s)
|
||||
{{ 'templateFieldsEditor.typeImage' | translate }}
|
||||
</button>
|
||||
<button type="button" class="chip chip-custom" (click)="addBlank('KEY_VALUE_LIST')">
|
||||
<lucide-icon [img]="ListOrdered" [size]="12"></lucide-icon>
|
||||
Liste cle/valeur
|
||||
{{ 'templateFieldsEditor.typeKeyValue' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash, ListOrdered, X } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,7 @@ import { TemplateField, FieldType, ImageLayout } from '../../services/template.m
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-template-fields-editor',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './template-fields-editor.component.html',
|
||||
styleUrls: ['./template-fields-editor.component.scss']
|
||||
})
|
||||
@@ -37,27 +38,33 @@ export class TemplateFieldsEditorComponent {
|
||||
/** Suggestions de noms de champs (chips ajout rapide). */
|
||||
@Input() suggestions: string[] = [];
|
||||
|
||||
/** Label de la section (ex: "Champs de la fiche PJ"). */
|
||||
@Input() label = 'Champs du template';
|
||||
/** Label de la section (ex: "Champs de la fiche PJ"). Si vide, un libellé par défaut traduit est affiché. */
|
||||
@Input() label = '';
|
||||
|
||||
/** Hint affichee sous le label. */
|
||||
@Input() hint?: string;
|
||||
|
||||
@Output() fieldsChange = new EventEmitter<TemplateField[]>();
|
||||
|
||||
readonly typeOptions: { value: FieldType; label: string }[] = [
|
||||
{ value: 'TEXT', label: 'Texte' },
|
||||
{ value: 'NUMBER', label: 'Nombre' },
|
||||
{ value: 'IMAGE', label: 'Image(s)' },
|
||||
{ value: 'KEY_VALUE_LIST', label: 'Liste cle/valeur' }
|
||||
];
|
||||
constructor(private translate: TranslateService) {}
|
||||
|
||||
readonly layoutOptions: { value: ImageLayout; label: string }[] = [
|
||||
{ value: 'GALLERY', label: 'Galerie' },
|
||||
{ value: 'HERO', label: 'Bandeau' },
|
||||
{ value: 'MASONRY', label: 'Mosaique' },
|
||||
{ value: 'CAROUSEL', label: 'Carrousel' }
|
||||
];
|
||||
get typeOptions(): { value: FieldType; label: string }[] {
|
||||
return [
|
||||
{ value: 'TEXT', label: this.translate.instant('templateFieldsEditor.typeText') },
|
||||
{ value: 'NUMBER', label: this.translate.instant('templateFieldsEditor.typeNumber') },
|
||||
{ value: 'IMAGE', label: this.translate.instant('templateFieldsEditor.typeImage') },
|
||||
{ value: 'KEY_VALUE_LIST', label: this.translate.instant('templateFieldsEditor.typeKeyValue') }
|
||||
];
|
||||
}
|
||||
|
||||
get layoutOptions(): { value: ImageLayout; label: string }[] {
|
||||
return [
|
||||
{ value: 'GALLERY', label: this.translate.instant('templateFieldsEditor.layoutGallery') },
|
||||
{ value: 'HERO', label: this.translate.instant('templateFieldsEditor.layoutHero') },
|
||||
{ value: 'MASONRY', label: this.translate.instant('templateFieldsEditor.layoutMasonry') },
|
||||
{ value: 'CAROUSEL', label: this.translate.instant('templateFieldsEditor.layoutCarousel') }
|
||||
];
|
||||
}
|
||||
|
||||
isDuplicate(field: TemplateField, index: number): boolean {
|
||||
if (!field.name?.trim()) return false;
|
||||
|
||||
@@ -6,22 +6,18 @@
|
||||
>
|
||||
<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>
|
||||
<span [innerHTML]="'updateBanner.message' | translate:{ version: versionChecker.remoteVersion() }"></span>
|
||||
</div>
|
||||
<div class="banner-actions">
|
||||
<button type="button" class="btn-reload" (click)="reload()">
|
||||
Recharger
|
||||
{{ 'updateBanner.reload' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-dismiss"
|
||||
(click)="dismiss()"
|
||||
title="Fermer (sera reaffiche au prochain demarrage)"
|
||||
aria-label="Fermer"
|
||||
[title]="'updateBanner.dismissTitle' | translate"
|
||||
[attr.aria-label]="'updateBanner.dismissAria' | translate"
|
||||
>
|
||||
<lucide-icon [img]="X" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LucideAngularModule, RefreshCw, X } from 'lucide-angular';
|
||||
import { VersionCheckerService } from '../../services/version-checker.service';
|
||||
|
||||
@@ -10,7 +11,7 @@ import { VersionCheckerService } from '../../services/version-checker.service';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-update-banner',
|
||||
imports: [LucideAngularModule],
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './update-banner.component.html',
|
||||
styleUrls: ['./update-banner.component.scss']
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user